From d724b438bfefd37cc5b3a6f8133209045cec40ba Mon Sep 17 00:00:00 2001 From: BrandDead <1.74973198e+08+BrandDead@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:32:32 +0000 Subject: [PATCH 01/11] docs: add modern gameplay completion plan and skill --- docs/MODERN_GAMEPLAY_COMPLETION_PLAN.md | 138 ++++++++++++++++++ skills/dealt-slide-game-completion/SKILL.md | 71 +++++++++ .../references/project-contract.md | 54 +++++++ .../references/quality-gauntlet.md | 46 ++++++ .../templates/IMPLEMENTATION_PLAN.md | 74 ++++++++++ 5 files changed, 383 insertions(+) create mode 100644 docs/MODERN_GAMEPLAY_COMPLETION_PLAN.md create mode 100644 skills/dealt-slide-game-completion/SKILL.md create mode 100644 skills/dealt-slide-game-completion/references/project-contract.md create mode 100644 skills/dealt-slide-game-completion/references/quality-gauntlet.md create mode 100644 skills/dealt-slide-game-completion/templates/IMPLEMENTATION_PLAN.md diff --git a/docs/MODERN_GAMEPLAY_COMPLETION_PLAN.md b/docs/MODERN_GAMEPLAY_COMPLETION_PLAN.md new file mode 100644 index 0000000..19cc1fc --- /dev/null +++ b/docs/MODERN_GAMEPLAY_COMPLETION_PLAN.md @@ -0,0 +1,138 @@ +# DEALT / SLIDE — Modern Gameplay Completion Plan + +**Author:** Manus AI +**Prepared:** 2026-08-26 +**Baseline:** `main-tL2525` at `c178861` +**Implementation branch:** `feat/modern-gameplay-completion` + +## Product Direction + +**DEALT / SLIDE remains an iOS-like strategy game first.** The player scouts and claims a real-world-inspired block, deploys a crew, assigns product and equipment, manages cash, heat, morale, injuries, and retaliation, then enters a modern action encounter when a strategic decision turns violent. The first-person and third-person modes are not separate games; they are camera-and-input adapters over the same deterministic encounter session that the tactical view already uses. + +> The authoritative state must stay independent of presentation. MAP and the block view create an encounter preparation; the combat session owns legal commands and outcomes; tactical, first-person, and third-person renderers consume the same snapshot and emit the same typed commands; one idempotent result returns to the block, crew, heat, morale, and Shoebox systems. + +## What Comes After Stripe + +Stripe is not the next gameplay bottleneck. The repository already has a broad set of mini-games and economy surfaces, but the remaining work is primarily **integration, opposition, presentation, and release discipline**. + +| Priority | Workstream | Why it is next | Existing work to reuse | +|---|---|---|---| +| P0 | Recover and reconcile `copilot/dev-oplan` | Six unmerged commits already implement major parts of issues #78–#81 and the beta gate. Rebuilding them would duplicate tested work. | Ghost Crew engine/store, 17-card Block DNA library, manifest/runtime cleanup, smoke tests, release checklist | +| P0 | Lock the strategy-to-action contract | Every combat presentation must change the same empire state exactly once. This prevents disconnected mini-games and duplicate rewards. | `prepareEncounter`, `combatSession`, `CombatCommand`, `CombatResult`, `BlockModeView.applyEncounterResult` | +| P1 | Ship one modern 3D action vertical slice | The current Phaser encounter is deterministic and integrated but not a modern FPS/TPS experience. One hero block should support tactical, first-person, and third-person play before adding more modes. | Unified encounter preparation and result flow, Block DNA, Las Olas art direction, drive-by HUD patterns | +| P1 | Make the rival crew visibly persistent | Strategy needs an opponent that claims turf, earns, remembers attacks, and triggers the normal encounter path. | Recovered Ghost Crew branch plus existing notification/map stores | +| P2 | Complete the authored Block DNA path | Blocks must look and play differently without relying on Mapbox at combat time. | Main has 8 cards; the recovered branch grows this to 17 | +| P2 | Finish the canonical visual stack | The strategy view remains a 2.5D tactical diorama; the action view projects the same DNA into a 3D encounter. | V3 renderer, projection layer, asset resolver, runtime audit | +| P3 | Close the release umbrella | Outside testing should start only after browser smoke, performance, persistence, error-state, and release-checklist evidence is attached. | 652 frontend tests, 42 backend tests, asset audit, recovered beta smoke tests | + +## Target Gameplay Architecture + +```mermaid +flowchart LR + MAP[MAP / Block Strategy] --> PREP[prepareEncounter] + CREW[Contacts / Loadout] --> PREP + DNA[Block DNA] --> PREP + RIVAL[Ghost Crew] --> PREP + PREP --> SESSION[Deterministic CombatSession] + + SESSION --> TACTICAL[2.5D Tactical Adapter] + SESSION --> FPS[First-Person Adapter] + SESSION --> TPS[Third-Person Adapter] + + TACTICAL -->|CombatCommand| SESSION + FPS -->|CombatCommand| SESSION + TPS -->|CombatCommand| SESSION + + SESSION -->|Idempotent CombatResult| APPLY[BlockModeView Result Boundary] + APPLY --> BLOCK[Territory / Income] + APPLY --> GANG[Crew / Injury / Morale] + APPLY --> HEAT[Heat / Raid Risk] + APPLY --> VAULT[Shoebox Ledger] + APPLY --> RIVAL +``` + +The engine boundary is deliberate. React owns the OS shell, briefing, HUD, mode selection, and result application. The 3D engine owns the canvas, camera, input, scene projection, animation, and effects. The deterministic TypeScript combat session remains framework-agnostic and must never import React, Phaser, Babylon, the stores, or persistence services. + +## Camera and Control Contract + +| Mode | Purpose | Input | Shared-state behavior | +|---|---|---|---| +| Tactical | Position the selected member, inspect cover, command the squad | Click/tap selection and adjacent-grid commands | Emits existing move, fire, reload, interact, and retreat commands | +| First person | High-intensity direct control of the selected shooter | WASD, mouse look, aim, fire, reload, interact, camera toggle | Quantizes player movement and target selection into legal combat commands; presentation never applies damage directly | +| Third person | Situational movement and cover readability | WASD, orbit/shoulder camera, aim, fire, reload, interact, camera toggle | Uses the same command adapter and current selected combatant | + +The first implementation should support one controllable crew member, one hero DNA block, a small opposition squad, cover, aim/fire/reload, extraction, camera switching, HUD feedback, pause/exit, and deterministic demo mode. Squad command shortcuts and deeper AI can follow after this vertical slice is stable. + +## Delivery Plan + +### Milestone 0 — Recover Existing Work + +Rebase or cherry-pick the six unique `copilot/dev-oplan` commits onto the current default branch. Resolve conflicts against the already-merged playable map and bullet-camera work. Keep the pieces that pass current tests, remove superseded duplication, and preserve the newer map/encounter integration. + +Acceptance requires the 17-card DNA library, Ghost Crew state and tests, runtime-manifest cleanup, beta smoke tests, and release checklist to pass on the current main baseline. Issues #78–#81 must then be re-evaluated against code rather than their stale checkbox lists. + +### Milestone 1 — Shared Action Adapter + +Introduce a presentation-neutral action adapter that tracks the selected combatant, translates continuous movement into legal adjacent-grid commands, resolves aim targets from the current snapshot, advances simulation ticks, and exposes stable HUD data. Both the Phaser tactical scene and the new 3D scene should use this adapter over time; the first slice may leave Phaser on its existing direct path while tests establish parity. + +Acceptance requires deterministic command sequencing, no duplicate result commitment, safe pause/exit, and unit tests for movement, firing, reload, camera-independent snapshots, and result idempotency. + +### Milestone 2 — Modern 3D Encounter Vertical Slice + +Add a full-screen 3D encounter surface under `BlockModeView` as an additional action presentation, initially for one Las Olas-derived DNA card. Generate an art-direction target and a small runtime art set, project DNA zone rows into simple procedural street geometry, add readable crew/opposition proxies, cover, lighting, particles, weapon feedback, a first-person camera, a third-person shoulder camera, and a deterministic `?demo` path. + +Acceptance requires a visible authored place rather than a gray blockout, responsive movement and aim, camera switching, firing/reload feedback, opposition return fire, extraction, a single committed `CombatResult`, cleanup on unmount, and no loss of the existing tactical encounter. + +### Milestone 3 — Rival and Territory Integration + +Wire Ghost Crew ownership into the playable map and attack flow. A player attack on ghost turf records a grudge, and a ghost action against player turf creates a visible threat that enters the same encounter preparation and result boundary. Move long-term rival persistence from browser-only storage to the existing backend/Supabase contract after the local deterministic loop is stable. + +Acceptance requires a three-tick idle scenario to change the map and City Feed, a player counterattack to reclaim turf through normal combat, and persistence across reloads without duplicating blocks or feed events. + +### Milestone 4 — Visual, Asset, and Performance Gate + +Keep the strategy layer’s 2.5D diorama as the canonical overview while extending the art bible with a matching action-camera contract. Register the seven current orphan assets, remove stale `.png` references, finish real alpha and defringe work, keep runtime assets under the 20 MB gate, split circular chunks, and establish browser performance budgets for the 3D scene. + +Acceptance requires zero asset-audit errors, no missing runtime images, no placeholder emoji or legacy sprite path in player-facing block/combat views, stable cleanup after repeated mode entry, and screenshot evidence from tactical, first-person, and third-person views. + +### Milestone 5 — Beta Gate and Repository Closure + +Run frontend typecheck, Vitest, asset audit, production build, backend Pytest, browser smoke, and deterministic visual capture. Update the release checklist and project log. Close or rewrite stale issues based on verified acceptance, prune merged/stale branches while preserving any documented rollback anchor, and merge only through a reviewed pull request. + +## Issue Reconciliation + +| Issue | Current verified state | Completion action | +|---|---|---| +| #78 | Core manifest resolver is already live; legacy fallback remains only for missing art/compatibility. | Land recovered manifest changes, verify player-facing consumers, generate any missing role states, then close or narrow the issue. | +| #79 | Runtime audit passes at 5.73 MB with zero errors and seven orphan warnings. | Register/remove the seven orphans, verify alpha/fringe acceptance, and keep the CI gate. | +| #77 | Projection and V3 renderer exist, but the art/camera contract is not complete and action cameras are absent. | Publish one combined 2.5D strategy plus 3D action camera contract and prove it on the hero block. | +| #80 | Main has 8 authored cards; recovered branch has 17; target remains 30–40. | Land 17 now, tune and visually validate 5–10, then expand deliberately to 30–40. | +| #81 | Main only has lightweight retaliation; recovered branch has a substantial local Ghost Crew implementation. | Reconcile it, connect attacks/results, then add backend persistence. | +| #45 | Tests are healthy, but production build is memory-heavy and browser/action evidence is incomplete. | Treat as the final umbrella after the above issues and the 3D vertical slice are verified. | + +## Current Quality Baseline + +| Gate | Result on `c178861` | +|---|---| +| Frontend typecheck | Passed | +| Frontend tests | 40 files, 652 tests passed | +| Frontend lint | Passed with warnings only | +| Runtime asset audit | Passed; 5.73 MB of 20 MB, seven orphan warnings | +| Backend tests | 42 passed in offline mock mode | +| Production build | Transformed 2,437 modules, then was terminated by sandbox memory pressure during chunk rendering; existing circular chunk warnings remain | +| Default-branch CI | Latest run passed | + +## Non-Goals for the First Slice + +The first slice will not attempt a broad open-world shooter, multiplayer netcode, fully simulated vehicles, realistic weapon replication, or dozens of 3D maps. It will not replace the strategy game with a shooter. The objective is a coherent loop in which one strategic decision becomes one polished action encounter and returns durable consequences to the empire. + +## References + +[1]: https://github.com/BrandDead/slide/issues/45 +[2]: https://github.com/BrandDead/slide/issues/77 +[3]: https://github.com/BrandDead/slide/issues/78 +[4]: https://github.com/BrandDead/slide/issues/79 +[5]: https://github.com/BrandDead/slide/issues/80 +[6]: https://github.com/BrandDead/slide/issues/81 +[7]: https://github.com/BrandDead/slide/pull/118 +[8]: https://github.com/BrandDead/slide/blob/main-tL2525/docs/PROJECT_LOG.md diff --git a/skills/dealt-slide-game-completion/SKILL.md b/skills/dealt-slide-game-completion/SKILL.md new file mode 100644 index 0000000..0d4a4da --- /dev/null +++ b/skills/dealt-slide-game-completion/SKILL.md @@ -0,0 +1,71 @@ +--- +name: dealt-slide-game-completion +description: Complete and modernize an existing DEALT/SLIDE-style browser strategy game whose phone-app mini-games share one empire state. Use when auditing branches/issues/PRs, recovering unmerged work, finishing the strategy loop, adding persistent rival crews, expanding Block DNA, or building integrated tactical, first-person, and third-person action modes without disconnecting economy, territory, crew, heat, morale, inventory, or persistence. +--- + +# DEALT / SLIDE Game Completion + +Finish the game by **integrating before inventing**, preserving one authoritative empire state, and proving progress through playable pixels and deterministic tests. Treat the iOS-like desktop as the strategy shell and every mini-game as an input/output adapter over shared state. + +Read `references/project-contract.md` before planning. Read `references/quality-gauntlet.md` before implementation or verification. Use `templates/IMPLEMENTATION_PLAN.md` when the repository lacks a current resumable plan. + +## Workflow + +Follow these stages in order. If the project already has current `PLAN.md`, `STRUCTURE.md`, `MEMORY.md`, or `ASSETS.md` files, read them and resume from the first incomplete stage. + +1. **Audit before editing.** Inspect the default branch, open issues, pull requests, remote branches, recent history, CI runs, repository instructions, project log, package manifests, and current build health. Compare each open issue against the code; issue checkboxes may be stale. +2. **Recover existing work.** Identify unmerged branches whose commits already implement roadmap items. Rebase or cherry-pick compatible work onto a fresh feature branch before creating replacements. Preserve newer integration work on the default branch and remove superseded duplication. +3. **Map the live loop.** Trace the player path from OS shell → MAP/block selection → crew/loadout → encounter preparation → action mode → atomic result → territory/crew/heat/morale/vault persistence. Name the exact store, service, and component that owns each boundary. +4. **Define one vertical slice.** Choose one hero block, one named rival, one playable crew loadout, one objective, and one action presentation. Write explicit success/failure behavior, shared-state inputs, typed output, test criteria, screenshot criteria, and known deferrals. +5. **Stabilize the authoritative model.** Keep combat simulation and outcome resolution in framework-agnostic TypeScript. The model must accept typed commands, expose snapshots/events, use deterministic RNG, and emit an idempotent result. Never let a renderer directly apply damage, cash, heat, morale, territory, or inventory changes. +6. **Build presentation adapters.** Keep the existing tactical view working. Add first-person and third-person cameras as input/rendering adapters over the same session. Continuous movement may be visually smooth, but legal simulation changes must flow through the shared command contract. +7. **Generate and wire art.** Read the `imagegen` and `game-dev` skills. Generate a visual target and a small coherent asset set. Record prompts, source paths, runtime URLs, registration, and fallback behavior in `ASSETS.md`. Do not ship a gray blockout or disconnected asset gallery. +8. **Integrate consequences.** Apply the encounter result once through a single boundary. Update the block, crew injuries/arrests, heat/raid risk, morale, pending income or loot, rival grudge/territory, and Shoebox ledger without duplicate rewards. +9. **Run the quality gauntlet.** Type-check, test pure models, run integration and persistence tests, audit assets, build production, and capture deterministic tactical/FPS/TPS evidence. Fix the largest verified gap, rerun, and log the round. +10. **Reconcile the repository.** Update the project log, release checklist, and roadmap. Close, rewrite, or split stale issues based on verified acceptance. Push the feature branch and open a focused pull request; do not commit directly to a protected default branch. + +## Non-Negotiable Architecture + +> **Strategy state is authoritative; action modes are adapters.** + +Use this flow: + +`MAP + CREW + BLOCK DNA + RIVAL → EncounterPreparation → CombatSession → Tactical/FPS/TPS adapters → CombatResult → one result boundary → empire stores/persistence` + +Keep these ownership rules: + +| Layer | Owns | Must not own | +|---|---|---| +| React OS shell | Navigation, briefing, HUD, settings, modal consequences | Simulation damage or duplicate economy state | +| Strategy stores/services | Blocks, crew, money, inventory, heat, morale, rival state, persistence | Per-frame camera or renderer state | +| Combat model | Legal commands, ticks, RNG, damage, objectives, result | React, Phaser, Babylon, stores, APIs | +| Tactical/FPS/TPS adapter | Camera, input, selection, interpolation, effects | Authoritative rewards or permanent state | +| Result boundary | Atomic, idempotent empire updates | Replaying presentation events as new outcomes | + +## Modern Action Slice + +Implement one controllable crew member first. Require movement, aim, fire, reload, cover readability, return-fire telegraph, extraction or retreat, pause/exit, first-/third-person camera switching, and a deterministic demo route. Keep the tactical mode available as a fallback and debugging reference. + +Default to a Babylon.js canvas for a new 3D adapter when the repository has no established 3D engine. Keep React as the host shell and plain TypeScript as the gameplay layer. Reuse existing Phaser/canvas modes rather than rewriting them unless the audit proves removal is safer. + +## Persistent Rival Slice + +Start with one named rival crew that has personality, treasury, roster, owned DNA-backed blocks, grudge, and event history. On each deterministic world tick, choose among claim, reinforce, attack, and lay low using real costs and income. Surface visible City Feed consequences. Route contested blocks into the normal encounter path. Move from browser persistence to backend/Supabase only after the deterministic local loop is verified. + +## Completion Evidence + +Do not claim completion from code alone. Require all of the following: + +| Evidence | Minimum bar | +|---|---| +| Repository | Feature branch, clean diff, reconciled unmerged work, updated project log | +| Model | Deterministic command tests and idempotent result tests | +| Integration | Strategy → action → strategy path updates shared state once | +| Visual | Tactical, first-person, and third-person captures from the same hero block | +| Reliability | Repeated enter/exit, pause/resume, unmount cleanup, no duplicate loops | +| Assets | Manifest-only runtime assets, no missing files, budget gate passes | +| Build | Typecheck, unit/integration tests, backend tests when applicable, production build or documented environment limitation | + +## Guardrails + +Do not add a home-screen icon for a mode that cannot produce a typed shared-state outcome. Do not create a separate economy, crew roster, territory copy, or rival state inside a mini-game. Do not let Mapbox availability block core play. Do not expand to many maps before one hero block passes the action and consequence gates. Do not treat critic feedback as truth until checked against pixels, probes, or code. Do not merge an unreviewed large branch into a protected default branch. diff --git a/skills/dealt-slide-game-completion/references/project-contract.md b/skills/dealt-slide-game-completion/references/project-contract.md new file mode 100644 index 0000000..fbe1d84 --- /dev/null +++ b/skills/dealt-slide-game-completion/references/project-contract.md @@ -0,0 +1,54 @@ +# Project Contract + +Read this after triggering the skill and before writing a plan. + +## Product Contract + +Treat the phone/desktop shell as the strategy layer. A mini-game is complete only when it reads declared shared inputs and emits a typed outcome that changes at least one durable system: territory, crew, economy, inventory, heat, morale, progression, rival state, or persistence. + +| Surface | Reads | Writes | +|---|---|---| +| MAP / block | Blocks, crew availability, rivals, vault summary, Block DNA | Claim, placement, target, collect, encounter intent | +| Contacts / HQ | Vault, roster, inventory, status | Recruit, equip, assign, heal/bail, availability | +| Alchemy / dealing | Inventory, recipes, dealer skill, demand | Product, cash, heat, customer risk, XP | +| Tactical/FPS/TPS encounter | Encounter preparation, selected crew, Block DNA, rival | One idempotent combat result | +| Shoebox | Street cash, pending income, payroll, ledger | Money-router transactions only | +| Rival tick | DNA library, rival treasury/roster/turf/grudge, player blocks | Claim, reinforce, attack intent, lay-low, feed event | + +## Authoritative Encounter Boundary + +Keep `EncounterPreparation`, `CombatCommand`, `CombatSnapshot`, and `CombatResult` independent from presentation frameworks. Add fields through versioned, tested contracts rather than per-renderer side channels. + +Require each command to include a monotonically increasing sequence number. Reject stale or duplicate commands. Use seeded RNG for all hit, spawn, and AI decisions that affect outcomes. Keep renderer-only interpolation, camera motion, particles, decals, and audio outside the model. + +Commit a result through one guarded function. Include an idempotency key. Record applied result keys or otherwise guarantee that React rerenders, duplicate callbacks, route remounts, and retries cannot double-pay rewards or double-apply injuries. + +## Camera Adapter Contract + +Represent the selected combatant and current snapshot in a presentation-neutral controller. Let tactical, first-person, and third-person views share selection, command sequencing, aim-target resolution, tick advancement, HUD derivation, pause state, and result observation. + +First-person and third-person movement may interpolate continuously in the scene. Convert meaningful grid-boundary transitions into legal adjacent-cell move commands. Do not teleport the authoritative actor because the camera crossed a visual threshold. + +## Block DNA Contract + +Use Block DNA as the stable bridge between real-world-inspired selection and authored gameplay. Each card should eventually declare: + +| Category | Required data | +|---|---| +| Identity | ID, name, address/reference label, tier, tags | +| Strategy | Income multiplier, heat risk/decay, morale, capacity, event modifiers | +| Tactical | Zone rows, passability, cover, exposure, spawn/extraction candidates | +| Presentation | Strategy plate, action palette, lighting, projection profile, frontage/props | +| Persistence | Stable DNA ID stored with the claimed block | + +Mapbox or another map provider may improve search and recon, but combat must resolve from authored DNA without a live map call. + +## Rival Contract + +Use the same costs and income scale as the player. Persist identity, personality, treasury, roster, claimed DNA IDs, owned block IDs, grudge, event history, and last processed tick. Prevent duplicate claims and make ticks idempotent. Surface every meaningful move through the City Feed or map threat UI. + +Local persistence is acceptable for the first deterministic slice. Before release, move authoritative rival state and processed tick IDs to the backend/Supabase path so multiple devices and sessions cannot diverge. + +## Integration Order + +Recover compatible unmerged work first. Stabilize typed boundaries second. Implement one hero action slice third. Connect rival attacks and result consequences fourth. Expand content only after the slice passes browser, persistence, and performance gates. diff --git a/skills/dealt-slide-game-completion/references/quality-gauntlet.md b/skills/dealt-slide-game-completion/references/quality-gauntlet.md new file mode 100644 index 0000000..67f0ce9 --- /dev/null +++ b/skills/dealt-slide-game-completion/references/quality-gauntlet.md @@ -0,0 +1,46 @@ +# Quality Gauntlet + +Use this loop for every substantial gameplay or visual milestone. + +## Round Loop + +1. Build and run smoke tests before trusting a screenshot. A renderer or shader error can still leave a plausible partial frame. +2. Start from a fixed seed, hero Block DNA card, crew loadout, rival, viewport, and query flag such as `?demo=1`. +3. Capture the same evidence set: strategy block, tactical encounter, first person, third person, firefight feedback, low-health or injury state, and resolved result back in strategy. +4. Inspect the pixels directly. When useful, ask independent critics to identify the largest gap, but verify every diagnosis against code, probes, or measurements. +5. Fix the largest verified gap rather than many cosmetic symptoms. +6. Re-run the narrow tests, then the full quality gates. +7. Record the round, screenshots, measurements, change, and rollback commit in `MEMORY.md` or the project log. + +## Deterministic Evidence Set + +| Capture | Must show | +|---|---| +| Strategy | Authored block identity, crew placement, income/heat/morale, rival ownership or threat | +| Tactical | Same block geometry, legal grid/cover readability, objective, crew/opposition | +| First person | Weapon/readable hands or proxy, aim target, cover depth, HUD, location identity | +| Third person | Controlled actor, shoulder/orbit framing, cover relationship, objective direction | +| Combat feedback | Fire/reload state, impact/hit feedback, return-fire telegraph, ammo/health changes | +| Result | Outcome summary and exactly-once updates to empire state and ledger | + +## Model and Integration Gates + +Test deterministic session creation, command sequencing, legal/illegal movement, line of sight, fire cadence, reload timing, opponent decisions, objective completion, retreat/overrun, and result idempotency. Test that camera switching does not reset the session or duplicate commands. + +Test the full strategy path with controlled fixtures. Confirm that one result updates the block, selected crew, heat, morale, pending income or loot, rival state, and ledger as intended. Remount the result component and retry the callback to prove the idempotency guard. + +## Lifecycle Gates + +Enter and exit the 3D mode repeatedly. Verify that the engine, scene, event listeners, intervals, animation frame loops, pointer lock, and audio nodes are disposed. Confirm that React development double-mount does not create duplicate engines or ticks. Pause on pointer-lock loss and never silently reacquire it from an arbitrary click. + +## Performance Gates + +Measure rather than infer. Record boot time, frame-time median and p95, draw calls, active meshes, texture memory estimate, and long-session resource counts. Use object pools for transient combat effects. Keep the UI at native resolution if the 3D render scale changes. + +Do not pursue a broad custom AAA renderer before the integrated gameplay slice works. Prioritize stable input, readable lighting, coherent art, bounded effects, and consequences over exotic post-processing. + +## Repository Gates + +Run the repository’s own commands, normally including frontend typecheck, tests, asset audit, lint, production build, backend tests, and browser smoke. Treat environment limits separately from code failure and preserve logs. + +Before pushing, inspect the diff for generated artifacts, secrets, large unregistered assets, direct cash mutations, duplicate stores, stale temporary files, and accidental edits to the protected default branch. Update the project log and release checklist, then open a focused pull request with evidence and remaining limitations. diff --git a/skills/dealt-slide-game-completion/templates/IMPLEMENTATION_PLAN.md b/skills/dealt-slide-game-completion/templates/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..0280562 --- /dev/null +++ b/skills/dealt-slide-game-completion/templates/IMPLEMENTATION_PLAN.md @@ -0,0 +1,74 @@ +# Game Completion Implementation Plan + +**Branch:** `` +**Baseline:** `@` +**Hero Block DNA:** `` +**Named Rival:** `` +**Prepared:** `` + +## Vertical Slice + +Describe the player decision, playable action, success state, failure state, expected duration, and the durable empire consequence in one paragraph. + +## Existing Work to Recover + +| Branch/PR/Commit | Capability | Reuse decision | Conflict or verification needed | +|---|---|---|---| +| | | | | + +## Shared-State Contract + +| Input | Owner | Read path | +|---|---|---| +| Block and DNA | | | +| Selected crew/loadout | | | +| Rival/opposition | | | +| Heat/morale/inventory | | | + +| Output | Owner | Apply-once path | +|---|---|---| +| Combat result | | | +| Crew consequences | | | +| Territory/rival consequences | | | +| Economy/ledger consequences | | | + +## Risk Slices + +| Risk | Smallest proof | Verification | +|---|---|---| +| Shared session across tactical/FPS/TPS | | | +| First-person input and pointer-lock lifecycle | | | +| Third-person camera and collision | | | +| 3D scene projection from Block DNA | | | +| Result idempotency | | | +| Repeated mount/unmount cleanup | | | + +## Implementation Order + +| Order | Deliverable | Files/systems | Acceptance evidence | +|---|---|---|---| +| 1 | Recover compatible work | | | +| 2 | Stabilize model/adapter | | | +| 3 | Generate visual target/assets | | | +| 4 | Build hero 3D scene | | | +| 5 | Add first-/third-person controls | | | +| 6 | Integrate result consequences | | | +| 7 | Run gauntlet and refine | | | +| 8 | Update issues/log/release checklist | | | + +## Quality Gates + +| Gate | Command or method | Result | +|---|---|---| +| Typecheck | | | +| Unit/integration tests | | | +| Asset audit | | | +| Production build | | | +| Backend tests | | | +| Browser smoke | | | +| Tactical/FPS/TPS captures | | | +| Repeated enter/exit | | | + +## Deferred Work + +List only deliberate non-goals with a reason, dependency, and proposed follow-up issue. From 2f04bb00f623a767bfc728f84d2a0a046478ad6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:22:42 +0000 Subject: [PATCH 02/11] feat(assets): register 7 orphan runtime assets into manifest, wire audit as CI gate, fix stale .png refs Co-authored-by: BrandDead <174973198+BrandDead@users.noreply.github.com> --- .github/workflows/ci.yml | 3 + frontend/src/App.css | 2 +- frontend/src/assets/runtimeManifest.json | 128 +++++++++++++++++- .../src/components/alchemy/AlchemyLab.css | 2 +- frontend/src/components/casino/Casino.css | 2 +- .../src/components/compliance/AgeGate.tsx | 2 +- frontend/src/components/contacts/Contacts.css | 2 +- .../src/components/gang/MemberCreation.css | 2 +- .../src/components/layout/SplashScreen.tsx | 2 +- frontend/src/components/settings/Settings.css | 2 +- frontend/src/styles/theme.css | 2 +- 11 files changed, 139 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c68a92..56ff804 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Lint run: npm run lint + - name: Asset audit + run: npm run assets:audit + - name: Build run: npx vite build env: diff --git a/frontend/src/App.css b/frontend/src/App.css index c41dd71..4ff3712 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -67,7 +67,7 @@ body { content: ''; position: absolute; inset: 0; - background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.png') center/cover no-repeat; + background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.webp') center/cover no-repeat; opacity: 0.15; filter: brightness(0.5); } diff --git a/frontend/src/assets/runtimeManifest.json b/frontend/src/assets/runtimeManifest.json index 795dff1..deea7db 100644 --- a/frontend/src/assets/runtimeManifest.json +++ b/frontend/src/assets/runtimeManifest.json @@ -1,7 +1,7 @@ { "generatedAt": "2026-08-06T02:36:25.102Z", "budgetMB": 20, - "totalBytes": 5605208, + "totalBytes": 6007902, "qualityPass": "standard", "entries": [ { @@ -904,6 +904,24 @@ }, "alphaRepaired": true }, + { + "id": "generated.environments.street.block_lasolas_driveby_street_v001", + "runtimePath": "/assets/runtime/generated/environments/street/block_lasolas_driveby_street_v001.webp", + "sourcePath": "art-src/runtime/generated/environments/street/block_lasolas_driveby_street_v001.webp", + "class": "env-street", + "role": null, + "state": null, + "width": 1920, + "height": 1080, + "bytes": 93644, + "hasAlpha": false, + "fringeRatio": 0, + "pivot": { + "x": 0.5, + "y": 1 + }, + "alphaRepaired": false + }, { "id": "generated.environments.street.block_stripplaza_day_street_v001", "runtimePath": "/assets/runtime/generated/environments/street/block_stripplaza_day_street_v001.webp", @@ -940,6 +958,24 @@ }, "alphaRepaired": false }, + { + "id": "generated.environments.topdown.block_lasolas_topdown_v001", + "runtimePath": "/assets/runtime/generated/environments/topdown/block_lasolas_topdown_v001.webp", + "sourcePath": "art-src/runtime/generated/environments/topdown/block_lasolas_topdown_v001.webp", + "class": "env-topdown", + "role": null, + "state": null, + "width": 1536, + "height": 864, + "bytes": 146248, + "hasAlpha": false, + "fringeRatio": 0, + "pivot": { + "x": 0.5, + "y": 0.5 + }, + "alphaRepaired": false + }, { "id": "generated.environments.topdown.block_stripplaza_topdown_v001", "runtimePath": "/assets/runtime/generated/environments/topdown/block_stripplaza_topdown_v001.webp", @@ -1012,6 +1048,24 @@ }, "alphaRepaired": true }, + { + "id": "generated.ui.contact_card_shooter_v001", + "runtimePath": "/assets/runtime/generated/ui/contact_card_shooter_v001.webp", + "sourcePath": "art-src/runtime/generated/ui/contact_card_shooter_v001.webp", + "class": "ui-overlay", + "role": "shooter", + "state": null, + "width": 576, + "height": 1024, + "bytes": 29810, + "hasAlpha": false, + "fringeRatio": 0, + "pivot": { + "x": 0.5, + "y": 0.5 + }, + "alphaRepaired": false + }, { "id": "generated.ui.fps_hud_elements_v001", "runtimePath": "/assets/runtime/generated/ui/fps_hud_elements_v001.webp", @@ -1030,6 +1084,24 @@ }, "alphaRepaired": true }, + { + "id": "generated.ui.hud_overlay_v001", + "runtimePath": "/assets/runtime/generated/ui/hud_overlay_v001.webp", + "sourcePath": "art-src/runtime/generated/ui/hud_overlay_v001.webp", + "class": "ui-overlay", + "role": null, + "state": null, + "width": 576, + "height": 1024, + "bytes": 34100, + "hasAlpha": false, + "fringeRatio": 0, + "pivot": { + "x": 0.5, + "y": 0.5 + }, + "alphaRepaired": false + }, { "id": "generated.ui.icons.app_icon_set_v001", "runtimePath": "/assets/runtime/generated/ui/icons/app_icon_set_v001.webp", @@ -1066,6 +1138,60 @@ }, "alphaRepaired": true }, + { + "id": "generated.ui.loading_screen_v001", + "runtimePath": "/assets/runtime/generated/ui/loading_screen_v001.webp", + "sourcePath": "art-src/runtime/generated/ui/loading_screen_v001.webp", + "class": "ui-overlay", + "role": null, + "state": null, + "width": 1024, + "height": 576, + "bytes": 31078, + "hasAlpha": false, + "fringeRatio": 0, + "pivot": { + "x": 0.5, + "y": 0.5 + }, + "alphaRepaired": false + }, + { + "id": "generated.ui.member_creation_screen_v001", + "runtimePath": "/assets/runtime/generated/ui/member_creation_screen_v001.webp", + "sourcePath": "art-src/runtime/generated/ui/member_creation_screen_v001.webp", + "class": "ui-overlay", + "role": null, + "state": null, + "width": 576, + "height": 1024, + "bytes": 39036, + "hasAlpha": false, + "fringeRatio": 0, + "pivot": { + "x": 0.5, + "y": 0.5 + }, + "alphaRepaired": false + }, + { + "id": "generated.ui.os_shell_wallpaper_v001", + "runtimePath": "/assets/runtime/generated/ui/os_shell_wallpaper_v001.webp", + "sourcePath": "art-src/runtime/generated/ui/os_shell_wallpaper_v001.webp", + "class": "ui-overlay", + "role": null, + "state": null, + "width": 576, + "height": 1024, + "bytes": 28778, + "hasAlpha": false, + "fringeRatio": 0, + "pivot": { + "x": 0.5, + "y": 0.5 + }, + "alphaRepaired": false + }, { "id": "generated.ui.security_cam_overlay_v001", "runtimePath": "/assets/runtime/generated/ui/security_cam_overlay_v001.webp", diff --git a/frontend/src/components/alchemy/AlchemyLab.css b/frontend/src/components/alchemy/AlchemyLab.css index 119de01..2c9739c 100644 --- a/frontend/src/components/alchemy/AlchemyLab.css +++ b/frontend/src/components/alchemy/AlchemyLab.css @@ -19,7 +19,7 @@ content: ''; position: fixed; inset: 0; - background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.png') center/cover no-repeat; + background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.webp') center/cover no-repeat; opacity: 0.08; filter: brightness(0.4) hue-rotate(80deg); pointer-events: none; diff --git a/frontend/src/components/casino/Casino.css b/frontend/src/components/casino/Casino.css index 78a8b25..fbc08f9 100644 --- a/frontend/src/components/casino/Casino.css +++ b/frontend/src/components/casino/Casino.css @@ -15,7 +15,7 @@ content: ''; position: fixed; inset: 0; - background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.png') center/cover no-repeat; + background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.webp') center/cover no-repeat; opacity: 0.06; filter: brightness(0.4) hue-rotate(30deg); pointer-events: none; diff --git a/frontend/src/components/compliance/AgeGate.tsx b/frontend/src/components/compliance/AgeGate.tsx index c900587..e92dcf9 100644 --- a/frontend/src/components/compliance/AgeGate.tsx +++ b/frontend/src/components/compliance/AgeGate.tsx @@ -63,7 +63,7 @@ export default function AgeGate({ onConfirm }: AgeGateProps) { backgroundImage: 'radial-gradient(circle at 50% 18%, rgba(103, 55, 42, 0.34), transparent 38%), ' + 'linear-gradient(rgba(5, 6, 12, 0.62), rgba(5, 6, 12, 0.86)), ' + - 'url(/assets/runtime/generated/ui/loading_screen_v001.png)', + 'url(/assets/runtime/generated/ui/loading_screen_v001.webp)', backgroundSize: 'auto, auto, cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat', diff --git a/frontend/src/components/contacts/Contacts.css b/frontend/src/components/contacts/Contacts.css index f8ee2a3..e11762c 100644 --- a/frontend/src/components/contacts/Contacts.css +++ b/frontend/src/components/contacts/Contacts.css @@ -17,7 +17,7 @@ content: ''; position: absolute; inset: 0; - background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.png') center/cover no-repeat; + background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.webp') center/cover no-repeat; opacity: 0.08; filter: brightness(0.4); pointer-events: none; diff --git a/frontend/src/components/gang/MemberCreation.css b/frontend/src/components/gang/MemberCreation.css index f407fd9..9151aa1 100644 --- a/frontend/src/components/gang/MemberCreation.css +++ b/frontend/src/components/gang/MemberCreation.css @@ -15,7 +15,7 @@ font-family: 'Rajdhani', 'SF Pro Display', -apple-system, sans-serif; background: linear-gradient(rgba(4, 5, 10, 0.62), rgba(4, 5, 10, 0.88)), - url('/assets/runtime/generated/ui/loading_screen_v001.png') center/cover no-repeat, + url('/assets/runtime/generated/ui/loading_screen_v001.webp') center/cover no-repeat, #06070c; overflow-y: auto; } diff --git a/frontend/src/components/layout/SplashScreen.tsx b/frontend/src/components/layout/SplashScreen.tsx index 815023c..3acc765 100644 --- a/frontend/src/components/layout/SplashScreen.tsx +++ b/frontend/src/components/layout/SplashScreen.tsx @@ -6,7 +6,7 @@ import React from 'react'; -export const LOADING_SCREEN_URL = '/assets/runtime/generated/ui/loading_screen_v001.png'; +export const LOADING_SCREEN_URL = '/assets/runtime/generated/ui/loading_screen_v001.webp'; interface SplashScreenProps { /** Small status line under the wordmark. */ diff --git a/frontend/src/components/settings/Settings.css b/frontend/src/components/settings/Settings.css index 429dee5..a87eea1 100644 --- a/frontend/src/components/settings/Settings.css +++ b/frontend/src/components/settings/Settings.css @@ -15,7 +15,7 @@ content: ''; position: fixed; inset: 0; - background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.png') center/cover no-repeat; + background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.webp') center/cover no-repeat; opacity: 0.06; filter: brightness(0.4); pointer-events: none; diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 2fc1a56..b8db222 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -69,7 +69,7 @@ content: ''; position: absolute; inset: 0; - background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.png') center/cover no-repeat; + background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.webp') center/cover no-repeat; opacity: 0.1; filter: brightness(0.4) saturate(1.2); pointer-events: none; From 00f5d41388f416fd2cf5351d77ea954736a9c733 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:27:48 +0000 Subject: [PATCH 03/11] feat(assets): wire street sprites via manifest-driven resolver, fix assetManifest paths to on-disk webp Co-authored-by: BrandDead <174973198+BrandDead@users.noreply.github.com> --- frontend/src/assets/assetManifest.ts | 108 +++++++++++++------------ frontend/src/services/assetResolver.ts | 49 +++-------- 2 files changed, 65 insertions(+), 92 deletions(-) diff --git a/frontend/src/assets/assetManifest.ts b/frontend/src/assets/assetManifest.ts index 93c1f68..231982d 100644 --- a/frontend/src/assets/assetManifest.ts +++ b/frontend/src/assets/assetManifest.ts @@ -78,7 +78,9 @@ export interface EnvironmentAsset { } // ─── Base paths ────────────────────────────────────────────── -const BASE = '/assets/generated'; +// Processed runtime derivatives (WebP) — the only paths that exist on disk. +// Masters live in art-src/ and are never shipped. +const BASE = '/assets/runtime/generated'; const CHARS = `${BASE}/characters`; const VEHS = `${BASE}/vehicles`; const WEAPS = `${BASE}/weapons`; @@ -92,13 +94,13 @@ export const characterAssets: Record = { id: 'dealer_male_001', displayName: 'Street Dealer', role: 'dealer', - portrait: `${CHARS}/portraits/character_dealer_male_blacktee_portrait_v001.png`, - fullbody: `${CHARS}/fullbody/character_dealer_male_blacktee_fullbody_front_v001.png`, - topdown: `${CHARS}/topdown/character_dealer_male_blacktee_topdown_v001.png`, - streetIdle: `${CHARS}/street/character_dealer_male_blacktee_street_idle_v001.png`, - streetAim: `${CHARS}/street/character_dealer_male_street_aim_v001.png`, - streetHit: `${CHARS}/street/character_dealer_male_street_hit_v001.png`, - streetDowned: `${CHARS}/street/character_dealer_male_street_downed_v001.png`, + portrait: `${CHARS}/portraits/character_dealer_male_blacktee_portrait_v001.webp`, + fullbody: `${CHARS}/fullbody/character_dealer_male_blacktee_fullbody_front_v001.webp`, + topdown: `${CHARS}/topdown/character_dealer_male_blacktee_topdown_idle_v001.webp`, + streetIdle: `${CHARS}/street/character_dealer_male_street_idle_v001.webp`, + streetAim: `${CHARS}/street/character_dealer_male_street_aim_v001.webp`, + streetHit: `${CHARS}/street/character_dealer_male_street_hit_v001.webp`, + streetDowned: `${CHARS}/street/character_dealer_male_street_downed_v001.webp`, rarity: 'common', tags: ['masked', 'cash', 'streetwear'], scale: 1, @@ -109,10 +111,10 @@ export const characterAssets: Record = { id: 'lookout_female_001', displayName: 'Lookout', role: 'lookout', - portrait: `${CHARS}/portraits/character_lookout_female_portrait_v001.png`, - fullbody: `${CHARS}/fullbody/character_lookout_female_fullbody_front_v001.png`, - topdown: `${CHARS}/topdown/character_lookout_female_topdown_v001.png`, - streetIdle: `${CHARS}/street/character_lookout_female_street_idle_v001.png`, + portrait: `${CHARS}/portraits/character_lookout_female_portrait_v001.webp`, + fullbody: `${CHARS}/fullbody/character_lookout_female_fullbody_front_v001.webp`, + topdown: `${CHARS}/topdown/character_lookout_female_topdown_idle_v001.webp`, + streetIdle: `${CHARS}/street/character_lookout_female_street_idle_v001.webp`, rarity: 'common', tags: ['masked', 'radio', 'streetwear'], scale: 1, @@ -123,10 +125,10 @@ export const characterAssets: Record = { id: 'driver_male_001', displayName: 'Driver', role: 'driver', - portrait: `${CHARS}/portraits/character_driver_male_portrait_v001.png`, - fullbody: `${CHARS}/fullbody/character_driver_male_fullbody_front_v001.png`, - topdown: `${CHARS}/topdown/character_driver_male_topdown_v001.png`, - streetIdle: `${CHARS}/street/character_driver_male_street_idle_v001.png`, + portrait: `${CHARS}/portraits/character_driver_male_portrait_v001.webp`, + fullbody: `${CHARS}/fullbody/character_driver_male_fullbody_front_v001.webp`, + topdown: `${CHARS}/topdown/character_driver_male_topdown_idle_v001.webp`, + streetIdle: `${CHARS}/street/character_driver_male_street_idle_v001.webp`, rarity: 'uncommon', tags: ['cap', 'racing_jacket', 'keys'], scale: 1, @@ -137,10 +139,10 @@ export const characterAssets: Record = { id: 'enforcer_male_001', displayName: 'Enforcer', role: 'enforcer', - portrait: `${CHARS}/portraits/character_enforcer_male_portrait_v001.png`, - fullbody: `${CHARS}/fullbody/character_enforcer_male_fullbody_front_v001.png`, - topdown: `${CHARS}/topdown/character_enforcer_male_topdown_v001.png`, - streetIdle: `${CHARS}/street/character_enforcer_male_street_idle_v001.png`, + portrait: `${CHARS}/portraits/character_enforcer_male_portrait_v001.webp`, + fullbody: `${CHARS}/fullbody/character_enforcer_male_fullbody_front_v001.webp`, + topdown: `${CHARS}/topdown/character_enforcer_male_topdown_idle_v001.webp`, + streetIdle: `${CHARS}/street/character_enforcer_male_street_idle_v001.webp`, rarity: 'uncommon', tags: ['tactical', 'vest', 'bat'], scale: 1.1, @@ -151,12 +153,12 @@ export const characterAssets: Record = { id: 'shooter_male_001', displayName: 'Shooter', role: 'shooter', - portrait: `${CHARS}/portraits/character_shooter_male_portrait_v001.png`, - fullbody: `${CHARS}/fullbody/character_shooter_male_fullbody_front_v001.png`, - topdown: `${CHARS}/topdown/character_shooter_male_topdown_v001.png`, - streetIdle: `${CHARS}/street/character_shooter_male_street_idle_v001.png`, - streetHit: `${CHARS}/street/character_shooter_male_street_hit_v001.png`, - streetDowned: `${CHARS}/street/character_shooter_male_street_downed_v001.png`, + portrait: `${CHARS}/portraits/character_shooter_male_portrait_v001.webp`, + fullbody: `${CHARS}/fullbody/character_shooter_male_fullbody_front_v001.webp`, + topdown: `${CHARS}/topdown/character_shooter_male_topdown_idle_v001.webp`, + streetIdle: `${CHARS}/street/character_shooter_male_street_idle_v001.webp`, + streetHit: `${CHARS}/street/character_shooter_male_street_hit_v001.webp`, + streetDowned: `${CHARS}/street/character_shooter_male_street_downed_v001.webp`, rarity: 'uncommon', tags: ['masked', 'streetwear', 'weapon'], scale: 1, @@ -172,10 +174,10 @@ export const vehicleAssets: Record = { id: 'luxury_sedan_black_001', displayName: 'Black Luxury Sedan', vehicleClass: 'luxury_sedan', - topdown: `${VEHS}/topdown/vehicle_luxury_sedan_black_topdown_v001.png`, - streetSide: `${VEHS}/street/vehicle_luxury_sedan_black_street_side_v001.png`, - occupiedPassenger: `${VEHS}/overlays/vehicle_luxury_sedan_passenger_overlay_v001.png`, - damageLight: `${VEHS}/damage/vehicle_luxury_sedan_black_damage_light_v001.png`, + topdown: `${VEHS}/topdown/vehicle_luxury_sedan_black_topdown_v001.webp`, + streetSide: `${VEHS}/street/vehicle_luxury_sedan_black_street_side_v001.webp`, + occupiedPassenger: `${VEHS}/overlays/vehicle_luxury_sedan_passenger_overlay_v001.webp`, + damageLight: `${VEHS}/damage/vehicle_luxury_sedan_black_damage_light_v001.webp`, }, }; @@ -186,20 +188,20 @@ export const weaponAssets: Record = { id: 'compact_prop_black_001', displayName: 'Compact Prop', category: 'compact', - icon: `${WEAPS}/weapon_compact_prop_black_icon_v001.png`, + icon: `${WEAPS}/weapon_compact_prop_black_icon_v001.webp`, }, long_prop_black_001: { id: 'long_prop_black_001', displayName: 'Long Prop', category: 'long', - icon: `${WEAPS}/weapon_long_prop_black_icon_v001.png`, + icon: `${WEAPS}/weapon_long_prop_black_icon_v001.webp`, }, // Batch 4 — drum-magazine long prop long_prop_drum_001: { id: 'long_prop_drum_001', displayName: 'Drum Prop', category: 'long', - icon: `${WEAPS}/weapon_long_prop_black_icon_v001.png`, + icon: `${WEAPS}/weapon_long_prop_black_icon_v001.webp`, }, }; @@ -211,35 +213,35 @@ export const productAssets: Record = { displayName: 'Common Pack', tier: 1, rarity: 'common', - icon: `${PRODS}/product_pack_grey_common_icon_v001.png`, + icon: `${PRODS}/product_rarity_set_v001.webp`, }, product_uncommon_001: { id: 'product_uncommon_001', displayName: 'Green Pack', tier: 2, rarity: 'uncommon', - icon: `${PRODS}/product_pack_green_uncommon_icon_v001.png`, + icon: `${PRODS}/product_rarity_set_v001.webp`, }, product_rare_001: { id: 'product_rare_001', displayName: 'Purple Pack', tier: 3, rarity: 'rare', - icon: `${PRODS}/product_pack_purple_rare_icon_v001.png`, + icon: `${PRODS}/product_rarity_set_v001.webp`, }, product_legendary_001: { id: 'product_legendary_001', displayName: 'Gold Pack', tier: 4, rarity: 'legendary', - icon: `${PRODS}/product_pack_gold_legendary_icon_v001.png`, + icon: `${PRODS}/product_rarity_set_v001.webp`, }, }; // ─── Effects ───────────────────────────────────────────────── export const effectAssets = { - fx_combat_sheet: `${BASE}/effects/fx_combat_sprite_sheet_v001.png`, + fx_combat_sheet: `${BASE}/effects/fx_combat_sprite_sheet_v001.webp`, }; // ─── Environments ──────────────────────────────────────────── @@ -248,9 +250,9 @@ export const environmentAssets: Record = { block_stripplaza_miami_001: { id: 'block_stripplaza_miami_001', displayName: 'South Florida Strip Plaza', - topdownBg: `${ENVS}/topdown/block_stripplaza_topdown_v001.png`, - streetBackdropDay: `${ENVS}/street/block_stripplaza_day_street_v001.png`, - streetBackdropNight: `${ENVS}/street/block_stripplaza_night_street_v001.png`, + topdownBg: `${ENVS}/topdown/block_stripplaza_topdown_v001.webp`, + streetBackdropDay: `${ENVS}/street/block_stripplaza_day_street_v001.webp`, + streetBackdropNight: `${ENVS}/street/block_stripplaza_night_street_v001.webp`, }, // Las Olas hero block. Processed WebP paths keep shipped runtime assets within budget. block_lasolas_miami_001: { @@ -265,18 +267,18 @@ export const environmentAssets: Record = { // ─── UI Icons (generated set) ──────────────────────────────── export const uiIconPaths: Record = { - map: '/assets/icons/icon_map.png', - crew: '/assets/icons/icon_crew.png', - market: '/assets/icons/icon_market.png', - shoebox: '/assets/icons/icon_shoebox.png', - slide: '/assets/icons/icon_slide.png', - drive: '/assets/icons/icon_drive.png', - cook: '/assets/icons/icon_cook.png', - casino: '/assets/icons/icon_casino.png', - missions: '/assets/icons/icon_missions.png', - settings: '/assets/icons/icon_settings.png', - dealt: '/assets/icons/icon_dealt.png', - ops: '/assets/icons/icon_ops.png', + map: '/assets/runtime/icons/icon_map.webp', + crew: '/assets/runtime/icons/icon_crew.webp', + market: '/assets/runtime/icons/icon_market.webp', + shoebox: '/assets/runtime/icons/icon_shoebox.webp', + slide: '/assets/runtime/icons/icon_slide.webp', + drive: '/assets/runtime/icons/icon_driveby_new.webp', + cook: '/assets/runtime/icons/icon_cook.webp', + casino: '/assets/runtime/icons/icon_casino.webp', + missions: '/assets/runtime/icons/icon_attack_new.webp', + settings: '/assets/runtime/icons/icon_settings.webp', + dealt: '/assets/runtime/icons/icon_dealt.webp', + ops: '/assets/runtime/icons/icon_ops.webp', }; // ─── Aggregated manifest ───────────────────────────────────── diff --git a/frontend/src/services/assetResolver.ts b/frontend/src/services/assetResolver.ts index a270f4e..93054d8 100644 --- a/frontend/src/services/assetResolver.ts +++ b/frontend/src/services/assetResolver.ts @@ -11,6 +11,7 @@ import { environmentAssets, type CharacterAsset, } from '../assets/assetManifest'; +import { getWorldActor } from '../render/worldActorResolver'; import type { MemberRole } from '../types/block.types'; // ─── Role → character kit ──────────────────────────────────── @@ -40,52 +41,18 @@ export function getPortraitUrl(role: string): string { } // ─── Street-state sprites ──────────────────────────────────── -// Only states whose files exist on disk WITH a real alpha channel -// are listed; manifest entries for not-yet-generated art (e.g. most -// streetIdle files) are deliberately absent. The shooter's standing -// pose borrows its fullbody cutout — the only fullbody with alpha. +// Resolution is delegated to worldActorResolver (runtimeManifest-driven), +// which cannot drift from disk — the audit gate fails the build if it does. export type StreetSpriteState = 'idle' | 'aim' | 'fire' | 'hit' | 'downed'; -const STREET_STATES: Record>> = { - dealer_male_001: { - aim: characterAssets.dealer_male_001.streetAim, - hit: characterAssets.dealer_male_001.streetHit, - downed: characterAssets.dealer_male_001.streetDowned, - }, - enforcer_male_001: { - idle: characterAssets.enforcer_male_001.streetIdle, - }, - shooter_male_001: { - idle: characterAssets.shooter_male_001.fullbody, - hit: characterAssets.shooter_male_001.streetHit, - downed: characterAssets.shooter_male_001.streetDowned, - }, - lookout_female_001: {}, - driver_male_001: {}, -}; - -const STATE_FALLBACKS: Record = { - idle: ['idle', 'aim'], - aim: ['aim', 'idle'], - fire: ['aim', 'idle'], - hit: ['hit', 'downed', 'aim', 'idle'], - downed: ['downed', 'hit'], -}; - /** * Best available street-view sprite for a role in a given state, * or null when the role has no usable street art yet (falls back * to portrait chips / legacy rendering at the call site). */ export function getStreetSpriteUrl(role: string, state: StreetSpriteState): string | null { - const character = getCharacterForRole(role); - const available = STREET_STATES[character.id] ?? {}; - for (const candidate of STATE_FALLBACKS[state]) { - const url = available[candidate]; - if (url) return url; - } - return null; + return getWorldActor(role, state, 'street')?.url ?? null; } // ─── Environments ──────────────────────────────────────────── @@ -113,8 +80,12 @@ export function preloadBlockAssets(): void { for (const id of Object.values(ROLE_CHARACTER_ID)) { urls.add(characterAssets[id].portrait); } - for (const states of Object.values(STREET_STATES)) { - for (const url of Object.values(states)) { + // Warm the street sprites via the manifest-driven resolver so every role + // that ships street art is preloaded, not just a hardcoded subset. + const streetRoles: MemberRole[] = ['dealer', 'shooter', 'enforcer', 'lookout', 'driver']; + for (const role of streetRoles) { + for (const state of ['idle', 'aim', 'hit', 'downed'] as StreetSpriteState[]) { + const url = getStreetSpriteUrl(role, state); if (url) urls.add(url); } } From f8b05473a515d28d03000c6208eeb7a94b4d7b8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:31:10 +0000 Subject: [PATCH 04/11] feat(block-dna): grow library to 17 cards across tiers, stamp DNA on claim, scale income by DNA multiplier Co-authored-by: BrandDead <174973198+BrandDead@users.noreply.github.com> --- frontend/src/components/map/TerritoryMap.tsx | 6 + .../src/config/__tests__/blockDNA.test.ts | 10 +- frontend/src/config/blockDNA.ts | 334 ++++++++++++++++++ frontend/src/stores/blockStore.ts | 9 +- frontend/src/types/block.types.ts | 12 + .../utils/__tests__/blockDNAResolver.test.ts | 28 ++ 6 files changed, 393 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/map/TerritoryMap.tsx b/frontend/src/components/map/TerritoryMap.tsx index 4f2ca2f..b79d384 100644 --- a/frontend/src/components/map/TerritoryMap.tsx +++ b/frontend/src/components/map/TerritoryMap.tsx @@ -187,6 +187,12 @@ const TerritoryMap: React.FC = () => { heat: live.heat || resolved.startingHeat, morale: resolved.startingMorale, topdownBgUrl: satelliteUrl, + // Stamp the resolved Block DNA so the block keeps its archetype + // identity, income multiplier, and deployment cap after claim (#80). + dnaId: resolved.dna.id, + incomeMultiplier: resolved.incomeMultiplier, + heatDecayMultiplier: resolved.dna.heatDecayMultiplier, + maxMembers: resolved.maxMembers, }); selectBlock(live.id); setSelectedMapBlock(null); diff --git a/frontend/src/config/__tests__/blockDNA.test.ts b/frontend/src/config/__tests__/blockDNA.test.ts index 549616d..6005956 100644 --- a/frontend/src/config/__tests__/blockDNA.test.ts +++ b/frontend/src/config/__tests__/blockDNA.test.ts @@ -13,8 +13,14 @@ import { import { DEFAULT_PROFILE } from '../../render/projection'; describe('BLOCK_DNA_LIBRARY', () => { - it('has exactly 8 premade blocks', () => { - expect(BLOCK_DNA_LIBRARY).toHaveLength(8); + it('has at least 15 premade blocks across all tiers', () => { + expect(BLOCK_DNA_LIBRARY.length).toBeGreaterThanOrEqual(15); + }); + + it('every tier has at least 2 cards', () => { + for (const tier of ['starter', 'mid', 'high', 'elite'] as const) { + expect(getDNAByTier(tier).length).toBeGreaterThanOrEqual(2); + } }); it('every block has a unique id', () => { diff --git a/frontend/src/config/blockDNA.ts b/frontend/src/config/blockDNA.ts index 9b2b899..ddac2cf 100644 --- a/frontend/src/config/blockDNA.ts +++ b/frontend/src/config/blockDNA.ts @@ -371,6 +371,340 @@ export const BLOCK_DNA_LIBRARY: BlockDNA[] = [ hotBlock: true, startingHeat: 2, }, + + // ── 9. Brownsville Court (Class 1 — urban decay starter) ───── + { + id: 'brownsville-court', + name: 'Brownsville Court', + address: '5100 NW 24th Ct', + city: 'Miami', + state: 'FL', + lat: 25.8190, + lng: -80.2410, + tier: 'starter', + tags: ['alley-heavy', 'corner-store'], + flavour: 'Boarded-up court. Nobody looks twice here — perfect first trap.', + zoneOverrides: { + 0: 'street', + 1: 'curb', + 2: 'alley', + 3: 'alley', + 4: 'storefront', + 5: 'alley', + 6: 'parking', + 7: 'rooftop', + }, + projectionOverrides: { + groundYRatio: 0.92, + horizonYRatio: 0.31, + actorHeightRatio: 0.24, + }, + incomeMultiplier: 0.7, + heatDecayMultiplier: 1.5, + globalCoverBonus: 0.12, + startingMorale: 82, + maxMembers: 5, + hotBlock: false, + startingHeat: 0, + }, + + // ── 10. Pompano Strip Mall (Class 2 — suburban, mid income) ── + { + id: 'pompano-strip', + name: 'Pompano Strip Plaza', + address: '1400 N Federal Hwy', + city: 'Pompano Beach', + state: 'FL', + lat: 26.2478, + lng: -80.1120, + tier: 'mid', + tags: ['strip-mall', 'parking-lot'], + flavour: 'Suburban sprawl. Soccer moms by day, steady handoffs by night.', + zoneOverrides: { + 0: 'street', + 1: 'curb', + 2: 'parking', + 3: 'parking', + 4: 'storefront', + 5: 'storefront', + 6: 'sidewalk', + 7: 'rooftop', + }, + projectionOverrides: { + groundYRatio: 0.93, + horizonYRatio: 0.27, + actorHeightRatio: 0.26, + cellWidthRatio: 0.105, + }, + incomeMultiplier: 1.1, + heatDecayMultiplier: 1.0, + globalCoverBonus: 0.06, + startingMorale: 72, + maxMembers: 8, + hotBlock: false, + startingHeat: 1, + }, + + // ── 11. Coral Gables Estate Wall (Class 3 — gated estates) ─── + { + id: 'coral-gables-estate', + name: 'Gables Estate Wall', + address: '1200 Coral Way', + city: 'Coral Gables', + state: 'FL', + lat: 25.7492, + lng: -80.2615, + tier: 'elite', + tags: ['rooftop-access', 'open-air'], + flavour: 'Gated money. Rich clients pay double — private security shoots first.', + zoneOverrides: { + 0: 'street', + 1: 'curb', + 2: 'sidewalk', + 3: 'sidewalk', + 4: 'storefront', + 5: 'building', + 6: 'building', + 7: 'rooftop', + }, + projectionOverrides: { + groundYRatio: 0.94, + horizonYRatio: 0.19, + actorHeightRatio: 0.29, + zNear: 3.3, + rowDepth: 0.6, + }, + incomeMultiplier: 2.2, + heatDecayMultiplier: 0.45, + globalCoverBonus: -0.02, + startingMorale: 58, + maxMembers: 9, + hotBlock: true, + startingHeat: 3, + }, + + // ── 12. Hialeah Warehouse Row (Class 1 — industrial decay) ─── + { + id: 'hialeah-warehouse-row', + name: 'Hialeah Warehouse Row', + address: '2200 W 76th St', + city: 'Hialeah', + state: 'FL', + lat: 25.8894, + lng: -80.2920, + tier: 'mid', + tags: ['warehouse', 'alley-heavy'], + flavour: 'Rust-belt loading docks. Cheap to hold, hard to raid.', + zoneOverrides: { + 0: 'street', + 1: 'curb', + 2: 'parking', + 3: 'alley', + 4: 'alley', + 5: 'storefront', + 6: 'parking', + 7: 'building', + }, + projectionOverrides: { + groundYRatio: 0.95, + horizonYRatio: 0.26, + actorHeightRatio: 0.27, + cellWidthRatio: 0.11, + }, + incomeMultiplier: 1.05, + heatDecayMultiplier: 1.15, + globalCoverBonus: 0.14, + startingMorale: 68, + maxMembers: 9, + hotBlock: false, + startingHeat: 1, + }, + + // ── 13. Hollywood Beach Broadwalk (Class 3 — tourist elite) ── + { + id: 'hollywood-broadwalk', + name: 'Hollywood Broadwalk', + address: '300 N Broadwalk', + city: 'Hollywood', + state: 'FL', + lat: 26.0189, + lng: -80.1168, + tier: 'elite', + tags: ['beachfront', 'open-air'], + flavour: 'Tourist cash on the sand. Zero cover — bring your own crowd.', + zoneOverrides: { + 0: 'street', + 1: 'curb', + 2: 'sidewalk', + 3: 'sidewalk', + 4: 'storefront', + 5: 'sidewalk', + 6: 'curb', + 7: 'street', + }, + projectionOverrides: { + groundYRatio: 0.90, + horizonYRatio: 0.21, + actorHeightRatio: 0.30, + cellWidthRatio: 0.12, + shadowLengthRatio: 0.6, + }, + incomeMultiplier: 2.1, + heatDecayMultiplier: 0.5, + globalCoverBonus: -0.06, + startingMorale: 56, + maxMembers: 8, + hotBlock: true, + startingHeat: 3, + }, + + // ── 14. Liberty Square Projects (Class 1 — urban decay, hot) ─ + { + id: 'liberty-square-projects', + name: 'Liberty Square', + address: '1400 NW 63rd St', + city: 'Miami', + state: 'FL', + lat: 25.8415, + lng: -80.2213, + tier: 'starter', + tags: ['open-air', 'alley-heavy'], + flavour: 'The Pork & Beans. Everyone knows everyone — word travels fast.', + zoneOverrides: { + 0: 'street', + 1: 'curb', + 2: 'sidewalk', + 3: 'alley', + 4: 'storefront', + 5: 'alley', + 6: 'curb', + 7: 'rooftop', + }, + projectionOverrides: { + groundYRatio: 0.92, + horizonYRatio: 0.30, + actorHeightRatio: 0.24, + }, + incomeMultiplier: 0.85, + heatDecayMultiplier: 0.9, + globalCoverBonus: 0.1, + startingMorale: 75, + maxMembers: 6, + hotBlock: true, + startingHeat: 2, + }, + + // ── 15. Plantation Acres Ranch (Class 2 — suburban low heat) ─ + { + id: 'plantation-acres', + name: 'Plantation Acres', + address: '11500 W Broward Blvd', + city: 'Plantation', + state: 'FL', + lat: 26.1217, + lng: -80.2660, + tier: 'mid', + tags: ['open-air', 'parking-lot'], + flavour: 'Horse country at the edge of the suburbs. Slow money, zero heat.', + zoneOverrides: { + 0: 'street', + 1: 'curb', + 2: 'parking', + 3: 'parking', + 4: 'parking', + 5: 'sidewalk', + 6: 'curb', + 7: 'rooftop', + }, + projectionOverrides: { + groundYRatio: 0.91, + horizonYRatio: 0.33, + actorHeightRatio: 0.23, + cellWidthRatio: 0.10, + }, + incomeMultiplier: 0.95, + heatDecayMultiplier: 1.6, + globalCoverBonus: 0.04, + startingMorale: 78, + maxMembers: 7, + hotBlock: false, + startingHeat: 0, + }, + + // ── 16. Downtown Brickell High-Rise (Class 3 — gated estates) ─ + { + id: 'brickell-highrise', + name: 'Brickell High-Rise', + address: '950 Brickell Ave', + city: 'Miami', + state: 'FL', + lat: 25.7630, + lng: -80.1918, + tier: 'elite', + tags: ['rooftop-access', 'warehouse'], + flavour: 'Condo towers and valet lanes. Vertical turf for the endgame crew.', + zoneOverrides: { + 0: 'street', + 1: 'curb', + 2: 'sidewalk', + 3: 'storefront', + 4: 'building', + 5: 'building', + 6: 'building', + 7: 'rooftop', + }, + projectionOverrides: { + groundYRatio: 0.95, + horizonYRatio: 0.18, + actorHeightRatio: 0.28, + zNear: 3.4, + rowDepth: 0.58, + }, + incomeMultiplier: 2.4, + heatDecayMultiplier: 0.55, + globalCoverBonus: 0.08, + startingMorale: 62, + maxMembers: 11, + hotBlock: true, + startingHeat: 2, + }, + + // ── 17. Dania Jai-Alai Lot (Class 2 — suburban mid risk) ───── + { + id: 'dania-jai-alai', + name: 'Dania Jai-Alai Lot', + address: '301 E Dania Beach Blvd', + city: 'Dania Beach', + state: 'FL', + lat: 26.0523, + lng: -80.1439, + tier: 'mid', + tags: ['parking-lot', 'strip-mall'], + flavour: 'Old fronton parking lot. Night crowd pays cash and asks nothing.', + zoneOverrides: { + 0: 'street', + 1: 'curb', + 2: 'parking', + 3: 'parking', + 4: 'storefront', + 5: 'alley', + 6: 'parking', + 7: 'rooftop', + }, + projectionOverrides: { + groundYRatio: 0.92, + horizonYRatio: 0.28, + actorHeightRatio: 0.25, + cellWidthRatio: 0.108, + }, + incomeMultiplier: 1.15, + heatDecayMultiplier: 0.95, + globalCoverBonus: 0.07, + startingMorale: 70, + maxMembers: 8, + hotBlock: false, + startingHeat: 1, + }, ]; // ─── Lookup helpers ─────────────────────────────────────────── diff --git a/frontend/src/stores/blockStore.ts b/frontend/src/stores/blockStore.ts index 60c9f72..42e95c8 100644 --- a/frontend/src/stores/blockStore.ts +++ b/frontend/src/stores/blockStore.ts @@ -65,14 +65,14 @@ function generateDefaultGrid(): BlockZone[][] { /** Roles that generate income when placed on a block */ const INCOME_ROLES = new Set(['dealer', 'chemist', 'runner']); -function calcPlacementIncome(placement: BlockPlacement, grid: BlockZone[][]): number { +function calcPlacementIncome(placement: BlockPlacement, grid: BlockZone[][], incomeMultiplier = 1): number { const zone = grid[placement.y]?.[placement.x]; if (!zone || !INCOME_ROLES.has(placement.role)) return 0; const base = zone.incomeModifier; const levelBonus = 1 + (placement.level - 1) * 0.12; // +12% per level // chemist and runner earn at 70% of dealer rate (indirect income) const roleMult = placement.role === 'dealer' ? 1.0 : 0.7; - return Math.round(base * levelBonus * roleMult); + return Math.round(base * levelBonus * roleMult * incomeMultiplier); } // ─── Store ─────────────────────────────────────────────────── @@ -126,8 +126,9 @@ export const useBlockStore = create()( }) ); - // Recalculate income for this placement - const income = calcPlacementIncome(placement, newGrid); + // Recalculate income for this placement, scaled by the block's + // DNA income multiplier (#80). + const income = calcPlacementIncome(placement, newGrid, block.incomeMultiplier ?? 1); const finalPlacement = { ...placement, incomePerTick: income }; const newPlacements = [...filtered, finalPlacement]; diff --git a/frontend/src/types/block.types.ts b/frontend/src/types/block.types.ts index 16aa75e..b7e29de 100644 --- a/frontend/src/types/block.types.ts +++ b/frontend/src/types/block.types.ts @@ -118,6 +118,18 @@ export interface BlockData { taggedBy?: string; /** ISO timestamp of the tag recorded in `taggedBy`. */ taggedAt?: string; + /** + * Block DNA archetype this block was seeded from (see config/blockDNA.ts). + * Set at claim time; drives income multiplier, heat decay, cover, and the + * zone layout. Undefined on legacy blocks claimed before DNA assignment. + */ + dnaId?: string; + /** Income multiplier inherited from the DNA card (1.0 = normal). */ + incomeMultiplier?: number; + /** Heat decay multiplier inherited from the DNA card. */ + heatDecayMultiplier?: number; + /** Deployment cap inherited from the DNA card. */ + maxMembers?: number; } // ─── Attack / Drive-By ────────────────────────────────────── diff --git a/frontend/src/utils/__tests__/blockDNAResolver.test.ts b/frontend/src/utils/__tests__/blockDNAResolver.test.ts index eb7ae56..9f391bb 100644 --- a/frontend/src/utils/__tests__/blockDNAResolver.test.ts +++ b/frontend/src/utils/__tests__/blockDNAResolver.test.ts @@ -62,6 +62,34 @@ describe('resolveBlockDNA', () => { expect(res.startingHeat).toBeGreaterThanOrEqual(0); expect(res.startingHeat).toBeLessThanOrEqual(5); }); + + it('tier acceptance: starter and elite blocks resolve to different stats (#80)', () => { + // Coordinates sit right on top of two curated DNA cards in different tiers. + const starter = resolveBlockDNA(25.8190, -80.2410, '5100 NW 24th Ct, Miami, FL'); // brownsville-court (starter) + const elite = resolveBlockDNA(25.7630, -80.1918, '950 Brickell Ave, Miami, FL'); // brickell-highrise (elite) + expect(starter.dna.id).toBe('brownsville-court'); + expect(elite.dna.id).toBe('brickell-highrise'); + // … and they play differently: elite pays more but starts hotter. + expect(elite.incomeMultiplier).toBeGreaterThan(starter.incomeMultiplier); + expect(elite.startingHeat).toBeGreaterThan(starter.startingHeat); + expect(elite.maxMembers).toBeGreaterThan(starter.maxMembers); + expect(elite.zoneLayout).not.toEqual(starter.zoneLayout); + }); + + it('curated cards across all tiers resolve without any Mapbox call', () => { + // Resolve each tier's curated coordinate; all must map to a library card. + const spots: Array<[number, number, string]> = [ + [26.1186239, -80.1574818, '1208 W Las Olas Blvd'], + [25.8190, -80.2410, '5100 NW 24th Ct'], + [25.8894, -80.2920, '2200 W 76th St'], + [25.7492, -80.2615, '1200 Coral Way'], + ]; + for (const [lat, lng, address] of spots) { + const res = resolveBlockDNA(lat, lng, address); + expect(res.dna.id).toBeTruthy(); + expect(res.zoneLayout).toHaveLength(8); + } + }); }); // ─── Fort Lauderdale city config ───────────────────────────────────────────── From 857b5de43814441160dd95698ca9f12ba3a3978c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:38:26 +0000 Subject: [PATCH 05/11] feat(npc): ghost crew engine + persistent store + City Feed banner + grudge memory (#81) Co-authored-by: BrandDead <174973198+BrandDead@users.noreply.github.com> --- frontend/src/App.tsx | 6 + .../src/components/driveby/DriveByGame.tsx | 11 + .../src/components/map/GhostThreatBanner.tsx | 106 +++++ frontend/src/components/map/TerritoryMap.tsx | 5 +- .../stores/__tests__/ghostCrewStore.test.ts | 72 +++ frontend/src/stores/combatIntentStore.ts | 10 +- frontend/src/stores/ghostCrewStore.ts | 258 ++++++++++ .../utils/__tests__/ghostCrewEngine.test.ts | 247 ++++++++++ frontend/src/utils/ghostCrewEngine.ts | 450 ++++++++++++++++++ 9 files changed, 1161 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/map/GhostThreatBanner.tsx create mode 100644 frontend/src/stores/__tests__/ghostCrewStore.test.ts create mode 100644 frontend/src/stores/ghostCrewStore.ts create mode 100644 frontend/src/utils/__tests__/ghostCrewEngine.test.ts create mode 100644 frontend/src/utils/ghostCrewEngine.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be2154a..1635eed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import PayrollModal from './components/economy/PayrollModal'; import { useNPCRetaliation } from './utils/npcRetaliationEngine'; import { useTutorialProgressStore } from './stores/tutorialProgressStore'; import { useNPCTick } from './stores/npcStore'; +import { useGhostTick } from './stores/ghostCrewStore'; import { useTerritoryStore, useGangStore } from './stores/gameStore'; import { supabase } from './services/supabase'; import type { User } from '@supabase/supabase-js'; @@ -36,6 +37,7 @@ import RaidEventOverlay from './components/layout/RaidEventOverlay'; import GetBackClock from './components/common/GetBackClock'; import TutorialOverlay from './components/tutorial/TutorialOverlay'; import NPCThreatBanner from './components/map/NPCThreatBanner'; +import GhostThreatBanner from './components/map/GhostThreatBanner'; // Always-loaded core screens import DealtMode from './components/dealt/DealtMode'; @@ -143,6 +145,8 @@ const App: React.FC = () => { // NPC AI Tick — drives rival gang behavior every 30s const playerBlockIds = useTerritoryStore((s) => s.blocks.map((b) => b.id)); useNPCTick(playerBlockIds); + // Ghost Crew world tick — persistent rivals that claim/attack turf (#81) + useGhostTick(); const { completeStep } = useTutorialProgressStore(); @@ -268,6 +272,8 @@ const App: React.FC = () => {
{/* NPC threat banner — fixed top bar, shows on any screen */} + {/* Ghost crew activity banner — surfaces rival claims/attacks (#81) */} + {/* Get Back shot clock — global HUD. Hides itself when no debt is open. */} diff --git a/frontend/src/components/driveby/DriveByGame.tsx b/frontend/src/components/driveby/DriveByGame.tsx index a7b1781..c8d56c3 100644 --- a/frontend/src/components/driveby/DriveByGame.tsx +++ b/frontend/src/components/driveby/DriveByGame.tsx @@ -10,6 +10,8 @@ import { useNavigationStore, usePlayerStore, useGangStore } from '../../stores/g import CarCrewSelector, { type CarCrew } from './CarCrewSelector'; import DriveByEngine from './DriveByEngine'; import { vaultDeposit } from '../../utils/moneyRouter'; +import { useCombatIntentStore } from '../../stores/combatIntentStore'; +import { useGhostStore } from '../../stores/ghostCrewStore'; interface GameStats { kills: number; @@ -44,6 +46,15 @@ const DriveByGame: React.FC = () => { } updateHeat(Math.min(stats.kills * 3 + stats.civilianHits * 10, 50)); addXP(stats.kills * 20 + stats.blocksCleared * 50); + + // Ghost-crew grudge (#81): if the target block belonged to a rival crew, + // a successful hit (kills on their turf) raises that crew's grudge, which + // biases its next world-tick decision toward retaliation. + const targetCrewId = useCombatIntentStore.getState().targetCrewId; + const targetBlockId = crew?.targetBlock?.placeId ?? null; + if (targetCrewId && targetBlockId && stats.kills > 0) { + useGhostStore.getState().recordPlayerAttack(targetCrewId, targetBlockId); + } // Update crew member XP/stats based on performance if (crew) { diff --git a/frontend/src/components/map/GhostThreatBanner.tsx b/frontend/src/components/map/GhostThreatBanner.tsx new file mode 100644 index 0000000..88edc12 --- /dev/null +++ b/frontend/src/components/map/GhostThreatBanner.tsx @@ -0,0 +1,106 @@ +/** + * DEALT/SLIDE — GhostThreatBanner (#81) + * Fixed-position banner that surfaces the latest ghost-crew move that + * threatens the player (attack / contested claim). Dismissable; reads from + * the persistent ghostCrewStore feed. + */ + +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useGhostStore, selectGhostFeed, type GhostFeedEvent } from '../../stores/ghostCrewStore'; + +const bannerStyle: React.CSSProperties = { + position: 'fixed', + top: 0, + left: 0, + width: '100%', + zIndex: 9998, + backgroundColor: '#12071c', + borderBottom: '2px solid #a855f7', + padding: '12px 16px', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + fontFamily: 'monospace', + color: '#d8b4fe', + boxSizing: 'border-box', +}; + +const contentStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 2, +}; + +const crewNameStyle: React.CSSProperties = { + fontWeight: 'bold', + fontSize: '14px', + textTransform: 'uppercase', + letterSpacing: '1px', +}; + +const actionStyle: React.CSSProperties = { + fontSize: '11px', + color: '#c084fc', + textTransform: 'uppercase', +}; + +const descStyle: React.CSSProperties = { + fontSize: '12px', + color: '#e9d5ff', +}; + +const dismissBtnStyle: React.CSSProperties = { + background: 'transparent', + border: '1px solid #a855f7', + color: '#d8b4fe', + fontSize: '16px', + cursor: 'pointer', + padding: '4px 10px', + borderRadius: '4px', + fontFamily: 'monospace', +}; + +/** Events that warrant a player-facing banner. */ +const BANNER_ACTIONS = new Set(['attack', 'claim']); + +const GhostThreatBanner: React.FC = () => { + const feed = useGhostStore(selectGhostFeed); + const [dismissedId, setDismissedId] = React.useState(null); + + const latest = feed.find( + (e) => BANNER_ACTIONS.has(e.action) && e.id !== dismissedId, + ); + + return ( + + {latest && ( + +
+ 🏴 {latest.crewName} + {latest.action} + {latest.description} +
+ +
+ )} +
+ ); +}; + +export default GhostThreatBanner; diff --git a/frontend/src/components/map/TerritoryMap.tsx b/frontend/src/components/map/TerritoryMap.tsx index b79d384..d4e0509 100644 --- a/frontend/src/components/map/TerritoryMap.tsx +++ b/frontend/src/components/map/TerritoryMap.tsx @@ -28,6 +28,7 @@ import { attackableNearby, buildNearbyRecon, rankThreats, recommendHit, type Rec import { computeEmpirePnl } from '../../utils/shoeboxAnalytics'; import { vaultDeposit } from '../../utils/moneyRouter'; import { useCombatIntentStore } from '../../stores/combatIntentStore'; +import { useGhostStore } from '../../stores/ghostCrewStore'; import type { BlockData, BlockZone, MemberRole } from '../../types/block.types'; import './TerritoryMap.css'; import './EmpireCommandBar.css'; @@ -244,13 +245,15 @@ const TerritoryMap: React.FC = () => { selectBlock(block.id); setSelectedMapBlock(null); setShowRecon(false); + // Tag the owning ghost crew so a successful hit raises their grudge (#81). + const targetCrewId = useGhostStore.getState().crewForBlock(block.id)?.id ?? null; useCombatIntentStore.getState().setPendingTarget({ address: block.address, lat: block.lat, lng: block.lng, placeId: block.id, seedMode: 'geocoded', - }); + }, targetCrewId); navigateTo('driveby'); notify(`Sliding on ${block.address}`); }, [navigateTo, selectBlock, notify]); diff --git a/frontend/src/stores/__tests__/ghostCrewStore.test.ts b/frontend/src/stores/__tests__/ghostCrewStore.test.ts new file mode 100644 index 0000000..5fd0422 --- /dev/null +++ b/frontend/src/stores/__tests__/ghostCrewStore.test.ts @@ -0,0 +1,72 @@ +// Integration test for the Ghost Crew Store (#81). +// Verifies the acceptance criterion: a player who ignores the map for several +// ticks visibly loses ground to a named rival crew. +import { describe, it, expect, beforeEach } from 'vitest'; +import { useGhostStore } from '../ghostCrewStore'; +import { useBlockStore } from '../blockStore'; + +function resetStores() { + useGhostStore.setState({ crews: {}, feed: [], tickActive: false, tickIndex: 0 }); + useBlockStore.setState({ blocks: {}, selectedBlockId: null }); +} + +describe('ghostCrewStore', () => { + beforeEach(resetStores); + + it('seeds the default crews once', () => { + useGhostStore.getState().seedCrews(); + const crews = useGhostStore.getState().crews; + expect(Object.keys(crews).length).toBeGreaterThanOrEqual(3); + // Idempotent — a second seed does not wipe persisted progress. + const nightfallTreasury = crews['ghost-nightfall'].treasury; + useGhostStore.getState().seedCrews(); + expect(useGhostStore.getState().crews['ghost-nightfall'].treasury).toBe(nightfallTreasury); + }); + + it('a passive player loses ground over several ticks (acceptance)', () => { + useGhostStore.getState().seedCrews(); + const before = Object.values(useGhostStore.getState().crews) + .reduce((sum, c) => sum + c.ownedBlockIds.length, 0); + expect(before).toBe(0); + + // Run enough ticks that expansion-driven crews claim turf. The player + // does nothing (no blocks, no attacks). + for (let i = 0; i < 12; i++) { + useGhostStore.getState().runTick(); + } + + const crews = Object.values(useGhostStore.getState().crews); + const after = crews.reduce((sum, c) => sum + c.ownedBlockIds.length, 0); + expect(after).toBeGreaterThan(0); + + // … and the claimed turf is visible in blockStore as npc-owned blocks. + const ghostBlocks = Object.values(useBlockStore.getState().blocks) + .filter((b) => b.owner === 'npc'); + expect(ghostBlocks.length).toBe(after); + expect(ghostBlocks[0].ownerGangName).toBeTruthy(); + expect(ghostBlocks[0].dnaId).toBeTruthy(); + }); + + it('records a grudge when the player attacks ghost turf', () => { + useGhostStore.getState().seedCrews(); + const crewId = 'ghost-sistrunk'; + const before = useGhostStore.getState().crews[crewId].grudge.score; + useGhostStore.getState().recordPlayerAttack(crewId, 'ghost-block-x'); + const after = useGhostStore.getState().crews[crewId].grudge.score; + expect(after).toBeGreaterThan(before); + }); + + it('crewForBlock resolves the owner of a claimed block', () => { + useGhostStore.getState().seedCrews(); + // Force a claim by running ticks until at least one crew owns a block. + for (let i = 0; i < 12 && useBlockStore.getState().blocks.length === undefined; i++) { + useGhostStore.getState().runTick(); + } + const npcBlock = Object.values(useBlockStore.getState().blocks).find((b) => b.owner === 'npc'); + if (npcBlock) { + const owner = useGhostStore.getState().crewForBlock(npcBlock.id); + expect(owner).toBeTruthy(); + expect(owner!.ownedBlockIds).toContain(npcBlock.id); + } + }); +}); diff --git a/frontend/src/stores/combatIntentStore.ts b/frontend/src/stores/combatIntentStore.ts index f2adb2a..eb7ec3b 100644 --- a/frontend/src/stores/combatIntentStore.ts +++ b/frontend/src/stores/combatIntentStore.ts @@ -3,18 +3,22 @@ import type { DriveByTarget } from '../utils/driveByTarget'; interface CombatIntentState { pendingTarget: DriveByTarget | null; - setPendingTarget: (target: DriveByTarget | null) => void; + /** Ghost crew that owns the pending target block, when known (#81). */ + targetCrewId: string | null; + setPendingTarget: (target: DriveByTarget | null, targetCrewId?: string | null) => void; consumePendingTarget: () => DriveByTarget | null; reset: () => void; } export const useCombatIntentStore = create((set, get) => ({ pendingTarget: null, - setPendingTarget: (target) => set({ pendingTarget: target }), + targetCrewId: null, + setPendingTarget: (target, targetCrewId = null) => + set({ pendingTarget: target, targetCrewId }), consumePendingTarget: () => { const target = get().pendingTarget; set({ pendingTarget: null }); return target; }, - reset: () => set({ pendingTarget: null }), + reset: () => set({ pendingTarget: null, targetCrewId: null }), })); diff --git a/frontend/src/stores/ghostCrewStore.ts b/frontend/src/stores/ghostCrewStore.ts new file mode 100644 index 0000000..99b2dea --- /dev/null +++ b/frontend/src/stores/ghostCrewStore.ts @@ -0,0 +1,258 @@ +// ============================================================ +// DEALT/SLIDE — Ghost Crew Store (#81) +// Persistent rival crews + world-tick driver + City Feed surfacing. +// +// This store is the single owner of ghost-crew state. It: +// - persists crews to localStorage (zustand/persist) so a named rival +// keeps its roster, treasury, grudges, and turf across sessions +// - runs one world-tick decision per crew every GHOST_TICK_MS +// - writes ghost-owned territory into useBlockStore.blocks (owner:'npc') +// so the map, recon ring, and encounter pipeline all see ghost turf +// - pushes every visible move to the City Feed (notification store) +// - folds in retaliation: player attacks on ghost turf raise a grudge, +// which biases the crew's next decision toward revenge +// ============================================================ + +import { create } from 'zustand'; +import { devtools, persist } from 'zustand/middleware'; +import { useEffect, useRef, useCallback } from 'react'; +import { + DEFAULT_GHOST_CREWS, + decideGhostAction, + applyGhostAction, + buildGhostBlock, + pickClaimTarget, + addGrudge, + type GhostCrew, + type GhostAction, + type GhostTickContext, +} from '../utils/ghostCrewEngine'; +import { getDNAById } from '../config/blockDNA'; +import { useBlockStore } from './blockStore'; +import { useNotificationStore } from './gameStore'; + +// ─── Types ─────────────────────────────────────────────────── + +export interface GhostFeedEvent { + id: string; + crewId: string; + crewName: string; + action: GhostAction['type']; + description: string; + targetBlockId?: string; + timestamp: number; +} + +export interface GhostStoreState { + crews: Record; + feed: GhostFeedEvent[]; + tickActive: boolean; + tickIndex: number; +} + +export interface GhostStoreActions { + /** Seed the default crews if the store is empty (first run). */ + seedCrews(): void; + /** Run one world tick across all crews. */ + runTick(): void; + /** Record a player attack on a ghost block → raises that crew's grudge. */ + recordPlayerAttack(crewId: string, blockId: string): void; + /** The crew that owns a given block, if any. */ + crewForBlock(blockId: string): GhostCrew | undefined; + setTickActive(active: boolean): void; +} + +type GhostStore = GhostStoreState & GhostStoreActions; + +// ─── Store ─────────────────────────────────────────────────── + +const FEED_LIMIT = 50; + +export const useGhostStore = create()( + persist( + devtools( + (set, get) => ({ + crews: {}, + feed: [], + tickActive: false, + tickIndex: 0, + + seedCrews() { + if (Object.keys(get().crews).length > 0) return; + const crews: Record = {}; + for (const crew of DEFAULT_GHOST_CREWS) crews[crew.id] = crew; + set({ crews }, false, 'ghost/seedCrews'); + }, + + crewForBlock(blockId) { + return Object.values(get().crews).find((c) => c.ownedBlockIds.includes(blockId)); + }, + + recordPlayerAttack(crewId, blockId) { + set( + (state) => { + const crew = state.crews[crewId]; + if (!crew) return state; + const updated = addGrudge(crew, blockId, 25); + const feed: GhostFeedEvent = { + id: `grudge-${Date.now()}`, + crewId, + crewName: crew.name, + action: 'attack', + description: `${crew.name} will remember what you did on their block.`, + targetBlockId: blockId, + timestamp: Date.now(), + }; + useNotificationStore.getState().addNotification({ + type: 'warning', + title: `${crew.name} holds a grudge`, + message: `Your hit on their turf raised their grudge to ${updated.grudge.score}. Expect payback.`, + priority: 'high', + }); + return { + crews: { ...state.crews, [crewId]: updated }, + feed: [feed, ...state.feed].slice(0, FEED_LIMIT), + }; + }, + false, + 'ghost/recordPlayerAttack', + ); + }, + + runTick() { + const state = get(); + const tickIndex = state.tickIndex + 1; + const blockStore = useBlockStore.getState(); + const playerBlocks = Object.values(blockStore.blocks).filter((b) => b.owner === 'player'); + const ghostOwnedBlockIds = new Set( + Object.values(state.crews).flatMap((c) => c.ownedBlockIds), + ); + const ctx: GhostTickContext = { playerBlocks, ghostOwnedBlockIds, tickIndex }; + + const crews = { ...state.crews }; + const newFeed: GhostFeedEvent[] = []; + + for (const crew of Object.values(state.crews)) { + const action = decideGhostAction(crew, ctx); + const updated = applyGhostAction(crew, action); + crews[crew.id] = updated; + + // Surface claims into blockStore so the map shows new ghost turf. + if (action.type === 'claim' && action.claimedDnaId) { + const dna = getDNAById(action.claimedDnaId); + if (dna) { + blockStore.upsertBlock(buildGhostBlock(updated, dna)); + ctx.ghostOwnedBlockIds.add(`ghost-${dna.id}`); + } + } + + // Feed + City Feed notification. + const feedEvent: GhostFeedEvent = { + id: `feed-${tickIndex}-${crew.id}`, + crewId: crew.id, + crewName: crew.name, + action: action.type, + description: action.description, + targetBlockId: action.targetBlockId, + timestamp: Date.now(), + }; + newFeed.push(feedEvent); + + if (action.threatensPlayer) { + useNotificationStore.getState().addNotification({ + type: 'danger', + title: `${crew.name} is moving on you`, + message: action.description, + priority: 'high', + }); + } else if (action.type === 'claim') { + useNotificationStore.getState().addNotification({ + type: 'info', + title: `${crew.name} expanded`, + message: action.description, + priority: 'normal', + }); + } + } + + set( + (s) => ({ + crews, + tickIndex, + feed: [...newFeed.reverse(), ...s.feed].slice(0, FEED_LIMIT), + }), + false, + 'ghost/runTick', + ); + }, + + setTickActive(active) { + set({ tickActive: active }, false, 'ghost/setTickActive'); + }, + }), + { name: 'ghost-store' }, + ), + { + name: 'slide-ghost-crews', + version: 1, + }, + ), +); + +// ─── Tick hook ─────────────────────────────────────────────── + +const GHOST_TICK_MS = 30_000; // 30 s real time = one world tick + +/** + * Drop this hook in a top-level component (App.tsx) to start the ghost-crew + * world tick. Crews are seeded once on first run, then decide a move every + * tick. The store persists, so rivals keep their turf and grudges across + * sessions. + */ +export function useGhostTick(): void { + const { crews, seedCrews, runTick, setTickActive } = useGhostStore(); + + // Seed once on first mount. + useEffect(() => { + if (Object.keys(crews).length === 0) seedCrews(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const tick = useCallback(() => useGhostStore.getState().runTick(), []); + + useEffect(() => { + setTickActive(true); + // First tick is delayed so the player isn't ambushed on load. + const interval = setInterval(tick, GHOST_TICK_MS); + return () => { + clearInterval(interval); + setTickActive(false); + }; + }, [tick, setTickActive]); + + // Keep a live ref so other systems can read the latest crews without + // subscribing. + const crewsRef = useRef(crews); + useEffect(() => { + crewsRef.current = crews; + }, [crews]); +} + +// ─── Selectors ─────────────────────────────────────────────── + +export const selectGhostCrewList = (s: GhostStoreState): GhostCrew[] => Object.values(s.crews); + +export const selectGhostFeed = (s: GhostStoreState): GhostFeedEvent[] => s.feed; + +export const selectThreateningCrews = (s: GhostStoreState): GhostCrew[] => + Object.values(s.crews).filter((c) => c.grudge.score >= 50); + +/** Pick the DNA card a crew would claim next (for UI previews). */ +export function previewNextClaim(crew: GhostCrew, tickIndex: number) { + const blockStore = useBlockStore.getState(); + const playerBlocks = Object.values(blockStore.blocks).filter((b) => b.owner === 'player'); + const ghostOwnedBlockIds = new Set( + Object.values(useGhostStore.getState().crews).flatMap((c) => c.ownedBlockIds), + ); + return pickClaimTarget(crew, { playerBlocks, ghostOwnedBlockIds, tickIndex }); +} diff --git a/frontend/src/utils/__tests__/ghostCrewEngine.test.ts b/frontend/src/utils/__tests__/ghostCrewEngine.test.ts new file mode 100644 index 0000000..9bce6cf --- /dev/null +++ b/frontend/src/utils/__tests__/ghostCrewEngine.test.ts @@ -0,0 +1,247 @@ +// Tests for the Ghost Crew Engine (#81) — decision loop, claim economics, +// grudge memory, and deterministic behavior. +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_GHOST_CREWS, + decideGhostAction, + applyGhostAction, + buildGhostBlock, + pickClaimTarget, + addGrudge, + ghostClaimCost, + ghostBlockIncome, + type GhostCrew, + type GhostTickContext, +} from '../ghostCrewEngine'; +import { BLOCK_DNA_LIBRARY, getDNAById } from '../../config/blockDNA'; +import type { BlockData } from '../../types/block.types'; + +function makeCtx(overrides: Partial = {}): GhostTickContext { + return { + playerBlocks: [], + ghostOwnedBlockIds: new Set(), + tickIndex: 1, + ...overrides, + }; +} + +function playerBlock(id: string, dnaId?: string): BlockData { + return { + id, + address: '1 Player St', + lat: 25.8, + lng: -80.2, + owner: 'player', + grid: [], + placements: [], + incomePerTick: 0, + heat: 0, + morale: 70, + members: 0, + viewMode: 'topdown', + pendingIncome: 0, + dnaId, + }; +} + +describe('DEFAULT_GHOST_CREWS', () => { + it('seeds named crews with distinct personalities', () => { + expect(DEFAULT_GHOST_CREWS.length).toBeGreaterThanOrEqual(3); + const personalities = new Set(DEFAULT_GHOST_CREWS.map((c) => c.personality.type)); + expect(personalities.size).toBeGreaterThanOrEqual(3); + }); + + it('each crew has a roster and treasury', () => { + for (const crew of DEFAULT_GHOST_CREWS) { + expect(crew.roster.length).toBeGreaterThan(0); + expect(crew.treasury).toBeGreaterThan(0); + expect(crew.name).toBeTruthy(); + } + }); +}); + +describe('ghostClaimCost / ghostBlockIncome', () => { + it('cost scales with tier', () => { + const starter = BLOCK_DNA_LIBRARY.find((d) => d.tier === 'starter')!; + const elite = BLOCK_DNA_LIBRARY.find((d) => d.tier === 'elite')!; + expect(ghostClaimCost(elite)).toBeGreaterThan(ghostClaimCost(starter)); + }); + + it('income scales with the DNA income multiplier', () => { + const low = BLOCK_DNA_LIBRARY.reduce((a, b) => (a.incomeMultiplier < b.incomeMultiplier ? a : b)); + const high = BLOCK_DNA_LIBRARY.reduce((a, b) => (a.incomeMultiplier > b.incomeMultiplier ? a : b)); + expect(ghostBlockIncome(high)).toBeGreaterThan(ghostBlockIncome(low)); + }); +}); + +describe('pickClaimTarget', () => { + const crew = DEFAULT_GHOST_CREWS[2]; // money-crew, treasury 3000 + + it('returns an affordable, unclaimed DNA card', () => { + const target = pickClaimTarget(crew, makeCtx({ tickIndex: 3 })); + expect(target).toBeTruthy(); + expect(ghostClaimCost(target!)).toBeLessThanOrEqual(crew.treasury); + expect(crew.claimedDnaIds).not.toContain(target!.id); + }); + + it('is deterministic for the same game state', () => { + const a = pickClaimTarget(crew, makeCtx({ tickIndex: 5 })); + const b = pickClaimTarget(crew, makeCtx({ tickIndex: 5 })); + expect(a?.id).toBe(b?.id); + }); + + it('returns null when the crew cannot afford any card', () => { + const broke: GhostCrew = { ...crew, treasury: 0 }; + expect(pickClaimTarget(broke, makeCtx())).toBeNull(); + }); + + it('skips DNA cards the player already owns', () => { + const dna = BLOCK_DNA_LIBRARY[0]; + const rich: GhostCrew = { ...crew, treasury: 99999 }; + const ctx = makeCtx({ playerBlocks: [playerBlock('p1', dna.id)] }); + const target = pickClaimTarget(rich, ctx); + expect(target?.id).not.toBe(dna.id); + }); +}); + +describe('decideGhostAction', () => { + it('lays low when the roster is nearly wiped', () => { + const crew: GhostCrew = { + ...DEFAULT_GHOST_CREWS[0], + roster: DEFAULT_GHOST_CREWS[0].roster.map((m, i) => ({ ...m, alive: i === 0 })), + }; + const action = decideGhostAction(crew, makeCtx()); + expect(action.type).toBe('lay-low'); + }); + + it('revenge-driven crews with a hot grudge attack the player', () => { + const crew: GhostCrew = { + ...DEFAULT_GHOST_CREWS[1], // revenge-driven, grudgeWeight 90 + grudge: { score: 80 }, + }; + const action = decideGhostAction(crew, makeCtx({ playerBlocks: [playerBlock('pb-1')], tickIndex: 7 })); + expect(action.type).toBe('attack'); + expect(action.threatensPlayer).toBe(true); + expect(action.targetBlockId).toBe('pb-1'); + }); + + it('expansion-driven crews claim new turf', () => { + const crew: GhostCrew = { + ...DEFAULT_GHOST_CREWS[0], // territory-hungry, expansionDrive 85 + grudge: { score: 0 }, + }; + // Try several ticks — at least one should choose expansion over the + // deterministic roll. + const types = [1, 2, 3, 4, 5, 6, 7, 8].map( + (t) => decideGhostAction(crew, makeCtx({ tickIndex: t })).type, + ); + expect(types).toContain('claim'); + }); + + it('money crews with no expansion target reinforce', () => { + const crew: GhostCrew = { + ...DEFAULT_GHOST_CREWS[2], + treasury: 0, // cannot afford any claim + personality: { ...DEFAULT_GHOST_CREWS[2].personality, aggression: 0 }, + grudge: { score: 0 }, + }; + const action = decideGhostAction(crew, makeCtx({ tickIndex: 4 })); + expect(['reinforce', 'lay-low']).toContain(action.type); + }); +}); + +describe('applyGhostAction', () => { + it('claim spends treasury and records the block + DNA', () => { + const crew = DEFAULT_GHOST_CREWS[2]; // treasury 3000 + const target = pickClaimTarget(crew, makeCtx({ tickIndex: 3 }))!; + const action = { + type: 'claim' as const, + crewId: crew.id, + crewName: crew.name, + description: '', + claimedDnaId: target.id, + targetBlockId: `ghost-${target.id}`, + threatensPlayer: false, + }; + const updated = applyGhostAction(crew, action); + expect(updated.treasury).toBe(crew.treasury - ghostClaimCost(target)); + expect(updated.ownedBlockIds).toContain(`ghost-${target.id}`); + expect(updated.claimedDnaIds).toContain(target.id); + expect(updated.incomePerTick).toBeGreaterThan(0); + }); + + it('reinforce banks income into the treasury', () => { + const crew: GhostCrew = { ...DEFAULT_GHOST_CREWS[0], incomePerTick: 300 }; + const action = { + type: 'reinforce' as const, + crewId: crew.id, + crewName: crew.name, + description: '', + threatensPlayer: false, + }; + const updated = applyGhostAction(crew, action); + expect(updated.treasury).toBe(crew.treasury + 300); + }); + + it('attack cools the grudge and costs the crew', () => { + const crew: GhostCrew = { ...DEFAULT_GHOST_CREWS[1], grudge: { score: 60 } }; + const action = { + type: 'attack' as const, + crewId: crew.id, + crewName: crew.name, + description: '', + threatensPlayer: true, + }; + const updated = applyGhostAction(crew, action); + expect(updated.grudge.score).toBeLessThan(crew.grudge.score); + expect(updated.treasury).toBeLessThan(crew.treasury); + }); + + it('lay-low revives a downed member', () => { + const crew: GhostCrew = { + ...DEFAULT_GHOST_CREWS[0], + roster: DEFAULT_GHOST_CREWS[0].roster.map((m, i) => ({ ...m, alive: i !== 1 })), + }; + const action = { + type: 'lay-low' as const, + crewId: crew.id, + crewName: crew.name, + description: '', + threatensPlayer: false, + }; + const updated = applyGhostAction(crew, action); + expect(updated.roster.filter((m) => m.alive).length).toBe(crew.roster.length); + }); +}); + +describe('grudge memory', () => { + it('player attacks raise the grudge score persistently', () => { + const crew = DEFAULT_GHOST_CREWS[0]; + const hit1 = addGrudge(crew, 'block-a', 25); + expect(hit1.grudge.score).toBe(25); + const hit2 = addGrudge(hit1, 'block-a', 25); + expect(hit2.grudge.score).toBe(50); + expect(hit2.grudge.lastIncidentBlockId).toBe('block-a'); + }); + + it('grudge is capped at 100', () => { + const crew: GhostCrew = { ...DEFAULT_GHOST_CREWS[0], grudge: { score: 90 } }; + const hit = addGrudge(crew, 'block-b', 50); + expect(hit.grudge.score).toBe(100); + }); +}); + +describe('buildGhostBlock', () => { + it('creates an npc-owned block stamped with the DNA identity', () => { + const crew = DEFAULT_GHOST_CREWS[0]; + const dna = getDNAById('las-olas-1208')!; + const block = buildGhostBlock(crew, dna); + expect(block.owner).toBe('npc'); + expect(block.ownerGangName).toBe(crew.name); + expect(block.dnaId).toBe(dna.id); + expect(block.id).toBe(`ghost-${dna.id}`); + expect(block.incomeMultiplier).toBe(dna.incomeMultiplier); + expect(block.maxMembers).toBe(dna.maxMembers); + expect(block.grid).toHaveLength(8); + }); +}); diff --git a/frontend/src/utils/ghostCrewEngine.ts b/frontend/src/utils/ghostCrewEngine.ts new file mode 100644 index 0000000..13c5ce8 --- /dev/null +++ b/frontend/src/utils/ghostCrewEngine.ts @@ -0,0 +1,450 @@ +// ============================================================ +// DEALT/SLIDE — Ghost Crew Engine (#81) +// +// The computer as the rival gang. Persistent NPC crews play by the same +// rules as the player: they claim blocks from the Block DNA library, +// reinforce turf, retaliate when attacked, and expand into open ground. +// +// Design contract: +// - Crew state is PERSISTENT (Zustand persist → localStorage) — never +// regenerated per session. A named crew keeps its roster, treasury, +// personality, grudges, and owned blocks across reloads. +// - One world-tick decision per crew per tick: claim / reinforce / +// attack / lay-low, chosen from a personality card. +// - Claims come from the Block DNA library (#80) adjacent to the crew's +// turf; contested blocks resolve through the normal combat path. +// - Grudge memory: player attacks raise a persistent grudge score that +// biases target selection toward revenge. +// - City Feed surfacing: every visible consequence is pushed to the +// notification store so the opposition feels alive. +// +// This engine owns territory through useBlockStore.blocks (owner: 'npc'), +// which is the same store the territory map, recon ring, and encounter +// systems read — so ghost turf shows up everywhere the player's does. +// ============================================================ + +import { BLOCK_DNA_LIBRARY, type BlockDNA } from '../config/blockDNA'; +import { generateBlockHash } from '../config/mapbox.config'; +import type { BlockData } from '../types/block.types'; + +// ─── Types ─────────────────────────────────────────────────── + +export type PersonalityType = 'territory-hungry' | 'revenge-driven' | 'money-crew' | 'chaotic'; + +export interface Personality { + type: PersonalityType; + /** 0-100: baseline willingness to attack */ + aggression: number; + /** 0-100: desire to expand into open blocks */ + expansionDrive: number; + /** 0-100: how strongly grudges bias target selection */ + grudgeWeight: number; + /** 0-100: preference for defending/earning over fighting */ + caution: number; +} + +export interface GhostMember { + id: string; + name: string; + role: 'shooter' | 'dealer' | 'enforcer'; + level: number; + alive: boolean; +} + +export interface GrudgeEntry { + /** Cumulative grudge score against the player (0-100) */ + score: number; + /** Block the player last took from / attacked on this crew */ + lastIncidentBlockId?: string; + /** ISO timestamp of last incident */ + lastIncidentAt?: string; +} + +export interface GhostCrew { + id: string; + name: string; + /** DNA-flavored home turf tag (e.g. 'downtown') */ + homeTag: string; + personality: Personality; + /** Liquid cash the crew can spend on claims/reinforcement */ + treasury: number; + roster: GhostMember[]; + /** Block IDs this crew currently owns in blockStore */ + ownedBlockIds: string[]; + /** DNA ids this crew has already claimed (never reclaim the same card) */ + claimedDnaIds: string[]; + grudge: GrudgeEntry; + /** Total income this crew banks per tick from its blocks */ + incomePerTick: number; + /** ISO timestamp of last decision */ + lastTickAt: string; + /** Human-readable last move for the feed / panel */ + lastMove?: string; +} + +export type GhostActionType = 'claim' | 'reinforce' | 'attack' | 'lay-low'; + +export interface GhostAction { + type: GhostActionType; + crewId: string; + crewName: string; + /** Feed line shown to the player */ + description: string; + /** Block affected, when relevant */ + targetBlockId?: string; + targetBlockName?: string; + /** DNA card claimed, when type === 'claim' */ + claimedDnaId?: string; + /** Whether this move is a direct threat to the player */ + threatensPlayer: boolean; +} + +export interface GhostTickContext { + /** Player-owned blocks, read from blockStore */ + playerBlocks: BlockData[]; + /** All blocks already owned by any ghost crew */ + ghostOwnedBlockIds: Set; + /** Monotonic tick counter used for deterministic seeds */ + tickIndex: number; +} + +// ─── Name pools (deterministic) ────────────────────────────── + +const MEMBER_NAMES = [ + 'Ghost', 'Smoke', 'Trey', 'Bricks', 'Slim', 'Ace', 'Dre', 'Boonie', + 'Casper', 'Havoc', 'Static', 'Murk', 'Reap', 'Yayo', 'Frost', 'Loco', +]; + +function seeded(seed: number, salt: number): number { + const x = Math.sin(seed * 12.9898 + salt * 78.233) * 43758.5453; + return x - Math.floor(x); +} + +function pickName(seed: number, salt: number): string { + return MEMBER_NAMES[Math.floor(seeded(seed, salt) * MEMBER_NAMES.length)]; +} + +// ─── Default crew roster (seeded once, then persisted) ─────── + +function makeMember(id: string, name: string, role: GhostMember['role'], level: number): GhostMember { + return { id, name, role, level, alive: true }; +} + +export const DEFAULT_GHOST_CREWS: GhostCrew[] = [ + { + id: 'ghost-nightfall', + name: 'Nightfall Crew', + homeTag: 'downtown', + personality: { type: 'territory-hungry', aggression: 55, expansionDrive: 85, grudgeWeight: 40, caution: 30 }, + treasury: 2200, + roster: [ + makeMember('nf-1', 'Olas King', 'enforcer', 4), + makeMember('nf-2', 'Strip Boss', 'shooter', 4), + makeMember('nf-3', 'Beach Boy', 'dealer', 3), + ], + ownedBlockIds: [], + claimedDnaIds: [], + grudge: { score: 0 }, + incomePerTick: 0, + lastTickAt: new Date(Date.now() - 300_000).toISOString(), + lastMove: 'Controlling downtown Las Olas', + }, + { + id: 'ghost-sistrunk', + name: 'Sistrunk Ghosts', + homeTag: 'eastside', + personality: { type: 'revenge-driven', aggression: 80, expansionDrive: 45, grudgeWeight: 90, caution: 20 }, + treasury: 1500, + roster: [ + makeMember('sg-1', 'Fed Buster', 'enforcer', 4), + makeMember('sg-2', 'All-Day', 'shooter', 3), + makeMember('sg-3', 'Zero Fed', 'shooter', 3), + ], + ownedBlockIds: [], + claimedDnaIds: [], + grudge: { score: 15 }, + incomePerTick: 0, + lastTickAt: new Date(Date.now() - 600_000).toISOString(), + lastMove: 'Watching Sistrunk Blvd', + }, + { + id: 'ghost-riverwalk', + name: 'Riverwalk Money Crew', + homeTag: 'southside', + personality: { type: 'money-crew', aggression: 30, expansionDrive: 55, grudgeWeight: 25, caution: 80 }, + treasury: 3000, + roster: [ + makeMember('rm-1', 'Lucky 7', 'dealer', 4), + makeMember('rm-2', 'Down-Low', 'dealer', 3), + makeMember('rm-3', 'Seven-Up', 'enforcer', 3), + ], + ownedBlockIds: [], + claimedDnaIds: [], + grudge: { score: 0 }, + incomePerTick: 0, + lastTickAt: new Date(Date.now() - 900_000).toISOString(), + lastMove: 'Running the Riverwalk docks', + }, + { + id: 'ghost-chaos', + name: 'Westside Wolves', + homeTag: 'westside', + personality: { type: 'chaotic', aggression: 70, expansionDrive: 65, grudgeWeight: 55, caution: 10 }, + treasury: 1200, + roster: [ + makeMember('ww-1', 'Cloud 9', 'shooter', 2), + makeMember('ww-2', 'Nine-Life', 'dealer', 2), + makeMember('ww-3', 'Lil Niner', 'enforcer', 2), + ], + ownedBlockIds: [], + claimedDnaIds: [], + grudge: { score: 5 }, + incomePerTick: 0, + lastTickAt: new Date(Date.now() - 450_000).toISOString(), + lastMove: 'Tagging the west side', + }, +]; + +// ─── Claim economics ───────────────────────────────────────── + +/** Cost for a ghost crew to claim a DNA block (same scale as the player). */ +export function ghostClaimCost(dna: BlockDNA): number { + const tierCost: Record = { + starter: 1000, + mid: 2000, + high: 3500, + elite: 5000, + }; + return tierCost[dna.tier]; +} + +/** Income a ghost crew banks per tick from an owned DNA block. */ +export function ghostBlockIncome(dna: BlockDNA): number { + return Math.round(120 * dna.incomeMultiplier); +} + +/** + * Pick the next DNA card a crew should claim. + * + * Preference order: + * 1. Unclaimed cards the crew can afford + * 2. Tier matching the crew's expansion drive (aggressive crews reach up, + * cautious crews stay in their lane) + * 3. Deterministic tie-break on the tick seed so the same game state always + * produces the same move + */ +export function pickClaimTarget( + crew: GhostCrew, + ctx: GhostTickContext, +): BlockDNA | null { + const affordable = BLOCK_DNA_LIBRARY.filter((dna) => { + if (crew.claimedDnaIds.includes(dna.id)) return false; + if (ctx.ghostOwnedBlockIds.has(`ghost-${dna.id}`)) return false; + // The player already holds this block + if (ctx.playerBlocks.some((b) => b.dnaId === dna.id)) return false; + return ghostClaimCost(dna) <= crew.treasury; + }); + if (affordable.length === 0) return null; + + const tierRank: Record = { starter: 0, mid: 1, high: 2, elite: 3 }; + // Expansion drive pushes the crew toward higher tiers. + const reach = crew.personality.expansionDrive >= 70 ? 1 : 0; + const scored = affordable + .map((dna) => { + const tierScore = tierRank[dna.tier] + reach; + const jitter = seeded(ctx.tickIndex, crew.id.length + dna.id.length); + return { dna, score: tierScore + jitter * 0.5 }; + }) + .sort((a, b) => b.score - a.score); + return scored[0]?.dna ?? null; +} + +/** Build the BlockData a ghost crew owns after claiming a DNA card. */ +export function buildGhostBlock(crew: GhostCrew, dna: BlockDNA): BlockData { + const id = `ghost-${dna.id}`; + const seed = generateBlockHash(dna.lat, dna.lng); + const grid = Array.from({ length: 8 }, (_, y) => + Array.from({ length: 8 }, (_, x) => { + const zoneType = (dna.zoneOverrides?.[y] ?? 'sidewalk') as BlockData['grid'][0][0]['zoneType']; + return { + x, y, zoneType, + incomeModifier: 0, exposureRisk: 0, coverScore: 0, + passable: zoneType !== 'building' && zoneType !== 'street', + occupantId: null, + }; + }), + ); + return { + id, + address: dna.address, + lat: dna.lat, + lng: dna.lng, + owner: 'npc', + ownerGangName: crew.name, + grid, + placements: [], + incomePerTick: ghostBlockIncome(dna), + heat: dna.startingHeat, + morale: dna.startingMorale, + members: crew.roster.filter((m) => m.alive).length, + viewMode: 'topdown', + pendingIncome: 0, + dnaId: dna.id, + incomeMultiplier: dna.incomeMultiplier, + heatDecayMultiplier: dna.heatDecayMultiplier, + maxMembers: dna.maxMembers, + // Stable identity so the encounter pipeline resolves the same scene. + taggedBy: undefined, + taggedAt: undefined, + ...(seed ? {} : {}), + }; +} + +// ─── Decision loop ─────────────────────────────────────────── + +/** + * Decide one crew's move for this tick. + * + * Personality drives the choice: + * - lay-low when out of members, out of cash, or cautious & hot + * - claim when it can afford a DNA block and wants to expand + * - attack when it holds a grudge or is aggressive and the player + * owns blocks + * - reinforce otherwise (bank income, heal, build treasury) + */ +export function decideGhostAction(crew: GhostCrew, ctx: GhostTickContext): GhostAction { + const alive = crew.roster.filter((m) => m.alive).length; + const p = crew.personality; + const roll = seeded(ctx.tickIndex, crew.id.length * 7 + alive); + const base: Omit = { + crewId: crew.id, + crewName: crew.name, + threatensPlayer: false, + }; + + // 1. Regroup when the crew is broken. + if (alive <= 1) { + return { + ...base, + type: 'lay-low', + description: `${crew.name} is regrouping after losing too many members.`, + }; + } + + // 2. Revenge-driven crews with a hot grudge go after the player. + const wantsRevenge = + crew.grudge.score > 30 && + ctx.playerBlocks.length > 0 && + roll * 100 < p.grudgeWeight; + if (wantsRevenge) { + const target = ctx.playerBlocks[Math.floor(seeded(ctx.tickIndex, 99) * ctx.playerBlocks.length)]; + return { + ...base, + type: 'attack', + targetBlockId: target.id, + targetBlockName: target.address, + threatensPlayer: true, + description: `${crew.name} is coming for ${target.address} — payback for the last hit.`, + }; + } + + // 3. Expansion: claim a new DNA block when affordable and driven. + const wantsExpand = roll * 100 < p.expansionDrive; + if (wantsExpand) { + const target = pickClaimTarget(crew, ctx); + if (target) { + return { + ...base, + type: 'claim', + claimedDnaId: target.id, + targetBlockId: `ghost-${target.id}`, + targetBlockName: target.name, + description: `${crew.name} claimed ${target.name}.`, + }; + } + } + + // 4. Aggressive crews raid the player even without a grudge. + const wantsRaid = + ctx.playerBlocks.length > 0 && + roll * 100 < p.aggression * (1 - p.caution / 200); + if (wantsRaid) { + const target = ctx.playerBlocks[Math.floor(seeded(ctx.tickIndex, 7) * ctx.playerBlocks.length)]; + return { + ...base, + type: 'attack', + targetBlockId: target.id, + targetBlockName: target.address, + threatensPlayer: true, + description: `${crew.name} is probing ${target.address}.`, + }; + } + + // 5. Default: reinforce / bank income. + return { + ...base, + type: 'reinforce', + description: `${crew.name} is reinforcing its turf and stacking cash.`, + }; +} + +/** + * Apply a decided action to a crew, returning the updated crew. + * Claim spends treasury and adds the block; reinforce banks income; + * attack spends a little on the hit; lay-low heals and cools the grudge. + */ +export function applyGhostAction(crew: GhostCrew, action: GhostAction): GhostCrew { + const updated: GhostCrew = { + ...crew, + lastTickAt: new Date().toISOString(), + lastMove: action.description, + }; + + switch (action.type) { + case 'claim': { + const dna = BLOCK_DNA_LIBRARY.find((d) => d.id === action.claimedDnaId); + if (!dna) return updated; + const cost = ghostClaimCost(dna); + return { + ...updated, + treasury: Math.max(0, crew.treasury - cost), + ownedBlockIds: [...crew.ownedBlockIds, `ghost-${dna.id}`], + claimedDnaIds: [...crew.claimedDnaIds, dna.id], + incomePerTick: crew.incomePerTick + ghostBlockIncome(dna), + }; + } + case 'reinforce': + return { ...updated, treasury: crew.treasury + crew.incomePerTick }; + case 'attack': + return { + ...updated, + treasury: Math.max(0, crew.treasury - 150), + grudge: { ...crew.grudge, score: Math.max(0, crew.grudge.score - 10) }, + }; + case 'lay-low': + return { + ...updated, + treasury: crew.treasury + Math.round(crew.incomePerTick * 0.5), + grudge: { ...crew.grudge, score: Math.max(0, crew.grudge.score - 5) }, + roster: crew.roster.map((m, i) => + !m.alive && i === crew.roster.findIndex((x) => !x.alive) + ? { ...m, alive: true, name: pickName(action.crewId.length, Date.now() % 97) } + : m, + ), + }; + default: + return updated; + } +} + +/** Raise a crew's grudge after the player attacks one of its blocks. */ +export function addGrudge(crew: GhostCrew, blockId: string, amount: number): GhostCrew { + return { + ...crew, + grudge: { + score: Math.min(100, crew.grudge.score + amount), + lastIncidentBlockId: blockId, + lastIncidentAt: new Date().toISOString(), + }, + }; +} From b616279642b307af9373613b02ba97934cfed19c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:42:10 +0000 Subject: [PATCH 06/11] feat(beta): smoke tests + release checklist + project log; final validation Co-authored-by: BrandDead <174973198+BrandDead@users.noreply.github.com> --- docs/PROJECT_LOG.md | 41 +++++++++ docs/RELEASE_CHECKLIST.md | 43 ++++++++++ frontend/src/__tests__/betaGate.smoke.test.ts | 84 +++++++++++++++++++ frontend/src/stores/ghostCrewStore.ts | 2 +- .../utils/__tests__/blockDNAResolver.test.ts | 2 +- 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 docs/RELEASE_CHECKLIST.md create mode 100644 frontend/src/__tests__/betaGate.smoke.test.ts diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index d5568c7..1b5f8c6 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -84,6 +84,47 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN (197 existing warnings); production build passed with existing chunk/asset warnings; asset audit passed at 5.73 MB / 20 MB with seven existing orphan warnings. +### 2026-08-24 — Visual foundation + Block DNA growth + ghost crews (#78/#79/#80/#81) + +Landed the four-phase dev plan against the open roadmap issues. + +- **#79 asset cleanup & CI gate.** Registered the 7 hand-added runtime assets + (Las Olas street/topdown plates + 5 UI plates) into `runtimeManifest.json` — + the audit now reports 0 errors / 0 warnings at 5.73 MB / 20 MB. Added + `npm run assets:audit` as a CI step so orphan/oversize/no-alpha regressions + fail the build. Fixed 8 stale `.png` references to files that only ship as + `.webp`. +- **#78 manifest wiring.** `assetResolver.getStreetSpriteUrl` now delegates to + the manifest-driven `worldActorResolver`, so every role with shipped street + art (dealer, shooter, enforcer, lookout, driver, recruit, k9) resolves a real + sprite instead of the old 3-role `STREET_STATES` table. Fixed 10 stale + `assetManifest.ts` paths (png→webp, topdown `_idle` suffixes, dealer street, + product icons, UI icons) — all 55 manifest paths now verify on disk. Audited + renderers: none bypass the resolver except `GameSprite`, which is icon-only. +- **#80 Block DNA growth.** Library grew 8 → 17 cards across starter/mid/high/ + elite tiers (urban decay, suburban, gated estates). Claiming a block now + stamps `dnaId` / `incomeMultiplier` / `heatDecayMultiplier` / `maxMembers` + onto `BlockData`, and `blockStore` scales placement income by the DNA + multiplier. Acceptance tests prove a starter and an elite block resolve to + different stats and layouts with no Mapbox call. +- **#81 NPC rival crew v1.** New `ghostCrewEngine.ts` + `ghostCrewStore.ts`: + persistent named crews (territory-hungry / revenge-driven / money-crew / + chaotic personalities) claim DNA blocks, reinforce, attack, or lay low on a + 30 s world tick, playing by the same economy rules as the player. Ghost turf + is written into `blockStore` as `owner:'npc'` so the map, recon ring, and + encounter pipeline all see it. Player kills on ghost turf raise a persistent + grudge (wired through `combatIntentStore.targetCrewId` → `recordPlayerAttack`) + that biases the crew toward revenge. Moves surface through a new + `GhostThreatBanner` and the City Feed. 19 engine + 4 store integration tests; + the store test proves a passive player loses ground over 12 ticks. +- **#45 beta gate scaffolding.** Added `src/__tests__/betaGate.smoke.test.ts` + (7 shallow assertions, one per pillar: territory, economy, combat, npc, + assets) and `docs/RELEASE_CHECKLIST.md` with the full gate + explicit + deferral list. + +Validation: `npm run typecheck` clean; Vitest 674/674 across 41 files; +`npm run assets:audit` 0 errors / 0 warnings. + ### 2026-08-19 — Game experience audit + Cash App vault / Maps hood / combat HUD Audited the player-facing loop (empire → shoebox → map → combat) and closed the biggest diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..b01f856 --- /dev/null +++ b/docs/RELEASE_CHECKLIST.md @@ -0,0 +1,43 @@ +# RELEASE CHECKLIST — DEALT / SLIDE Beta (#45) + +Gate for shipping a build to outside testers. Check every box; a "no" blocks release. + +## Core gameplay loop +- [ ] Claim → place crew → earn → collect → spend loop works end-to-end offline (demo mode) +- [ ] A drive-by can be launched from the map and resolves into vault deposit + heat +- [ ] Ghost crews claim turf, surface in the feed, and retaliate after a player hit (#81) +- [ ] No blocker-level crash in: OS shell, map, block view, drive-by, shoebox, crew + +## Visuals +- [ ] No emoji or flat-color cells in any player-facing view (#77) +- [ ] All world actors resolve through `worldActorResolver` (no hardcoded sprite URLs) +- [ ] `npm run assets:audit` passes: 0 errors, 0 warnings, ≤ 20 MB runtime budget (#79) +- [ ] Street + top-down scenes render real art for every placed role (#78) + +## Systems +- [ ] `npm run typecheck` clean +- [ ] `npm test` green (Vitest — includes `betaGate.smoke.test.ts`) +- [ ] `npm run build` succeeds; Vercel preview deploys +- [ ] Backend `pytest` green in offline mock mode +- [ ] CI green on the release branch (lint + asset audit + build + pytest) + +## Performance +- [ ] First paint ≤ 3 s on a mid laptop in demo mode +- [ ] Map pans/zooms without dropped frames with ≥ 20 blocks on screen +- [ ] No `getImageData` per-frame work in the combat renderers (build-time defringe only) + +## Compliance / safety +- [ ] Age gate shows on first launch and is versioned (#76) +- [ ] Fictional-content notice renders on encounter scenes +- [ ] No real-person likenesses; all art is generated or licensed + +## Known deferrals (explicitly NOT in this beta) +- Multiplayer / real PvP (ghost crews stand in — #81) +- Full Block DNA library at 30–40 cards (currently 17 — #80) +- Lookout/driver street-aim/fire art (idle + fullbody shipped — #78) +- Mapbox as the combat foundation (selection/recon only — #80) +- Payments/monetization P0s — see `docs/MVP_STATUS_AND_DEV_PLAN_2026-07-16.md` + +## Rollback +- [ ] `backup/pre-pr76` branch intact as the rollback anchor +- [ ] Release tag created and logged in `docs/PROJECT_LOG.md` diff --git a/frontend/src/__tests__/betaGate.smoke.test.ts b/frontend/src/__tests__/betaGate.smoke.test.ts new file mode 100644 index 0000000..3c738e8 --- /dev/null +++ b/frontend/src/__tests__/betaGate.smoke.test.ts @@ -0,0 +1,84 @@ +// ============================================================ +// Beta Gate smoke tests (#45) +// One passing assertion per core pillar proves the module boots and its +// primary contract holds. These are deliberately shallow — the deep +// coverage lives in each system's own test file — so a regression in any +// pillar fails fast here before a beta build ships. +// ============================================================ +import { describe, it, expect } from 'vitest'; + +describe('beta gate — core pillars smoke', () => { + it('territory: Block DNA library loads with all tiers represented', async () => { + const { BLOCK_DNA_LIBRARY, getDNAByTier } = await import('../config/blockDNA'); + expect(BLOCK_DNA_LIBRARY.length).toBeGreaterThanOrEqual(15); + for (const tier of ['starter', 'mid', 'high', 'elite'] as const) { + expect(getDNAByTier(tier).length).toBeGreaterThan(0); + } + }); + + it('territory: address → DNA resolves deterministically with no Mapbox call', async () => { + const { resolveBlockDNA } = await import('../utils/blockDNAResolver'); + const a = resolveBlockDNA(26.1186, -80.1575, '1208 W Las Olas Blvd'); + const b = resolveBlockDNA(26.1186, -80.1575, '1208 W Las Olas Blvd'); + expect(a.dna.id).toBe(b.dna.id); + expect(a.zoneLayout).toHaveLength(8); + }); + + it('economy: block store computes DNA-scaled income', async () => { + const { useBlockStore } = await import('../stores/blockStore'); + const { generateDefaultGrid } = useBlockStore.getState(); + const grid = generateDefaultGrid(); + expect(grid).toHaveLength(8); + expect(grid[0]).toHaveLength(8); + // A dealer on a street cell earns more than on an alley cell. + expect(grid[0][0].incomeModifier).toBeGreaterThan(grid[4][2].incomeModifier); + }); + + it('combat: encounter preparation builds terrain + crews from a block', async () => { + const { prepareEncounter } = await import('../game/combat/prepareEncounter'); + const { useBlockStore } = await import('../stores/blockStore'); + const grid = useBlockStore.getState().generateDefaultGrid(); + const prep = prepareEncounter({ + id: 'smoke-block', + address: '1208 W Las Olas Blvd', + lat: 26.1186, + lng: -80.1575, + owner: 'player', + grid, + placements: [], + incomePerTick: 0, + heat: 1, + morale: 70, + members: 0, + viewMode: 'topdown', + pendingIncome: 0, + }); + expect(prep.terrain).toHaveLength(8); + expect(prep.crew.length).toBeGreaterThan(0); + expect(prep.opposition.length).toBeGreaterThan(0); + }); + + it('npc: ghost crews seed and hold persistent state', async () => { + const { useGhostStore } = await import('../stores/ghostCrewStore'); + useGhostStore.setState({ crews: {}, feed: [], tickIndex: 0, tickActive: false }); + useGhostStore.getState().seedCrews(); + const crews = Object.values(useGhostStore.getState().crews); + expect(crews.length).toBeGreaterThanOrEqual(3); + expect(crews.every((c) => c.name && c.roster.length > 0)).toBe(true); + }); + + it('assets: runtime manifest has entries within budget', async () => { + const manifest = (await import('../assets/runtimeManifest.json')).default; + expect(manifest.entries.length).toBeGreaterThan(100); + expect(manifest.totalBytes / 1048576).toBeLessThanOrEqual(manifest.budgetMB); + }); + + it('assets: world actor resolver returns art for every core role', async () => { + const { getWorldActor } = await import('../render/worldActorResolver'); + for (const role of ['dealer', 'shooter', 'enforcer', 'lookout', 'driver'] as const) { + const actor = getWorldActor(role, 'idle', 'street'); + expect(actor, `no street art for ${role}`).toBeTruthy(); + expect(actor!.url).toMatch(/\.webp$/); + } + }); +}); diff --git a/frontend/src/stores/ghostCrewStore.ts b/frontend/src/stores/ghostCrewStore.ts index 99b2dea..b1c1fbb 100644 --- a/frontend/src/stores/ghostCrewStore.ts +++ b/frontend/src/stores/ghostCrewStore.ts @@ -210,7 +210,7 @@ const GHOST_TICK_MS = 30_000; // 30 s real time = one world tick * sessions. */ export function useGhostTick(): void { - const { crews, seedCrews, runTick, setTickActive } = useGhostStore(); + const { crews, seedCrews, setTickActive } = useGhostStore(); // Seed once on first mount. useEffect(() => { diff --git a/frontend/src/utils/__tests__/blockDNAResolver.test.ts b/frontend/src/utils/__tests__/blockDNAResolver.test.ts index 9f391bb..a12a016 100644 --- a/frontend/src/utils/__tests__/blockDNAResolver.test.ts +++ b/frontend/src/utils/__tests__/blockDNAResolver.test.ts @@ -1,6 +1,6 @@ // Tests for blockDNAResolver — address-to-block-archetype pipeline import { describe, it, expect } from 'vitest'; -import { resolveBlockDNA, type ResolvedBlock } from '../blockDNAResolver'; +import { resolveBlockDNA } from '../blockDNAResolver'; // ─── Las Olas Blvd, Fort Lauderdale ────────────────────────────────────────── const LAS_OLAS = { lat: 26.1195, lng: -80.1368, address: '1208 W Las Olas Blvd, Fort Lauderdale, FL 33312' }; From f192ad7911af28716bad3bba9c976255cc8582c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:44:26 +0000 Subject: [PATCH 07/11] =?UTF-8?q?refactor(npc):=20address=20code=20review?= =?UTF-8?q?=20=E2=80=94=20drop=20dead=20code,=20keep=20Zustand=20updater?= =?UTF-8?q?=20pure,=20fix=20test=20loop=20condition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BrandDead <174973198+BrandDead@users.noreply.github.com> --- .../stores/__tests__/ghostCrewStore.test.ts | 5 +- frontend/src/stores/ghostCrewStore.ts | 57 ++++++++----------- frontend/src/utils/ghostCrewEngine.ts | 6 -- 3 files changed, 28 insertions(+), 40 deletions(-) diff --git a/frontend/src/stores/__tests__/ghostCrewStore.test.ts b/frontend/src/stores/__tests__/ghostCrewStore.test.ts index 5fd0422..230a9bf 100644 --- a/frontend/src/stores/__tests__/ghostCrewStore.test.ts +++ b/frontend/src/stores/__tests__/ghostCrewStore.test.ts @@ -58,9 +58,10 @@ describe('ghostCrewStore', () => { it('crewForBlock resolves the owner of a claimed block', () => { useGhostStore.getState().seedCrews(); - // Force a claim by running ticks until at least one crew owns a block. - for (let i = 0; i < 12 && useBlockStore.getState().blocks.length === undefined; i++) { + // Run ticks until at least one crew owns a block (bounded). + for (let i = 0; i < 12; i++) { useGhostStore.getState().runTick(); + if (Object.keys(useBlockStore.getState().blocks).length > 0) break; } const npcBlock = Object.values(useBlockStore.getState().blocks).find((b) => b.owner === 'npc'); if (npcBlock) { diff --git a/frontend/src/stores/ghostCrewStore.ts b/frontend/src/stores/ghostCrewStore.ts index b1c1fbb..08011db 100644 --- a/frontend/src/stores/ghostCrewStore.ts +++ b/frontend/src/stores/ghostCrewStore.ts @@ -15,7 +15,7 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useCallback } from 'react'; import { DEFAULT_GHOST_CREWS, decideGhostAction, @@ -89,34 +89,34 @@ export const useGhostStore = create()( }, recordPlayerAttack(crewId, blockId) { + // Read + derive first, then set, then notify — keep the Zustand + // updater pure (no side effects inside set()). + const crew = get().crews[crewId]; + if (!crew) return; + const updated = addGrudge(crew, blockId, 25); + const feedEvent: GhostFeedEvent = { + id: `grudge-${Date.now()}`, + crewId, + crewName: crew.name, + action: 'attack', + description: `${crew.name} will remember what you did on their block.`, + targetBlockId: blockId, + timestamp: Date.now(), + }; set( - (state) => { - const crew = state.crews[crewId]; - if (!crew) return state; - const updated = addGrudge(crew, blockId, 25); - const feed: GhostFeedEvent = { - id: `grudge-${Date.now()}`, - crewId, - crewName: crew.name, - action: 'attack', - description: `${crew.name} will remember what you did on their block.`, - targetBlockId: blockId, - timestamp: Date.now(), - }; - useNotificationStore.getState().addNotification({ - type: 'warning', - title: `${crew.name} holds a grudge`, - message: `Your hit on their turf raised their grudge to ${updated.grudge.score}. Expect payback.`, - priority: 'high', - }); - return { - crews: { ...state.crews, [crewId]: updated }, - feed: [feed, ...state.feed].slice(0, FEED_LIMIT), - }; - }, + (state) => ({ + crews: { ...state.crews, [crewId]: updated }, + feed: [feedEvent, ...state.feed].slice(0, FEED_LIMIT), + }), false, 'ghost/recordPlayerAttack', ); + useNotificationStore.getState().addNotification({ + type: 'warning', + title: `${crew.name} holds a grudge`, + message: `Your hit on their turf raised their grudge to ${updated.grudge.score}. Expect payback.`, + priority: 'high', + }); }, runTick() { @@ -229,13 +229,6 @@ export function useGhostTick(): void { setTickActive(false); }; }, [tick, setTickActive]); - - // Keep a live ref so other systems can read the latest crews without - // subscribing. - const crewsRef = useRef(crews); - useEffect(() => { - crewsRef.current = crews; - }, [crews]); } // ─── Selectors ─────────────────────────────────────────────── diff --git a/frontend/src/utils/ghostCrewEngine.ts b/frontend/src/utils/ghostCrewEngine.ts index 13c5ce8..cf78563 100644 --- a/frontend/src/utils/ghostCrewEngine.ts +++ b/frontend/src/utils/ghostCrewEngine.ts @@ -24,7 +24,6 @@ // ============================================================ import { BLOCK_DNA_LIBRARY, type BlockDNA } from '../config/blockDNA'; -import { generateBlockHash } from '../config/mapbox.config'; import type { BlockData } from '../types/block.types'; // ─── Types ─────────────────────────────────────────────────── @@ -262,7 +261,6 @@ export function pickClaimTarget( /** Build the BlockData a ghost crew owns after claiming a DNA card. */ export function buildGhostBlock(crew: GhostCrew, dna: BlockDNA): BlockData { const id = `ghost-${dna.id}`; - const seed = generateBlockHash(dna.lat, dna.lng); const grid = Array.from({ length: 8 }, (_, y) => Array.from({ length: 8 }, (_, x) => { const zoneType = (dna.zoneOverrides?.[y] ?? 'sidewalk') as BlockData['grid'][0][0]['zoneType']; @@ -293,10 +291,6 @@ export function buildGhostBlock(crew: GhostCrew, dna: BlockDNA): BlockData { incomeMultiplier: dna.incomeMultiplier, heatDecayMultiplier: dna.heatDecayMultiplier, maxMembers: dna.maxMembers, - // Stable identity so the encounter pipeline resolves the same scene. - taggedBy: undefined, - taggedAt: undefined, - ...(seed ? {} : {}), }; } From 99b11ac6b2b2fcb35763c92e0e713882d1249bea Mon Sep 17 00:00:00 2001 From: BrandDead <1.74973198e+08+BrandDead@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:34:54 +0000 Subject: [PATCH 08/11] feat(combat): add shared session controller for action modes --- .../game/combat/CombatSessionController.ts | 238 ++++++++++++++++++ .../__tests__/CombatSessionController.test.ts | 132 ++++++++++ 2 files changed, 370 insertions(+) create mode 100644 frontend/src/game/combat/CombatSessionController.ts create mode 100644 frontend/src/game/combat/__tests__/CombatSessionController.test.ts diff --git a/frontend/src/game/combat/CombatSessionController.ts b/frontend/src/game/combat/CombatSessionController.ts new file mode 100644 index 0000000..4fe7a0c --- /dev/null +++ b/frontend/src/game/combat/CombatSessionController.ts @@ -0,0 +1,238 @@ +import { + advanceCombat, + createCombatSession, + dispatchCombatCommand, + getCombatSnapshot, +} from './combatSession'; +import type { + CombatCommand, + CombatSession, + CombatSnapshot, + Combatant, + EncounterPreparation, + GridPoint, +} from './types'; + +export type ActionCameraMode = 'tactical' | 'first-person' | 'third-person'; + +export type CombatCommandWithoutSequence = + T extends CombatCommand ? Omit : never; + +export interface CombatHudState { + cameraMode: ActionCameraMode; + paused: boolean; + tick: number; + selectedId: string | null; + selectedName: string; + health: number; + maxHealth: number; + ammo: number; + maxAmmo: number; + activeCrew: number; + activeOpposition: number; + objectiveProgress: number; + objectiveTarget: number; + latestMessage: string; +} + +export interface CombatControllerState { + snapshot: CombatSnapshot; + hud: CombatHudState; +} + +type ControllerListener = (state: CombatControllerState) => void; + +function manhattan(a: GridPoint, b: GridPoint): number { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} + +/** + * Presentation-neutral owner of one deterministic combat session. + * + * Renderers may select actors, switch cameras, and submit commands, but they + * never mutate authoritative combat state directly. Camera state is deliberately + * kept outside CombatSession so switching presentation cannot reset or alter the + * outcome. + */ +export class CombatSessionController { + private session: CombatSession; + private selectedCrewId: string | null; + private sequence = 0; + private paused = false; + private cameraMode: ActionCameraMode; + private listeners = new Set(); + + constructor( + preparation: EncounterPreparation, + initialCameraMode: ActionCameraMode = 'tactical', + ) { + this.session = createCombatSession(preparation); + this.selectedCrewId = preparation.crew.find((actor) => !actor.isDown)?.id ?? null; + this.cameraMode = initialCameraMode; + } + + subscribe(listener: ControllerListener): () => void { + this.listeners.add(listener); + listener(this.getState()); + return () => this.listeners.delete(listener); + } + + dispose(): void { + this.listeners.clear(); + } + + getSnapshot(): CombatSnapshot { + return getCombatSnapshot(this.session); + } + + getState(): CombatControllerState { + return { + snapshot: this.getSnapshot(), + hud: this.getHudState(), + }; + } + + getCameraMode(): ActionCameraMode { + return this.cameraMode; + } + + setCameraMode(mode: ActionCameraMode): void { + if (mode === this.cameraMode) return; + this.cameraMode = mode; + this.emit(); + } + + cycleCameraMode(): ActionCameraMode { + const modes: ActionCameraMode[] = ['tactical', 'first-person', 'third-person']; + const currentIndex = modes.indexOf(this.cameraMode); + const next = modes[(currentIndex + 1) % modes.length]; + this.setCameraMode(next); + return next; + } + + isPaused(): boolean { + return this.paused; + } + + setPaused(paused: boolean): void { + if (paused === this.paused) return; + this.paused = paused; + this.emit(); + } + + selectCrew(actorId: string): boolean { + const actor = this.session.combatants.find( + (candidate) => candidate.id === actorId && candidate.team === 'crew' && !candidate.isDown, + ); + if (!actor) return false; + this.selectedCrewId = actor.id; + this.emit(); + return true; + } + + getSelectedCrew(): Combatant | undefined { + const selected = this.session.combatants.find( + (actor) => actor.id === this.selectedCrewId && actor.team === 'crew' && !actor.isDown, + ); + if (selected) return selected; + + const fallback = this.session.combatants.find((actor) => actor.team === 'crew' && !actor.isDown); + this.selectedCrewId = fallback?.id ?? null; + return fallback; + } + + advance(steps = 1): CombatSnapshot { + if (this.paused || this.session.phase !== 'active' || steps <= 0) return this.getSnapshot(); + this.session = advanceCombat(this.session, steps); + this.ensureSelectedCrew(); + this.emit(); + return this.getSnapshot(); + } + + dispatch(command: CombatCommandWithoutSequence): CombatSnapshot { + if (this.paused || this.session.phase !== 'active') return this.getSnapshot(); + this.sequence += 1; + this.session = dispatchCombatCommand( + this.session, + { ...command, sequence: this.sequence } as CombatCommand, + ); + this.ensureSelectedCrew(); + this.emit(); + return this.getSnapshot(); + } + + moveSelected(dx: number, dy: number): CombatSnapshot { + const actor = this.getSelectedCrew(); + if (!actor) return this.getSnapshot(); + return this.dispatch({ + type: 'move', + actorId: actor.id, + destination: { x: actor.position.x + dx, y: actor.position.y + dy }, + }); + } + + fireAt(targetId: string): CombatSnapshot { + const actor = this.getSelectedCrew(); + if (!actor) return this.getSnapshot(); + return this.dispatch({ type: 'aim-fire', actorId: actor.id, targetId }); + } + + fireNearest(): CombatSnapshot { + const actor = this.getSelectedCrew(); + if (!actor) return this.getSnapshot(); + const target = this.session.combatants + .filter((candidate) => candidate.team === 'opposition' && !candidate.isDown) + .sort((left, right) => manhattan(actor.position, left.position) - manhattan(actor.position, right.position))[0]; + return target ? this.fireAt(target.id) : this.getSnapshot(); + } + + reloadSelected(): CombatSnapshot { + const actor = this.getSelectedCrew(); + if (!actor) return this.getSnapshot(); + return this.dispatch({ type: 'reload', actorId: actor.id }); + } + + interactSelected(): CombatSnapshot { + const actor = this.getSelectedCrew(); + if (!actor) return this.getSnapshot(); + return this.dispatch({ type: 'interact', actorId: actor.id }); + } + + retreatSelected(): CombatSnapshot { + const actor = this.getSelectedCrew(); + if (!actor) return this.getSnapshot(); + return this.dispatch({ type: 'retreat', actorId: actor.id }); + } + + getHudState(): CombatHudState { + const selected = this.getSelectedCrew(); + const activeCrew = this.session.combatants.filter((actor) => actor.team === 'crew' && !actor.isDown).length; + const activeOpposition = this.session.combatants.filter((actor) => actor.team === 'opposition' && !actor.isDown).length; + return { + cameraMode: this.cameraMode, + paused: this.paused, + tick: this.session.tick, + selectedId: selected?.id ?? null, + selectedName: selected?.name ?? 'No active crew', + health: selected?.health ?? 0, + maxHealth: selected?.maxHealth ?? 0, + ammo: selected?.ammo ?? 0, + maxAmmo: selected?.maxAmmo ?? 0, + activeCrew, + activeOpposition, + objectiveProgress: this.session.objective.progress, + objectiveTarget: this.session.objective.target, + latestMessage: this.session.events.at(-1)?.message ?? 'Move through cover and secure the exit.', + }; + } + + private ensureSelectedCrew(): void { + this.getSelectedCrew(); + } + + private emit(): void { + if (this.listeners.size === 0) return; + const state = this.getState(); + this.listeners.forEach((listener) => listener(state)); + } +} diff --git a/frontend/src/game/combat/__tests__/CombatSessionController.test.ts b/frontend/src/game/combat/__tests__/CombatSessionController.test.ts new file mode 100644 index 0000000..db4d5cc --- /dev/null +++ b/frontend/src/game/combat/__tests__/CombatSessionController.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CombatSessionController } from '../CombatSessionController'; +import type { Combatant, EncounterPreparation } from '../types'; + +const terrain = Array.from({ length: 3 }, (_, y) => Array.from({ length: 3 }, (_, x) => ({ + x, + y, + zoneType: 'sidewalk' as const, + passable: true, + cover: x === 1 ? 0.65 : 0.2, + exposure: x === 1 ? 0.2 : 0.7, +}))); + +function actor(overrides: Partial): Combatant { + return { + id: 'crew-1', + name: 'Scout', + team: 'crew', + role: 'shooter', + position: { x: 0, y: 1 }, + health: 100, + maxHealth: 100, + armor: 0, + ammo: 8, + maxAmmo: 8, + reloadUntilTick: null, + nextFireTick: 0, + level: 2, + lastSequence: -1, + isDown: false, + ...overrides, + }; +} + +function preparation(): EncounterPreparation { + return { + sessionId: 'controller-session', + seed: 42, + sceneLabel: 'Test Strip', + locationReference: 'Fictional reference', + fictionNotice: 'Fictional scene.', + terrain, + crew: [actor({})], + opposition: [actor({ + id: 'opposition-1', + name: 'Lookout', + team: 'opposition', + role: 'opposition', + position: { x: 2, y: 0 }, + health: 55, + maxHealth: 55, + })], + objective: { + kind: 'extract', + label: 'Reach exit', + extraction: { x: 0, y: 2 }, + requiredCrew: 1, + progress: 0, + target: 1, + }, + heatAtStart: 1, + moraleAtStart: 80, + tacticalBrief: [], + }; +} + +describe('CombatSessionController', () => { + it('keeps the authoritative snapshot unchanged while cycling cameras', () => { + const controller = new CombatSessionController(preparation()); + const before = controller.getSnapshot(); + + expect(controller.cycleCameraMode()).toBe('first-person'); + expect(controller.cycleCameraMode()).toBe('third-person'); + expect(controller.getSnapshot()).toEqual(before); + }); + + it('shares deterministic command sequencing for movement and extraction', () => { + const controller = new CombatSessionController(preparation(), 'first-person'); + + controller.moveSelected(0, 1); + const resolved = controller.interactSelected(); + + expect(resolved.phase).toBe('resolved'); + expect(resolved.result?.outcome).toBe('secured'); + expect(resolved.result?.idempotencyKey).toBe('controller-session:secured'); + }); + + it('blocks ticks and commands while paused without losing the session', () => { + const controller = new CombatSessionController(preparation(), 'third-person'); + controller.setPaused(true); + const paused = controller.getSnapshot(); + + controller.moveSelected(0, 1); + controller.advance(12); + + expect(controller.getSnapshot()).toEqual(paused); + controller.setPaused(false); + expect(controller.moveSelected(0, 1).combatants[0].position).toEqual({ x: 0, y: 2 }); + }); + + it('derives stable HUD state and notifies subscribers on presentation or simulation changes', () => { + const controller = new CombatSessionController(preparation()); + const listener = vi.fn(); + const unsubscribe = controller.subscribe(listener); + + controller.setCameraMode('first-person'); + controller.fireNearest(); + + const hud = controller.getHudState(); + expect(hud.cameraMode).toBe('first-person'); + expect(hud.selectedName).toBe('Scout'); + expect(hud.ammo).toBe(7); + expect(hud.activeOpposition).toBe(1); + expect(listener).toHaveBeenCalledTimes(3); + + unsubscribe(); + controller.setPaused(true); + expect(listener).toHaveBeenCalledTimes(3); + }); + + it('produces identical snapshots for identical controller inputs', () => { + const first = new CombatSessionController(preparation()); + const second = new CombatSessionController(preparation()); + + first.fireNearest(); + first.advance(6); + second.fireNearest(); + second.advance(6); + + expect(first.getSnapshot()).toEqual(second.getSnapshot()); + }); +}); From e6875acddfff6dfcfc01645b4d0730246d0da8df Mon Sep 17 00:00:00 2001 From: BrandDead <1.74973198e+08+BrandDead@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:07:44 +0000 Subject: [PATCH 09/11] feat(ops): add shared tactical FPS and TPS encounter slice --- ASSETS.md | 34 ++ MEMORY.md | 39 ++ PLAN.md | 65 +++ STRUCTURE.md | 36 ++ frontend/package-lock.json | 7 + frontend/package.json | 1 + .../block_modern_ops_storefront_v001.webp | Bin 0 -> 88376 bytes frontend/src/assets/runtimeManifest.json | 20 +- frontend/src/components/map/BlockModeView.tsx | 35 +- .../src/components/ops/ModernOpsCanvas.tsx | 83 +++ .../src/components/ops/ModernOpsEncounter.css | 364 ++++++++++++ .../src/components/ops/ModernOpsEncounter.tsx | 155 ++++++ .../combat/__tests__/combatSession.test.ts | 10 + frontend/src/game/combat/combatSession.ts | 6 +- frontend/src/game/combat/prepareEncounter.ts | 15 +- frontend/src/game/ops/OpsInput.ts | 125 +++++ frontend/src/game/ops/OpsWorld.ts | 520 ++++++++++++++++++ .../game/ops/__tests__/opsCoordinates.test.ts | 19 + frontend/src/game/ops/createModernOpsScene.ts | 38 ++ frontend/src/game/ops/opsCoordinates.ts | 44 ++ 20 files changed, 1607 insertions(+), 9 deletions(-) create mode 100644 ASSETS.md create mode 100644 MEMORY.md create mode 100644 PLAN.md create mode 100644 STRUCTURE.md create mode 100644 frontend/public/assets/runtime/generated/environments/street/block_modern_ops_storefront_v001.webp create mode 100644 frontend/src/components/ops/ModernOpsCanvas.tsx create mode 100644 frontend/src/components/ops/ModernOpsEncounter.css create mode 100644 frontend/src/components/ops/ModernOpsEncounter.tsx create mode 100644 frontend/src/game/ops/OpsInput.ts create mode 100644 frontend/src/game/ops/OpsWorld.ts create mode 100644 frontend/src/game/ops/__tests__/opsCoordinates.test.ts create mode 100644 frontend/src/game/ops/createModernOpsScene.ts create mode 100644 frontend/src/game/ops/opsCoordinates.ts diff --git a/ASSETS.md b/ASSETS.md new file mode 100644 index 0000000..a62a894 --- /dev/null +++ b/ASSETS.md @@ -0,0 +1,34 @@ +# DEALT / SLIDE Modern Ops Assets + +**Art direction:** A compact fictionalized South Florida strip at night, with wet asphalt, palms, simple one-story storefront geometry, cyan-magenta-amber-green emissive accents, restrained physically plausible materials, readable cover lanes, and clean crew/opposition silhouettes. The target is stylized-realistic browser-game output rather than a cinematic or photoreal scene. + +## Visual Target + +| Name | Role | Size | File | Runtime | +|---|---|---:|---|---| +| Modern Ops visual target | QA reference for composition, camera, palette, cover, HUD, and object density | 2560×1440 | `/home/ubuntu/webdev-static-assets/dealt-slide-modern-ops-visual-target.png` | No; verification reference only | + +**Prompt:** Create a clean, sharp 16:9 in-game screenshot target for a modern 3D browser action game, using the supplied rainy neon Florida strip reference only for palette, mood, wet asphalt reflections, palm silhouettes, and storefront identity. Use an over-the-right-shoulder camera; include one player crew member, two readable opposition silhouettes, concrete planter and parked-car cover, a green extraction marker, a wet two-lane road, simple neon storefront boxes, a complete but compact combat HUD, and only effects the implementation will support. Exclude motion blur, depth of field, volumetric fog, crowds, real brands, gore, and cinematic composition. + +## Runtime Texture + +| Name | Description | Intended size | Source | Runtime path | +|---|---|---:|---|---| +| Modern Ops storefront facade | Four-bay straight-on fictional storefront elevation with cyan, green, magenta, and amber emissive signs | 32 m × 6 m plane; source 1536×864 WebP, 88,376 bytes | `/home/ubuntu/webdev-static-assets/dealt-slide-ops-storefront-facade.png` | `/assets/runtime/generated/environments/street/block_modern_ops_storefront_v001.webp` | + +**Prompt:** Create a straight-on orthographic 16:9 game texture plate for four simple fictional Florida strip-mall bays, matching the visual target’s rainy-neon palette. Include charcoal stucco, reflective windows, metal doors, shallow awnings, and short fictional signs: NIGHT MART, 305 CAFE, VICE SHOP, and GOLD PAWN. Exclude people, cars, sidewalk, sky, rain, HUD, weapons, real brands, perspective, depth of field, and dramatic lighting. + +The runtime texture is registered as `generated.environments.street.block_modern_ops_storefront_v001` in `frontend/src/assets/runtimeManifest.json` and is included in the repository asset budget. + +## Existing Reused Assets + +| Asset | Role in the slice | Runtime path | +|---|---|---| +| Las Olas drive-by street plate | Existing palette/place reference and legacy action fallback | `/assets/runtime/generated/environments/street/block_lasolas_driveby_street_v001.webp` | +| Las Olas top-down plate | Strategy/tactical identity continuity | `/assets/runtime/generated/environments/topdown/block_lasolas_topdown_v001.webp` | +| Registered street character art | Tactical/legacy actor art; 3D mode uses lightweight procedural proxies in the first slice | Resolved through `worldActorResolver` | +| Combat effect sheet | Existing effect vocabulary for non-3D action modes | `/assets/runtime/generated/effects/fx_combat_sprite_sheet_v001.webp` | + +## Runtime Rules + +All runtime images must be registered in `runtimeManifest.json`, pass `npm run assets:audit`, and stay within the 20 MB budget. Babylon receives the storefront through its registered public path. The visual-target PNG remains outside the runtime tree and should be attached with review evidence rather than loaded by the game. diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 0000000..7f0302d --- /dev/null +++ b/MEMORY.md @@ -0,0 +1,39 @@ +# DEALT / SLIDE Modern Ops Memory + +## 2026-08-26 — Audit and Recovery + +The protected default branch is `main-tL2525`. Work is isolated on `feat/modern-gameplay-completion`. The repository had 32 open issues, no open pull requests, and one materially divergent unmerged branch: `copilot/dev-oplan`. Six unique commits from that branch were recovered and reconciled onto the current baseline rather than recreated. + +The recovered work registers the seven previously orphaned assets, fixes stale runtime paths, expands Block DNA from 8 to 17 cards, adds persistent local Ghost Crews and tests, adds the City Feed threat banner, and restores the beta smoke tests and release checklist. After recovery, frontend typecheck passed, 685 tests passed across 43 files, and the asset audit passed with zero errors and zero warnings at 5.73 MB. + +The default branch’s existing tactical encounter already has a strong deterministic core: `prepareEncounter` produces a typed preparation, `combatSession` owns commands/ticks/RNG/results, and `BlockModeView` applies the result back to block/crew/heat systems. The main missing modern-action requirement was not another simulation but a shared presentation controller and 3D adapter. + +## 2026-08-26 — Shared Controller + +Added `CombatSessionController` and five focused tests. Camera mode, pause state, selection, shared command sequence, nearest-target fire, reload, interact, retreat, HUD derivation, subscriptions, and disposal live outside the authoritative `CombatSession`. Switching tactical, first-person, and third-person cameras leaves the combat snapshot unchanged. Focused typecheck and 10 combat tests passed. + +## 2026-08-26 — Art Direction + +Generated a mandatory 2560×1440 visual target at `/home/ubuntu/webdev-static-assets/dealt-slide-modern-ops-visual-target.png`, using the existing Las Olas drive-by plate as a palette and place reference. The target establishes a wet neon strip, over-the-shoulder camera, simple cover, two opponents, extraction marker, and the minimum Modern Ops HUD. + +Generated a straight-on four-bay storefront texture from that target, converted it to an 88,376-byte 1536×864 WebP, placed it at `/assets/runtime/generated/environments/street/block_modern_ops_storefront_v001.webp`, and registered it in `runtimeManifest.json`. The visual target is not loaded at runtime. + +## Build Environment Note + +On the audited default baseline, production bundling transformed 2,437 modules and was terminated by sandbox memory pressure while rendering chunks. Typecheck, tests, lint, asset audit, backend tests, and the latest default-branch CI were healthy. Treat a repeat local build termination as an environment limitation only after confirming there is no TypeScript, test, or asset regression; use CI/Vercel build evidence for the final gate. + +## Next Implementation Step + +Install Babylon.js, add `frontend/src/game/ops/**` and `frontend/src/components/ops/**`, project encounter terrain into one hero 3D block, wire semantic controls to `CombatSessionController`, add tactical/FPS/TPS cameras, expose Modern Ops from `BlockModeView`, then run focused tests before browser verification. + +## 2026-08-26 — Modern Ops Integrated and Browser-Verified + +Added Babylon.js, the grid-to-world projection helpers, semantic DOM input, lifecycle-safe scene factory, `OpsWorld`, full-screen React canvas/HUD, generated storefront runtime texture, and lazy `OPS 3D` launchers in `BlockModeView`. The scene supports tactical, first-person, and third-person cameras over one `CombatSessionController`, WASD/arrow movement, fire, reload, extract, retreat, pause, live opponent turns, cover, tracers/impacts, extraction, and deterministic `?demo=1` behavior. + +The first browser round found two real defects. Inline canvas callbacks caused the Babylon effect to recreate the engine after every HUD update, and `OpsWorld.dispose()` cleared externally owned controller listeners. Memoizing callbacks and moving controller disposal to encounter unmount fixed camera/HUD persistence. The initial encounter also overran the crew before input; opposition turns now occur every twenty ticks with lower damage, and a deterministic test proves the opening forty ticks remain active and survivable. + +Sandbox Chromium/CDP launched the mode through MAP → Strip → OPS 3D with WebGL2, one canvas, no Modern Ops error overlay, and zero captured runtime exceptions. TPS, FPS, and tactical screenshots show the same authored block, cover, actors, objective, ammo, health, and event stream. FPS firing reduced ammo and returned `Shot strikes cover.` without resetting the session. + +The deterministic demo reached a secured outcome and applied it through the existing block boundary. Pending income changed $840→$982, heat 1→2, morale 85%→89%, the Modern Ops portal unmounted, and the $2,200 injury/hospital follow-up opened for Lil Dre. No separate economy, crew, or territory copy was introduced. + +Evidence is saved under `.audit/`: `ops-third-person.png`, `ops-first-person.png`, `ops-tactical-active.png`, `ops-return-strategy.png`, `browser-findings.md`, and CDP state/event JSON files. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..f8dcabe --- /dev/null +++ b/PLAN.md @@ -0,0 +1,65 @@ +# Game Plan: DEALT / SLIDE Modern Ops Slice + +**Branch:** `feat/modern-gameplay-completion` +**Baseline:** `main-tL2525@c178861` +**Hero block:** Las Olas / 1208 E Las Olas-derived Block DNA +**Authoritative model:** `frontend/src/game/combat/**` + +## Recovered Foundation + +The six unique `copilot/dev-oplan` commits have been reconciled onto the current baseline. The recovered work provides zero-warning runtime assets, 17 Block DNA cards, persistent local Ghost Crews, City Feed threat surfacing, beta smoke tests, and a release checklist. The new `CombatSessionController` provides one deterministic command and snapshot boundary for tactical, first-person, and third-person presentation. + +## Risk Tasks + +### 1. Shared session across cameras + +- **Why isolated:** A camera switch must never recreate the encounter, reset RNG, duplicate commands, or commit a result twice. +- **Approach:** Keep `CombatSessionController` outside Babylon scene nodes; let cameras observe the same snapshot and emit typed commands through one monotonically increasing sequence. +- **Verify:** Switch tactical → first person → third person after movement and firing; tick, ammo, health, objective progress, and event IDs remain continuous. + +### 2. Pointer-lock first-person controls + +- **Why isolated:** Pointer lock requires a direct user gesture and may fail inside embedded browser contexts. +- **Approach:** Start with unlocked mouse-look plus a visible click-to-focus affordance; request pointer lock only from the canvas click, pause on lock loss, and keep keyboard/fire controls usable without lock. +- **Verify:** Camera does not spin on mount; pointer-lock denial shows no fatal error; Escape pauses; re-entry resumes from the same session. + +### 3. Third-person shoulder camera + +- **Why isolated:** Tight block geometry can place the camera inside cover or storefront meshes. +- **Approach:** Use a bounded follow/orbit camera with a fixed shoulder offset and ray-based obstruction shortening; keep movement grid-authoritative. +- **Verify:** Rotate behind the actor near planters and storefronts; the camera remains outside geometry and the selected actor stays visible. + +### 4. DNA-to-3D scene projection + +- **Why isolated:** A separate hand-authored 3D map would disconnect the claimed block from strategy and tactical modes. +- **Approach:** Convert encounter terrain cells and the stable hero-block identity into procedural road, sidewalk, storefront, cover, actor, and extraction meshes. Use the generated storefront facade as the authored visual anchor. +- **Verify:** The same preparation produces stable geometry; passable cells remain traversable; high-cover cells create visible cover; extraction occupies the declared grid point. + +### 5. React/Babylon lifecycle + +- **Why isolated:** Development double-mount and repeated mode entry can leak engines, render loops, events, pointer lock, and canvases. +- **Approach:** Guard engine initialization, centralize listener cleanup, call scene/world/controller disposal, and dispose the engine on unmount. +- **Verify:** Enter and exit Modern Ops five times; one canvas exists while active, no render loop continues after exit, and the next session starts cleanly. + +## Main Build + +Build a full-screen Babylon.js Modern Ops encounter under `BlockModeView` while preserving the existing Phaser tactical encounter. Add tactical, first-person, and third-person camera buttons; WASD/arrow movement; click/Space fire; `R` reload; `E` extract; `Q` retreat; `V` camera cycle; HUD; cover; opponent return fire; extraction; pause/exit; and atomic `CombatResult` handoff. + +- **Assets:** + - `/home/ubuntu/webdev-static-assets/dealt-slide-modern-ops-visual-target.png` — 2560×1440 visual QA target, not runtime-loaded. + - `frontend/public/assets/runtime/generated/environments/street/block_modern_ops_storefront_v001.webp` — generated facade texture on a 32 m × 6 m storefront plane. + - Existing Las Olas street and top-down plates plus registered character sprites remain available to tactical and legacy views. +- **Verify:** + - Movement direction matches input and legal grid movement. + - Camera switching never changes the combat snapshot. + - Fire/reload feedback and opponent return fire are visible. + - The green extraction point resolves the encounter through the shared result boundary. + - HUD is readable at desktop and narrow mobile widths. + - No missing texture, fallback-magenta material, or gray blockout dominates the hero view. + - No browser console error appears during deterministic demo capture. + - Re-entry does not create duplicate engines or rewards. + - Reference consistency: wet neon Las Olas palette, compact block scale, readable cover lanes, authored storefront identity. + +## Completion Gates + +Run frontend typecheck, focused and full Vitest, runtime asset audit, lint, production build where environment memory permits, backend offline Pytest, deterministic browser smoke, and visual captures of tactical, first-person, third-person, firefight, and resolved strategy state. Update `MEMORY.md`, `ASSETS.md`, `STRUCTURE.md`, the project log, and release checklist before pull request review. diff --git a/STRUCTURE.md b/STRUCTURE.md new file mode 100644 index 0000000..cf54d8e --- /dev/null +++ b/STRUCTURE.md @@ -0,0 +1,36 @@ +# DEALT / SLIDE Modern Ops Structure + +## Ownership Model + +| Layer | Primary paths | Responsibility | +|---|---|---| +| React strategy shell | `frontend/src/App.tsx`, `frontend/src/components/map/BlockModeView.tsx` | Navigation, launcher UI, briefing, HUD chrome, mode selection, result consequences | +| Strategy state | `frontend/src/stores/**`, `frontend/src/utils/moneyRouter.ts` | Blocks, placements, rivals, crew, money, inventory, heat, morale, persistence | +| Encounter preparation | `frontend/src/game/combat/prepareEncounter.ts` | Stable conversion from selected block, placement, DNA, and rival context into one encounter input | +| Authoritative combat | `frontend/src/game/combat/combatSession.ts`, `types.ts` | Deterministic ticks, commands, RNG, damage, objectives, events, idempotent result | +| Presentation-neutral control | `frontend/src/game/combat/CombatSessionController.ts` | Selection, command sequence, pause, camera mode, shared HUD derivation, subscriptions | +| Tactical presenter | `frontend/src/components/encounter/**` | Existing Phaser tactical rendering and input | +| Modern Ops presenter | `frontend/src/components/ops/**`, `frontend/src/game/ops/**` | Babylon engine lifecycle, procedural 3D projection, cameras, semantic input, effects | +| Result boundary | `BlockModeView.handleEncounterResolved` and `blockStore.applyEncounterResult` | Exactly-once return to territory, heat, morale, crew injury, income, and follow-up UI | + +## Modern Ops Runtime + +`ModernOpsEncounter.tsx` owns the full-screen React portal, creates one `CombatSessionController`, subscribes to state for the HUD, and commits a completed result once. `ModernOpsCanvas.tsx` owns one Babylon `Engine`, invokes `createModernOpsScene`, handles resize and StrictMode-safe cleanup, and never writes strategy state. + +`createModernOpsScene` creates the scene, lights, environment, actor proxies, extraction marker, effects, and three cameras. `OpsWorld` subscribes to the controller, projects grid cells to world coordinates, updates actor transforms and health/readability, interprets semantic input, and converts it into controller commands. Visual interpolation and effects are non-authoritative. + +## Coordinate Contract + +The combat grid is authoritative. Convert a grid point `{x, y}` to world space using a fixed cell size, centered around the block origin. The X axis follows grid columns; the Z axis follows grid rows; Y is height. Road, sidewalk, storefront, cover, actors, and extraction derive from encounter terrain rather than a separate scene map. + +The strategy renderer’s 2.5D projection remains unchanged. Modern Ops does not reuse the 2D pixel projection directly; it reuses the same grid and DNA meaning, with a documented world transform, so every renderer agrees on occupancy, cover, and objectives. + +## Asset Hints + +Use the generated storefront facade texture as the authored landmark across a simple procedural storefront plane. Use restrained PBR or standard materials for wet road, concrete sidewalk, cover planters, actor proxies, and emissive extraction. Reuse existing registered assets where appropriate; do not introduce unregistered runtime paths. Keep the first slice free of GLB dependencies. + +## Data Flow + +`BlockModeView → prepareEncounter(block) → CombatSessionController → Modern Ops scene ↔ typed CombatCommand → CombatSnapshot/CombatEvent → CombatResult → BlockModeView.applyEncounterResult`. + +Camera changes, interpolation, particles, muzzle flashes, tracer lines, pointer lock, and UI animation never enter `CombatSession`. Strategy stores and money routing never enter Babylon modules. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 14b4f41..9397d18 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "dealt-slide-mvp", "version": "1.0.0", "dependencies": { + "@babylonjs/core": "^8.56.2", "@mapbox/mapbox-gl-geocoder": "^5.0.1", "@supabase/supabase-js": "^2.39.0", "axios": "^1.13.5", @@ -435,6 +436,12 @@ "node": ">=6.9.0" } }, + "node_modules/@babylonjs/core": { + "version": "8.56.2", + "resolved": "https://registry.npmjs.org/@babylonjs/core/-/core-8.56.2.tgz", + "integrity": "sha512-UShs1pt8tSTLYOoITWclXPNrUZUpuHvB2Ur2L1D+uM8c9qaAYOi9gjkurOkQwIlbPGqwQHWImGEyiyix0mQ1dg==", + "license": "Apache-2.0" + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7b56e05..98049f2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "validate": "npm run lint && npm run typecheck && npm test && npm run assets:audit" }, "dependencies": { + "@babylonjs/core": "^8.56.2", "@mapbox/mapbox-gl-geocoder": "^5.0.1", "@supabase/supabase-js": "^2.39.0", "axios": "^1.13.5", diff --git a/frontend/public/assets/runtime/generated/environments/street/block_modern_ops_storefront_v001.webp b/frontend/public/assets/runtime/generated/environments/street/block_modern_ops_storefront_v001.webp new file mode 100644 index 0000000000000000000000000000000000000000..829d4abddd4f1e5ed2b8bcceec15bb566a67d9f4 GIT binary patch literal 88376 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 s*PC~rs0mZ$FA@;%umqJ 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 import('../ops/ModernOpsEncounter')); + // ─── Seed helper ───────────────────────────────────────────── function buildDefaultBlock(id: string, address: string): BlockData { const { generateDefaultGrid } = useBlockStore.getState(); @@ -129,6 +131,7 @@ const BlockModeView: React.FC = ({ const [showDeployPanel, setShowDeployPanel] = useState(false); const [showDriveBy, setShowDriveBy] = useState(false); const [showEncounter, setShowEncounter] = useState(false); + const [showModernOps, setShowModernOps] = useState(false); const [showRaid, setShowRaid] = useState(false); const [toast, setToast] = useState(null); const [bailIncidents, setBailIncidents] = useState([]); @@ -234,6 +237,7 @@ const BlockModeView: React.FC = ({ applyEncounterResult(selectedBlockId, result); updatePlayer({ heat: Math.max(0, Math.min(5, (player.heat ?? 0) + result.heatDelta)) }); setShowEncounter(false); + setShowModernOps(false); if (activeBlock && result.crewDown.length > 0) { setBailIncidents(result.crewDown.map((memberId, index) => { const placement = activeBlock.placements.find((item) => item.memberId === memberId); @@ -318,13 +322,19 @@ const BlockModeView: React.FC = ({ + @@ -338,7 +348,15 @@ const BlockModeView: React.FC = ({ {/* Main view */}

- {showEncounter ? ( + {showModernOps ? ( + Loading Modern Ops…
}> + setShowModernOps(false)} + /> + + ) : showEncounter ? ( = ({ { setShowDriveBy(false); setShowRaid(false); setShowEncounter(true); }} + onClick={() => { setShowDriveBy(false); setShowModernOps(false); setShowRaid(false); setShowEncounter(true); }} whileTap={{ scale: 0.95 }} > 🎯 Encounter + { setShowDriveBy(false); setShowEncounter(false); setShowRaid(false); setShowModernOps(true); }} + whileTap={{ scale: 0.95 }} + > + OPS 3D +
{/* Deploy panel */} diff --git a/frontend/src/components/ops/ModernOpsCanvas.tsx b/frontend/src/components/ops/ModernOpsCanvas.tsx new file mode 100644 index 0000000..a222382 --- /dev/null +++ b/frontend/src/components/ops/ModernOpsCanvas.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef } from 'react'; +import { Engine } from '@babylonjs/core/Engines/engine'; +import type { EncounterPreparation } from '../../game/combat/types'; +import type { CombatSessionController } from '../../game/combat/CombatSessionController'; +import { createModernOpsScene } from '../../game/ops/createModernOpsScene'; + +interface ModernOpsCanvasProps { + controller: CombatSessionController; + preparation: EncounterPreparation; + demo?: boolean; + onReady?(): void; + onError?(message: string): void; +} + +export function ModernOpsCanvas({ + controller, + preparation, + demo = false, + onReady, + onError, +}: ModernOpsCanvasProps) { + const canvasRef = useRef(null); + const mountedRef = useRef(false); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || mountedRef.current) return; + mountedRef.current = true; + + let cancelled = false; + let engine: Engine | null = null; + let sceneHandle: Awaited> | null = null; + + const resize = () => engine?.resize(); + + void (async () => { + try { + engine = new Engine(canvas, true, { + antialias: true, + preserveDrawingBuffer: true, + stencil: true, + disableWebGL2Support: false, + }, true); + sceneHandle = await createModernOpsScene(engine, canvas, controller, preparation, { demo }); + if (cancelled) { + sceneHandle.dispose(); + engine.dispose(); + return; + } + window.addEventListener('resize', resize); + engine.runRenderLoop(() => sceneHandle?.scene.render()); + onReady?.(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + onError?.(message); + sceneHandle?.dispose(); + engine?.dispose(); + } + })(); + + return () => { + cancelled = true; + window.removeEventListener('resize', resize); + sceneHandle?.dispose(); + sceneHandle = null; + engine?.stopRenderLoop(); + engine?.dispose(); + engine = null; + mountedRef.current = false; + }; + }, [controller, demo, onError, onReady, preparation]); + + return ( + + ); +} + +export default ModernOpsCanvas; diff --git a/frontend/src/components/ops/ModernOpsEncounter.css b/frontend/src/components/ops/ModernOpsEncounter.css new file mode 100644 index 0000000..655552c --- /dev/null +++ b/frontend/src/components/ops/ModernOpsEncounter.css @@ -0,0 +1,364 @@ +.modern-ops { + position: fixed; + inset: 0; + z-index: 10000; + display: grid; + grid-template-rows: auto 1fr; + overflow: hidden; + color: #eefcff; + background: #02070d; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.modern-ops__topbar { + position: relative; + z-index: 8; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 76px; + padding: 12px 20px; + border-bottom: 1px solid rgba(92, 229, 226, 0.22); + background: linear-gradient(180deg, rgba(3, 10, 18, 0.98), rgba(3, 10, 18, 0.9)); + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35); +} + +.modern-ops__topbar h2 { + margin: 3px 0 2px; + font-family: Inter, system-ui, sans-serif; + font-size: clamp(18px, 2.1vw, 28px); + line-height: 1; + letter-spacing: -0.02em; +} + +.modern-ops__topbar p, +.modern-ops__kicker { + margin: 0; + color: #8fb1bc; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.modern-ops__kicker { + color: #6ee7e2; +} + +.modern-ops__top-actions { + display: flex; + gap: 8px; +} + +.modern-ops button { + min-height: 38px; + padding: 0 13px; + border: 1px solid rgba(123, 214, 218, 0.34); + border-radius: 8px; + color: #dffcff; + background: rgba(8, 28, 38, 0.82); + font: inherit; + font-size: 11px; + font-weight: 800; + cursor: pointer; +} + +.modern-ops button:hover, +.modern-ops button:focus-visible { + border-color: #74ede7; + background: rgba(18, 69, 77, 0.92); + outline: none; +} + +.modern-ops__return { + border-color: rgba(251, 113, 133, 0.44) !important; + color: #ffd7de !important; +} + +.modern-ops__stage { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + isolation: isolate; +} + +.modern-ops-canvas { + display: block; + width: 100%; + height: 100%; + touch-action: none; + cursor: crosshair; + outline: none; +} + +.modern-ops__loading, +.modern-ops__error { + position: absolute; + inset: 0; + z-index: 20; + display: grid; + place-content: center; + gap: 12px; + text-align: center; + background: radial-gradient(circle at center, rgba(11, 45, 54, 0.72), rgba(2, 7, 13, 0.97)); +} + +.modern-ops__error span { + max-width: 560px; + color: #fda4af; + font-size: 12px; +} + +.ops-objective-card, +.ops-vitals, +.ops-ammo, +.ops-camera-switcher, +.ops-status, +.ops-rival-bar, +.ops-controls { + position: absolute; + z-index: 6; + pointer-events: none; + text-shadow: 0 2px 5px rgba(0, 0, 0, 0.92); +} + +.ops-objective-card { + top: 18px; + left: 18px; + min-width: 244px; + padding: 13px 15px; + border: 1px solid rgba(78, 225, 234, 0.55); + border-radius: 10px; + background: linear-gradient(135deg, rgba(3, 21, 30, 0.93), rgba(3, 17, 25, 0.7)); + box-shadow: inset 3px 0 #20cad2, 0 12px 26px rgba(0, 0, 0, 0.28); +} + +.ops-objective-card span, +.ops-rival-bar span, +.ops-vitals span, +.ops-ammo small { + display: block; + color: #73e7e7; + font-size: 9px; + font-weight: 900; + letter-spacing: 0.12em; +} + +.ops-objective-card strong { + display: block; + margin-top: 5px; + font-family: Inter, system-ui, sans-serif; + font-size: 17px; + text-transform: uppercase; +} + +.ops-objective-card p { + margin: 6px 0 0; + color: #a7c4cc; + font-size: 10px; +} + +.ops-rival-bar { + top: 20px; + left: 50%; + width: min(340px, 34vw); + transform: translateX(-50%); + text-align: center; +} + +.ops-rival-bar > div { + height: 7px; + margin-top: 5px; + overflow: hidden; + border: 1px solid rgba(255, 210, 216, 0.32); + background: rgba(3, 8, 13, 0.8); +} + +.ops-rival-bar i { + display: block; + height: 100%; + background: linear-gradient(90deg, #ff3d6e, #ff8c55); + box-shadow: 0 0 12px rgba(255, 61, 110, 0.78); + transition: width 180ms ease; +} + +.ops-crosshair { + position: absolute; + top: 50%; + left: 50%; + z-index: 6; + width: 28px; + height: 28px; + transform: translate(-50%, -50%); + border: 1px solid rgba(229, 255, 255, 0.58); + border-radius: 50%; + box-shadow: 0 0 12px rgba(72, 240, 234, 0.36); + pointer-events: none; +} + +.ops-crosshair i, +.ops-crosshair b { + position: absolute; + top: 50%; + left: 50%; + display: block; + background: #eaffff; + transform: translate(-50%, -50%); +} + +.ops-crosshair i { width: 40px; height: 1px; } +.ops-crosshair b { width: 1px; height: 40px; } + +.ops-vitals { + left: 18px; + bottom: 24px; + width: min(280px, 31vw); + padding: 12px 14px; + border-left: 3px solid #3bd8e1; + background: linear-gradient(90deg, rgba(2, 12, 20, 0.88), rgba(2, 12, 20, 0.12)); +} + +.ops-meter { + height: 8px; + margin: 7px 0; + overflow: hidden; + background: rgba(255, 255, 255, 0.14); +} + +.ops-meter i { + display: block; + height: 100%; + background: linear-gradient(90deg, #2dd4bf, #63f0a7); + box-shadow: 0 0 14px rgba(45, 212, 191, 0.55); + transition: width 160ms ease; +} + +.ops-vitals small { + color: #a9c2ca; + font-size: 9px; +} + +.ops-status { + left: 50%; + bottom: 66px; + max-width: min(620px, 58vw); + padding: 7px 14px; + transform: translateX(-50%); + color: #f7daa1; + font-size: 11px; + text-align: center; + background: rgba(2, 9, 15, 0.68); +} + +.ops-controls { + left: 50%; + bottom: 22px; + transform: translateX(-50%); + color: #c0d6da; + font-size: 9px; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.ops-camera-switcher { + right: 22px; + bottom: 92px; + display: flex; + gap: 5px; + pointer-events: auto; +} + +.ops-camera-switcher button { + min-width: 44px; + min-height: 32px; + padding: 0 8px; + border-radius: 4px; + font-size: 10px; +} + +.ops-camera-switcher button.active { + border-color: #67e8f9; + color: #021015; + background: #67e8f9; + box-shadow: 0 0 18px rgba(103, 232, 249, 0.46); +} + +.ops-ammo { + right: 24px; + bottom: 25px; + min-width: 126px; + text-align: right; +} + +.ops-ammo strong { + font-family: Inter, system-ui, sans-serif; + font-size: 42px; + line-height: 0.8; +} + +.ops-ammo > span { + color: #9fb3b9; + font-size: 16px; +} + +.ops-ammo small { + margin-top: 8px; + color: #dffcff; +} + +.ops-paused, +.ops-result { + position: absolute; + top: 50%; + left: 50%; + z-index: 30; + display: grid; + width: min(440px, calc(100vw - 32px)); + padding: 30px; + transform: translate(-50%, -50%); + border: 1px solid rgba(107, 232, 229, 0.5); + border-radius: 16px; + text-align: center; + background: rgba(2, 10, 17, 0.95); + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.65), inset 0 0 40px rgba(34, 211, 238, 0.05); +} + +.ops-paused span, +.ops-result__badge { + color: #68e7e4; + font-size: 10px; + font-weight: 900; + letter-spacing: 0.15em; +} + +.ops-paused strong, +.ops-result h3 { + margin: 11px 0 20px; + font-family: Inter, system-ui, sans-serif; + font-size: 23px; +} + +.ops-result p { + margin: 0 0 22px; + color: #aac1c8; + font-size: 11px; +} + +.ops-result__badge.outcome-overrun { color: #fb7185; } +.ops-result__badge.outcome-retreated { color: #fbbf24; } + +@media (max-width: 760px) { + .modern-ops__topbar { min-height: 64px; padding: 10px 12px; } + .modern-ops__topbar p { display: none; } + .modern-ops__top-actions button:first-child { display: none; } + .ops-objective-card { top: 10px; left: 10px; min-width: 190px; padding: 10px 12px; } + .ops-objective-card strong { font-size: 13px; } + .ops-rival-bar { top: 12px; right: 10px; left: auto; width: 128px; transform: none; text-align: right; } + .ops-vitals { left: 10px; bottom: 14px; width: 44vw; padding: 9px; } + .ops-status { bottom: 56px; max-width: 84vw; font-size: 9px; } + .ops-controls { display: none; } + .ops-camera-switcher { right: 10px; bottom: 72px; } + .ops-ammo { right: 12px; bottom: 14px; min-width: 90px; } + .ops-ammo strong { font-size: 32px; } +} diff --git a/frontend/src/components/ops/ModernOpsEncounter.tsx b/frontend/src/components/ops/ModernOpsEncounter.tsx new file mode 100644 index 0000000..e32870c --- /dev/null +++ b/frontend/src/components/ops/ModernOpsEncounter.tsx @@ -0,0 +1,155 @@ +import { createPortal } from 'react-dom'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { BlockData } from '../../types/block.types'; +import type { ActionCameraMode, CombatControllerState } from '../../game/combat/CombatSessionController'; +import { CombatSessionController } from '../../game/combat/CombatSessionController'; +import { prepareEncounter } from '../../game/combat/prepareEncounter'; +import type { CombatResult } from '../../game/combat/types'; +import ModernOpsCanvas from './ModernOpsCanvas'; +import './ModernOpsEncounter.css'; + +interface ModernOpsEncounterProps { + block: BlockData; + onResolved(result: CombatResult): void; + onClose(): void; +} + +function initialCameraMode(): ActionCameraMode { + if (typeof window === 'undefined') return 'third-person'; + const requested = new URLSearchParams(window.location.search).get('camera'); + if (requested === 'tactical' || requested === 'first-person' || requested === 'third-person') return requested; + return 'third-person'; +} + +export function ModernOpsEncounter({ block, onResolved, onClose }: ModernOpsEncounterProps) { + const [preparation] = useState(() => prepareEncounter(block)); + const [controller] = useState(() => new CombatSessionController(preparation, initialCameraMode())); + const [state, setState] = useState(() => controller.getState()); + const [ready, setReady] = useState(false); + const [error, setError] = useState(null); + const resolvedRef = useRef(false); + const demo = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('demo'); + + useEffect(() => { + const unsubscribe = controller.subscribe(setState); + return () => { + unsubscribe(); + controller.dispose(); + }; + }, [controller]); + + const handleReady = useCallback(() => setReady(true), []); + const handleError = useCallback((message: string) => setError(message), []); + + const result = state.snapshot.result; + const healthRatio = state.hud.maxHealth > 0 ? state.hud.health / state.hud.maxHealth : 0; + const cameraLabel = state.hud.cameraMode.replace('-', ' ').toUpperCase(); + + const commitResult = () => { + if (!result || resolvedRef.current) return; + resolvedRef.current = true; + onResolved(result); + }; + + const setCamera = (mode: ActionCameraMode) => { + controller.setCameraMode(mode); + }; + + return createPortal( +
+
+
+ MODERN OPS · FICTIONALIZED LOCAL REFERENCE +

{preparation.sceneLabel}

+

{preparation.locationReference}

+
+
+ + +
+
+ +
+ + + {!ready && !error &&
Building Modern Ops scene…
} + {error && ( +
+ Modern Ops could not start. + {error} + +
+ )} + +
+ OBJECTIVE + {state.snapshot.objective.label} +

Secure exits {state.hud.objectiveProgress}/{state.hud.objectiveTarget}

+
+ +
+ RIVAL PRESENCE +
+
+ + {state.hud.cameraMode !== 'tactical' && } + +
+ {state.hud.selectedName} +
+ {state.hud.health}/{state.hud.maxHealth} HEALTH · {state.hud.activeCrew} CREW ACTIVE +
+ +
{state.hud.latestMessage}
+ +
+ {(['tactical', 'first-person', 'third-person'] as ActionCameraMode[]).map((mode) => ( + + ))} +
+ +
+ {state.hud.ammo}/ {state.hud.maxAmmo} + {cameraLabel} +
+ +
WASD MOVE · CLICK/SPACE FIRE · R RELOAD · V CAMERA · E EXTRACT · Q RETREAT
+ + {state.hud.paused && !result && ( +
+ TACTICAL PAUSE + The encounter state is frozen. + +
+ )} + + {result && ( +
+ {result.outcome.toUpperCase()} +

{result.summary}

+

{result.oppositionDown.length} opposition down · {result.crewDown.length} crew injured · heat {result.heatDelta >= 0 ? '+' : ''}{result.heatDelta}

+ +
+ )} +
+
, + document.body, + ); +} + +export default ModernOpsEncounter; diff --git a/frontend/src/game/combat/__tests__/combatSession.test.ts b/frontend/src/game/combat/__tests__/combatSession.test.ts index 2b36477..23efa7a 100644 --- a/frontend/src/game/combat/__tests__/combatSession.test.ts +++ b/frontend/src/game/combat/__tests__/combatSession.test.ts @@ -117,9 +117,19 @@ describe('CombatSession', () => { expect(first.seed).toBe(second.seed); expect(first.terrain[2][0].zoneType).toBe('alley'); expect(first.backgroundUrl).toBe('/assets/reference.webp'); + expect(first.objective.extraction).not.toEqual(first.crew[0].position); expect(first.tacticalBrief[0]).toContain('Liberty City Alley'); }); + it('keeps opening opposition fire survivable long enough for player input', () => { + const advanced = advanceCombat(createCombatSession(preparation()), 40); + const crew = advanced.combatants.find((candidate) => candidate.id === 'crew-1'); + + expect(advanced.phase).toBe('active'); + expect(crew?.health).toBeGreaterThan(0); + expect(advanced.events.some((event) => event.type === 'weapon-fired')).toBe(true); + }); + it('uses bounded reload timing and ignores stale commands', () => { const empty = createCombatSession({ ...preparation(), diff --git a/frontend/src/game/combat/combatSession.ts b/frontend/src/game/combat/combatSession.ts index 6cbad03..5f07369 100644 --- a/frontend/src/game/combat/combatSession.ts +++ b/frontend/src/game/combat/combatSession.ts @@ -99,7 +99,9 @@ function applyDamage(session: CombatSession, source: Combatant, target: Combatan if (random > hitChance) { return appendEvents(fired, [event(fired, 'impact-cover', 'Shot strikes cover.', source.id, target.id)]); } - const damage = Math.max(6, 18 + source.level * 2 - target.armor * 3); + const damage = source.team === 'opposition' + ? Math.max(4, 6 + source.level - target.armor * 2) + : Math.max(6, 18 + source.level * 2 - target.armor * 3); const updatedTarget = { ...target, health: Math.max(0, target.health - damage) }; let updated = updateCombatant(fired, updatedTarget); updated = appendEvents(updated, [event(updated, 'impact-actor', `${target.name} takes ${damage} damage.`, source.id, target.id, damage)]); @@ -181,7 +183,7 @@ export function advanceCombat(session: CombatSession, steps = 1): CombatSession if (completedReloads.length > 0) { next = appendEvents(next, completedReloads.map((actor) => event(next, 'reload-complete', `${actor.name} is ready.`, actor.id))); } - if (next.tick % 6 === 0) next = runOppositionTurn(next); + if (next.tick % 20 === 0) next = runOppositionTurn(next); const livingCrew = next.combatants.filter((actor) => actor.team === 'crew' && !actor.isDown); if (livingCrew.length === 0) next = resolveResult(next, 'overrun'); } diff --git a/frontend/src/game/combat/prepareEncounter.ts b/frontend/src/game/combat/prepareEncounter.ts index 268f8f5..48294ef 100644 --- a/frontend/src/game/combat/prepareEncounter.ts +++ b/frontend/src/game/combat/prepareEncounter.ts @@ -84,6 +84,19 @@ function findSafePoint(terrain: CombatTerrainCell[][]): CombatTerrainCell { }; } +function findExtractionPoint(terrain: CombatTerrainCell[][], origin: GridPoint): CombatTerrainCell { + return terrain + .flat() + .filter((cell) => cell.passable && (cell.x !== origin.x || cell.y !== origin.y)) + .sort((left, right) => { + const leftDistance = Math.abs(left.x - origin.x) + Math.abs(left.y - origin.y); + const rightDistance = Math.abs(right.x - origin.x) + Math.abs(right.y - origin.y); + const leftScore = leftDistance * 10 + (7 - left.y) * 5 + left.cover * 3 - left.exposure; + const rightScore = rightDistance * 10 + (7 - right.y) * 5 + right.cover * 3 - right.exposure; + return rightScore - leftScore; + })[0] ?? findSafePoint(terrain); +} + function findOppositionStart(terrain: CombatTerrainCell[][], seed: number, index: number): GridPoint { const cells = terrain .flat() @@ -132,7 +145,7 @@ export function prepareEncounter(block: BlockData): EncounterPreparation { lastSequence: -1, isDown: false, })); - const extraction = findSafePoint(terrain); + const extraction = findExtractionPoint(terrain, fallbackCrew[0].position); const coverPercent = Math.round(extraction.cover * 100); const exposurePercent = Math.round(extraction.exposure * 100); diff --git a/frontend/src/game/ops/OpsInput.ts b/frontend/src/game/ops/OpsInput.ts new file mode 100644 index 0000000..07a96d6 --- /dev/null +++ b/frontend/src/game/ops/OpsInput.ts @@ -0,0 +1,125 @@ +export type OpsAction = 'fire' | 'reload' | 'interact' | 'retreat' | 'camera' | 'pause'; + +export interface OpsMovementInput { + forward: number; + strafe: number; +} + +export interface OpsLookDelta { + x: number; + y: number; +} + +/** DOM input owner for Modern Ops. All listeners are removed by dispose(). */ +export class OpsInput { + private readonly keys = new Set(); + private readonly pressed = new Set(); + private lookX = 0; + private lookY = 0; + private disposed = false; + + constructor(private readonly canvas: HTMLCanvasElement) { + window.addEventListener('keydown', this.onKeyDown); + window.addEventListener('keyup', this.onKeyUp); + window.addEventListener('blur', this.onBlur); + document.addEventListener('mousemove', this.onMouseMove); + document.addEventListener('pointerlockchange', this.onPointerLockChange); + canvas.addEventListener('mousedown', this.onMouseDown); + canvas.addEventListener('contextmenu', this.onContextMenu); + } + + movement(): OpsMovementInput { + const forward = Number(this.keys.has('KeyW') || this.keys.has('ArrowUp')) + - Number(this.keys.has('KeyS') || this.keys.has('ArrowDown')); + const strafe = Number(this.keys.has('KeyD') || this.keys.has('ArrowRight')) + - Number(this.keys.has('KeyA') || this.keys.has('ArrowLeft')); + return { forward, strafe }; + } + + consume(action: OpsAction): boolean { + const active = this.pressed.has(action); + this.pressed.delete(action); + return active; + } + + consumeLookDelta(): OpsLookDelta { + const result = { x: this.lookX, y: this.lookY }; + this.lookX = 0; + this.lookY = 0; + return result; + } + + isPointerLocked(): boolean { + return document.pointerLockElement === this.canvas; + } + + requestPointerLock(): void { + if (this.isPointerLocked()) return; + void this.canvas.requestPointerLock?.(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + window.removeEventListener('keydown', this.onKeyDown); + window.removeEventListener('keyup', this.onKeyUp); + window.removeEventListener('blur', this.onBlur); + document.removeEventListener('mousemove', this.onMouseMove); + document.removeEventListener('pointerlockchange', this.onPointerLockChange); + this.canvas.removeEventListener('mousedown', this.onMouseDown); + this.canvas.removeEventListener('contextmenu', this.onContextMenu); + this.keys.clear(); + this.pressed.clear(); + } + + private readonly onKeyDown = (event: KeyboardEvent): void => { + if (event.repeat) { + this.keys.add(event.code); + return; + } + this.keys.add(event.code); + const action = this.actionForCode(event.code); + if (action) { + this.pressed.add(action); + if (event.code !== 'Escape') event.preventDefault(); + } + }; + + private readonly onKeyUp = (event: KeyboardEvent): void => { + this.keys.delete(event.code); + }; + + private readonly onBlur = (): void => { + this.keys.clear(); + }; + + private readonly onMouseDown = (event: MouseEvent): void => { + if (event.button !== 0) return; + this.pressed.add('fire'); + if (!this.isPointerLocked()) this.requestPointerLock(); + }; + + private readonly onMouseMove = (event: MouseEvent): void => { + if (!this.isPointerLocked()) return; + this.lookX += event.movementX; + this.lookY += event.movementY; + }; + + private readonly onPointerLockChange = (): void => { + if (!this.isPointerLocked()) this.keys.clear(); + }; + + private readonly onContextMenu = (event: MouseEvent): void => { + event.preventDefault(); + }; + + private actionForCode(code: string): OpsAction | null { + if (code === 'Space') return 'fire'; + if (code === 'KeyR') return 'reload'; + if (code === 'KeyE') return 'interact'; + if (code === 'KeyQ') return 'retreat'; + if (code === 'KeyV') return 'camera'; + if (code === 'Escape') return 'pause'; + return null; + } +} diff --git a/frontend/src/game/ops/OpsWorld.ts b/frontend/src/game/ops/OpsWorld.ts new file mode 100644 index 0000000..603f09d --- /dev/null +++ b/frontend/src/game/ops/OpsWorld.ts @@ -0,0 +1,520 @@ +import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera'; +import { UniversalCamera } from '@babylonjs/core/Cameras/universalCamera'; +import type { Camera } from '@babylonjs/core/Cameras/camera'; +import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight'; +import { PointLight } from '@babylonjs/core/Lights/pointLight'; +import { Color3 } from '@babylonjs/core/Maths/math.color'; +import { Vector3 } from '@babylonjs/core/Maths/math.vector'; +import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; +import { Texture } from '@babylonjs/core/Materials/Textures/texture'; +import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh'; +import type { LinesMesh } from '@babylonjs/core/Meshes/linesMesh'; +import type { Mesh } from '@babylonjs/core/Meshes/mesh'; +import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; +import { TransformNode } from '@babylonjs/core/Meshes/transformNode'; +import type { Observer } from '@babylonjs/core/Misc/observable'; +import type { Scene } from '@babylonjs/core/scene'; +import type { CombatEvent, CombatSnapshot, Combatant, EncounterPreparation } from '../combat/types'; +import type { ActionCameraMode, CombatControllerState } from '../combat/CombatSessionController'; +import { CombatSessionController } from '../combat/CombatSessionController'; +import { OpsInput } from './OpsInput'; +import { gridToWorld, movementToGridStep, OPS_CELL_SIZE } from './opsCoordinates'; + +const FACADE_URL = '/assets/runtime/generated/environments/street/block_modern_ops_storefront_v001.webp'; +const MOVE_INTERVAL_MS = 155; +const SIM_TICK_MS = 100; + +interface ActorVisual { + root: TransformNode; + body: Mesh; + head: Mesh; + marker: Mesh; + target: Vector3; +} + +interface TimedEffect { + mesh: AbstractMesh; + remaining: number; +} + +export interface OpsWorldOptions { + demo?: boolean; +} + +function material( + scene: Scene, + name: string, + diffuse: Color3, + specular = new Color3(0.18, 0.18, 0.2), + emissive = Color3.Black(), +): StandardMaterial { + const result = new StandardMaterial(name, scene); + result.diffuseColor = diffuse; + result.specularColor = specular; + result.emissiveColor = emissive; + return result; +} + +function actorPosition(actor: Combatant): Vector3 { + const world = gridToWorld(actor.position, actor.isDown ? 0.22 : 0.9); + return new Vector3(world.x, world.y, world.z); +} + +export class OpsWorld { + private readonly input: OpsInput; + private readonly actorVisuals = new Map(); + private readonly effects: TimedEffect[] = []; + private readonly materials: StandardMaterial[] = []; + private readonly observers: Observer[] = []; + private readonly tacticalCamera: ArcRotateCamera; + private readonly firstPersonCamera: UniversalCamera; + private readonly thirdPersonCamera: UniversalCamera; + private readonly extractionMesh: Mesh; + private readonly unsubscribeController: () => void; + private snapshot: CombatSnapshot; + private latestEventId: string | null = null; + private yaw = Math.PI; + private pitch = 0; + private movementCooldown = 0; + private tickAccumulator = 0; + private elapsed = 0; + private demoAccumulator = 0; + private demoStep = 0; + private disposed = false; + + constructor( + private readonly scene: Scene, + canvas: HTMLCanvasElement, + private readonly controller: CombatSessionController, + private readonly preparation: EncounterPreparation, + private readonly options: OpsWorldOptions = {}, + ) { + this.snapshot = controller.getSnapshot(); + const selected = controller.getSelectedCrew(); + if (selected) { + const source = gridToWorld(selected.position, 0); + const extraction = gridToWorld(preparation.objective.extraction, 0); + this.yaw = Math.atan2(extraction.x - source.x, extraction.z - source.z); + } + this.input = new OpsInput(canvas); + + this.createEnvironment(); + this.createFacade(); + this.createStreetLights(); + this.extractionMesh = this.createExtraction(); + + this.tacticalCamera = this.createTacticalCamera(); + this.firstPersonCamera = this.createUniversalCamera('ops-first-person', 0.94); + this.thirdPersonCamera = this.createUniversalCamera('ops-third-person', 0.82); + + this.syncSnapshot(this.snapshot); + this.applyCameraMode(controller.getCameraMode()); + + this.unsubscribeController = controller.subscribe((state) => this.onControllerState(state)); + this.observers.push(scene.onBeforeRenderObservable.add(() => { + this.update(scene.getEngine().getDeltaTime() / 1000); + })); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.unsubscribeController(); + this.input.dispose(); + this.observers.forEach((observer) => this.scene.onBeforeRenderObservable.remove(observer)); + this.effects.forEach((effect) => effect.mesh.dispose()); + this.materials.forEach((item) => item.dispose()); + if (document.pointerLockElement) void document.exitPointerLock?.(); + } + + private createEnvironment(): void { + this.scene.clearColor.set(0.012, 0.025, 0.045, 1); + this.scene.ambientColor = new Color3(0.08, 0.11, 0.16); + + const sky = new HemisphericLight('ops-sky-light', new Vector3(0.2, 1, 0.1), this.scene); + sky.intensity = 0.82; + sky.diffuse = new Color3(0.34, 0.5, 0.68); + sky.groundColor = new Color3(0.11, 0.08, 0.16); + + const palette = { + street: material(this.scene, 'ops-road', new Color3(0.035, 0.055, 0.075), new Color3(0.95, 0.95, 1)), + curb: material(this.scene, 'ops-curb', new Color3(0.19, 0.21, 0.23), new Color3(0.4, 0.4, 0.44)), + sidewalk: material(this.scene, 'ops-sidewalk', new Color3(0.13, 0.18, 0.2), new Color3(0.5, 0.54, 0.58)), + storefront: material(this.scene, 'ops-storefront-ground', new Color3(0.11, 0.09, 0.14)), + alley: material(this.scene, 'ops-alley', new Color3(0.035, 0.08, 0.085)), + parking: material(this.scene, 'ops-parking', new Color3(0.095, 0.09, 0.085), new Color3(0.38, 0.35, 0.34)), + rooftop: material(this.scene, 'ops-rooftop', new Color3(0.105, 0.08, 0.13)), + building: material(this.scene, 'ops-building', new Color3(0.055, 0.045, 0.075)), + cover: material(this.scene, 'ops-cover', new Color3(0.22, 0.24, 0.26), new Color3(0.35, 0.35, 0.38)), + }; + this.materials.push(...Object.values(palette)); + + this.preparation.terrain.flat().forEach((cell) => { + const point = gridToWorld(cell, 0); + const cellMesh = MeshBuilder.CreateBox(`ops-cell-${cell.x}-${cell.y}`, { + width: OPS_CELL_SIZE - 0.08, + depth: OPS_CELL_SIZE - 0.08, + height: cell.zoneType === 'building' ? 1.6 : 0.12, + }, this.scene); + cellMesh.position.set(point.x, cell.zoneType === 'building' ? 0.75 : -0.08, point.z); + cellMesh.material = palette[cell.zoneType] ?? palette.sidewalk; + cellMesh.receiveShadows = true; + + if (cell.cover >= 0.45 && cell.passable) { + const cover = MeshBuilder.CreateBox(`ops-cover-${cell.x}-${cell.y}`, { + width: OPS_CELL_SIZE * 0.62, + depth: OPS_CELL_SIZE * 0.34, + height: 0.9, + }, this.scene); + cover.position.set(point.x, 0.45, point.z); + cover.material = palette.cover; + } + }); + + const laneLine = material(this.scene, 'ops-lane-line', new Color3(0.7, 0.52, 0.08), Color3.Black(), new Color3(0.18, 0.11, 0.01)); + this.materials.push(laneLine); + for (let index = -3; index <= 3; index += 1) { + const stripe = MeshBuilder.CreateBox(`ops-lane-${index}`, { width: 1.5, depth: 0.09, height: 0.03 }, this.scene); + stripe.position.set(index * 4.3, 0.02, 0); + stripe.material = laneLine; + } + } + + private createFacade(): void { + const facadeMaterial = material(this.scene, 'ops-facade-material', new Color3(1, 1, 1)); + const facadeTexture = new Texture(FACADE_URL, this.scene, true, false); + facadeTexture.hasAlpha = false; + facadeTexture.vScale = -1; + facadeTexture.vOffset = 1; + facadeMaterial.diffuseTexture = facadeTexture; + facadeMaterial.emissiveTexture = facadeTexture; + facadeMaterial.emissiveColor = new Color3(0.42, 0.42, 0.46); + facadeMaterial.backFaceCulling = false; + this.materials.push(facadeMaterial); + + const facade = MeshBuilder.CreatePlane('ops-storefront-facade-north', { width: 30, height: 16.875 }, this.scene); + facade.position.set(0, 4.1, -15.7); + facade.rotation.y = Math.PI; + facade.scaling.y = 0.49; + facade.material = facadeMaterial; + + const oppositeFacade = MeshBuilder.CreatePlane('ops-storefront-facade-south', { width: 30, height: 16.875 }, this.scene); + oppositeFacade.position.set(0, 4.1, 15.7); + oppositeFacade.scaling.y = 0.49; + oppositeFacade.material = facadeMaterial; + + const roofMaterial = material(this.scene, 'ops-roof-material', new Color3(0.045, 0.045, 0.055)); + this.materials.push(roofMaterial); + const northRoof = MeshBuilder.CreateBox('ops-storefront-roof-north', { width: 31, height: 0.5, depth: 3 }, this.scene); + northRoof.position.set(0, 7.95, -16.55); + northRoof.material = roofMaterial; + const southRoof = MeshBuilder.CreateBox('ops-storefront-roof-south', { width: 31, height: 0.5, depth: 3 }, this.scene); + southRoof.position.set(0, 7.95, 16.55); + southRoof.material = roofMaterial; + } + + private createStreetLights(): void { + const poleMaterial = material(this.scene, 'ops-pole', new Color3(0.06, 0.07, 0.08), new Color3(0.55, 0.55, 0.58)); + const lampMaterial = material(this.scene, 'ops-lamp', new Color3(0.75, 0.45, 0.16), Color3.Black(), new Color3(1, 0.48, 0.12)); + this.materials.push(poleMaterial, lampMaterial); + + [ + { x: -10.5, z: -4.5 }, + { x: 10.5, z: -4.5 }, + { x: -8.5, z: 8.5 }, + { x: 8.5, z: 8.5 }, + ].forEach(({ x, z }, index) => { + const pole = MeshBuilder.CreateCylinder(`ops-light-pole-${index}`, { height: 6.5, diameter: 0.16 }, this.scene); + pole.position.set(x, 3.25, z); + pole.material = poleMaterial; + const lamp = MeshBuilder.CreateSphere(`ops-light-lamp-${index}`, { diameter: 0.48, segments: 12 }, this.scene); + lamp.position.set(x, 6.35, z); + lamp.material = lampMaterial; + const light = new PointLight(`ops-light-${index}`, lamp.position.clone(), this.scene); + light.diffuse = index % 2 === 0 ? new Color3(0.2, 0.65, 0.95) : new Color3(1, 0.4, 0.22); + light.intensity = 3.6; + light.range = 18; + }); + } + + private createExtraction(): Mesh { + const position = gridToWorld(this.preparation.objective.extraction, 0.08); + const extractionMaterial = material( + this.scene, + 'ops-extraction-material', + new Color3(0.05, 0.65, 0.22), + Color3.Black(), + new Color3(0.02, 0.85, 0.25), + ); + extractionMaterial.alpha = 0.72; + this.materials.push(extractionMaterial); + const marker = MeshBuilder.CreateCylinder('ops-extraction', { height: 0.18, diameter: 2.1, tessellation: 32 }, this.scene); + marker.position.set(position.x, position.y, position.z); + marker.material = extractionMaterial; + const beam = MeshBuilder.CreateCylinder('ops-extraction-beam', { height: 5.8, diameter: 0.18, tessellation: 16 }, this.scene); + beam.parent = marker; + beam.position.y = 2.9; + beam.material = extractionMaterial; + return marker; + } + + private createTacticalCamera(): ArcRotateCamera { + const camera = new ArcRotateCamera('ops-tactical', Math.PI * 1.5, 1.02, 35, new Vector3(0, 0.2, 0), this.scene); + camera.fov = 0.72; + camera.lowerRadiusLimit = 24; + camera.upperRadiusLimit = 42; + camera.lowerBetaLimit = 0.72; + camera.upperBetaLimit = 1.25; + return camera; + } + + private createUniversalCamera(name: string, fov: number): UniversalCamera { + const camera = new UniversalCamera(name, new Vector3(0, 2, 10), this.scene); + camera.fov = fov; + camera.minZ = 0.08; + camera.speed = 0; + camera.angularSensibility = 8000; + return camera; + } + + private onControllerState(state: CombatControllerState): void { + const previousMode = this.scene.activeCamera?.name; + this.snapshot = state.snapshot; + this.syncSnapshot(state.snapshot); + this.handleEvents(state.snapshot.events); + this.applyCameraMode(state.hud.cameraMode); + if (previousMode !== this.scene.activeCamera?.name) this.input.consumeLookDelta(); + } + + private syncSnapshot(snapshot: CombatSnapshot): void { + const activeIds = new Set(snapshot.combatants.map((actor) => actor.id)); + this.actorVisuals.forEach((visual, id) => { + if (activeIds.has(id)) return; + visual.root.dispose(false, true); + this.actorVisuals.delete(id); + }); + + snapshot.combatants.forEach((actor) => { + let visual = this.actorVisuals.get(actor.id); + if (!visual) { + visual = this.createActorVisual(actor); + this.actorVisuals.set(actor.id, visual); + } + visual.target.copyFrom(actorPosition(actor)); + const selected = actor.id === this.controller.getHudState().selectedId; + visual.marker.setEnabled(selected && !actor.isDown); + visual.root.rotation.z = actor.isDown ? Math.PI / 2 : 0; + visual.body.visibility = actor.isDown ? 0.42 : 1; + visual.head.visibility = actor.isDown ? 0.42 : 1; + }); + } + + private createActorVisual(actor: Combatant): ActorVisual { + const root = new TransformNode(`ops-actor-${actor.id}`, this.scene); + root.position.copyFrom(actorPosition(actor)); + + const bodyMaterial = material( + this.scene, + `ops-actor-material-${actor.id}`, + actor.team === 'crew' ? new Color3(0.025, 0.19, 0.23) : new Color3(0.26, 0.035, 0.075), + new Color3(0.2, 0.2, 0.22), + actor.team === 'crew' ? new Color3(0.005, 0.035, 0.045) : new Color3(0.045, 0.003, 0.008), + ); + this.materials.push(bodyMaterial); + + const body = MeshBuilder.CreateCapsule(`ops-body-${actor.id}`, { height: 1.45, radius: 0.34, tessellation: 10 }, this.scene); + body.parent = root; + body.position.y = 0.6; + body.material = bodyMaterial; + + const head = MeshBuilder.CreateSphere(`ops-head-${actor.id}`, { diameter: 0.48, segments: 10 }, this.scene); + head.parent = root; + head.position.y = 1.48; + head.material = bodyMaterial; + + const markerMaterial = material( + this.scene, + `ops-marker-material-${actor.id}`, + Color3.Black(), + Color3.Black(), + new Color3(0.05, 0.85, 0.8), + ); + this.materials.push(markerMaterial); + const marker = MeshBuilder.CreateTorus(`ops-marker-${actor.id}`, { diameter: 1.35, thickness: 0.06, tessellation: 24 }, this.scene); + marker.parent = root; + marker.rotation.x = Math.PI / 2; + marker.position.y = -0.78; + marker.material = markerMaterial; + + return { root, body, head, marker, target: root.position.clone() }; + } + + private handleEvents(events: CombatEvent[]): void { + const startIndex = this.latestEventId + ? events.findIndex((event) => event.id === this.latestEventId) + 1 + : 0; + const nextEvents = startIndex > 0 ? events.slice(startIndex) : events; + nextEvents.forEach((event) => this.renderEvent(event)); + this.latestEventId = events.at(-1)?.id ?? this.latestEventId; + } + + private renderEvent(event: CombatEvent): void { + if (event.type === 'weapon-fired' && event.actorId && event.targetId) { + const source = this.actorVisuals.get(event.actorId)?.root.position; + const target = this.actorVisuals.get(event.targetId)?.root.position; + if (source && target) { + const tracer = MeshBuilder.CreateLines(`ops-tracer-${event.id}`, { + points: [source.add(new Vector3(0, 1.2, 0)), target.add(new Vector3(0, 1.15, 0))], + updatable: false, + }, this.scene) as LinesMesh; + tracer.color = new Color3(1, 0.72, 0.16); + tracer.alpha = 0.95; + this.effects.push({ mesh: tracer, remaining: 0.11 }); + } + } + + if ((event.type === 'impact-actor' || event.type === 'actor-downed') && event.targetId) { + const position = this.actorVisuals.get(event.targetId)?.root.position; + if (position) { + const flashMaterial = material( + this.scene, + `ops-impact-material-${event.id}`, + new Color3(1, 0.2, 0.05), + Color3.Black(), + new Color3(1, 0.1, 0.02), + ); + this.materials.push(flashMaterial); + const flash = MeshBuilder.CreateSphere(`ops-impact-${event.id}`, { diameter: event.type === 'actor-downed' ? 0.9 : 0.55, segments: 8 }, this.scene); + flash.position.copyFrom(position.add(new Vector3(0, 1, 0))); + flash.material = flashMaterial; + this.effects.push({ mesh: flash, remaining: 0.18 }); + } + } + } + + private applyCameraMode(mode: ActionCameraMode): void { + const camera: Camera = mode === 'tactical' + ? this.tacticalCamera + : mode === 'first-person' + ? this.firstPersonCamera + : this.thirdPersonCamera; + if (this.scene.activeCamera !== camera) this.scene.activeCamera = camera; + } + + private update(delta: number): void { + if (this.disposed) return; + const boundedDelta = Math.min(delta, 0.1); + this.elapsed += boundedDelta; + this.movementCooldown = Math.max(0, this.movementCooldown - boundedDelta * 1000); + + const look = this.input.consumeLookDelta(); + this.yaw -= look.x * 0.0022; + this.pitch = Math.max(-0.62, Math.min(0.55, this.pitch - look.y * 0.0017)); + + if (this.input.consume('pause')) this.controller.setPaused(!this.controller.isPaused()); + if (this.input.consume('camera')) this.controller.cycleCameraMode(); + if (this.input.consume('fire')) this.controller.fireNearest(); + if (this.input.consume('reload')) this.controller.reloadSelected(); + if (this.input.consume('interact')) this.controller.interactSelected(); + if (this.input.consume('retreat')) this.controller.retreatSelected(); + + if (!this.controller.isPaused() && this.snapshot.phase === 'active') { + this.updateMovement(); + this.tickAccumulator += boundedDelta * 1000; + while (this.tickAccumulator >= SIM_TICK_MS) { + this.tickAccumulator -= SIM_TICK_MS; + this.controller.advance(2); + } + if (this.options.demo) this.updateDemo(boundedDelta); + } + + this.actorVisuals.forEach((visual) => { + visual.root.position.copyFrom(Vector3.Lerp(visual.root.position, visual.target, Math.min(1, boundedDelta * 11))); + }); + this.updateCamera(); + this.updateEffects(boundedDelta); + const pulse = 1 + Math.sin(this.elapsed * 4.5) * 0.12; + this.extractionMesh.scaling.set(pulse, 1, pulse); + } + + private updateMovement(): void { + if (this.movementCooldown > 0) return; + const movement = this.input.movement(); + const step = movementToGridStep(movement.forward, movement.strafe, this.yaw); + if (!step) return; + this.controller.moveSelected(step.x, step.y); + this.movementCooldown = MOVE_INTERVAL_MS; + } + + private updateDemo(delta: number): void { + this.demoAccumulator += delta; + if (this.demoAccumulator < 0.68 || this.snapshot.phase !== 'active') return; + this.demoAccumulator = 0; + this.demoStep += 1; + + if (this.demoStep === 2) this.controller.setCameraMode('first-person'); + if (this.demoStep === 5) this.controller.setCameraMode('third-person'); + if (this.demoStep === 8) this.controller.setCameraMode('tactical'); + + const opposition = this.snapshot.combatants.filter((actor) => actor.team === 'opposition' && !actor.isDown); + if (opposition.length > 0) { + this.controller.fireNearest(); + return; + } + + const actor = this.controller.getSelectedCrew(); + if (!actor) return; + const target = this.snapshot.objective.extraction; + const dx = Math.sign(target.x - actor.position.x); + const dy = Math.sign(target.y - actor.position.y); + if (dx !== 0) this.controller.moveSelected(dx, 0); + else if (dy !== 0) this.controller.moveSelected(0, dy); + else this.controller.interactSelected(); + } + + private updateCamera(): void { + const selected = this.controller.getSelectedCrew(); + if (!selected) return; + const actor = this.actorVisuals.get(selected.id); + if (!actor) return; + const position = actor.root.position; + const forward = new Vector3(Math.sin(this.yaw), 0, Math.cos(this.yaw)); + const right = new Vector3(Math.cos(this.yaw), 0, -Math.sin(this.yaw)); + + if (this.controller.getCameraMode() === 'tactical') { + this.tacticalCamera.target.copyFrom(position.add(new Vector3(0, 0.6, 0))); + this.tacticalCamera.alpha = this.yaw - Math.PI / 2; + actor.body.visibility = selected.isDown ? 0.42 : 1; + actor.head.visibility = selected.isDown ? 0.42 : 1; + return; + } + + if (this.controller.getCameraMode() === 'first-person') { + this.firstPersonCamera.position.copyFrom(position.add(new Vector3(0, 1.55, 0))); + this.firstPersonCamera.rotation.set(this.pitch, this.yaw, 0); + actor.body.visibility = 0; + actor.head.visibility = 0; + return; + } + + const shoulder = position + .subtract(forward.scale(9.4)) + .add(right.scale(2.35)) + .add(new Vector3(0, 2.35, 0)); + this.thirdPersonCamera.position.copyFrom(shoulder); + this.thirdPersonCamera.setTarget(position.add(new Vector3(0, 1 + this.pitch * 1.5, 0)).add(forward.scale(6.2))); + actor.body.visibility = selected.isDown ? 0.42 : 1; + actor.head.visibility = selected.isDown ? 0.42 : 1; + } + + private updateEffects(delta: number): void { + for (let index = this.effects.length - 1; index >= 0; index -= 1) { + const effect = this.effects[index]; + effect.remaining -= delta; + effect.mesh.scaling.scaleInPlace(1 + delta * 2.5); + if (effect.remaining > 0) continue; + effect.mesh.dispose(); + this.effects.splice(index, 1); + } + } +} diff --git a/frontend/src/game/ops/__tests__/opsCoordinates.test.ts b/frontend/src/game/ops/__tests__/opsCoordinates.test.ts new file mode 100644 index 0000000..6f8c9e6 --- /dev/null +++ b/frontend/src/game/ops/__tests__/opsCoordinates.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { gridToWorld, movementToGridStep, worldToGrid } from '../opsCoordinates'; + +describe('Modern Ops coordinates', () => { + it('round-trips every authoritative combat cell', () => { + for (let y = 0; y < 8; y += 1) { + for (let x = 0; x < 8; x += 1) { + expect(worldToGrid(gridToWorld({ x, y }))).toEqual({ x, y }); + } + } + }); + + it('maps camera-relative input to a single cardinal grid step', () => { + expect(movementToGridStep(1, 0, 0)).toEqual({ x: 0, y: 1 }); + expect(movementToGridStep(1, 0, Math.PI / 2)).toEqual({ x: 1, y: 0 }); + expect(movementToGridStep(0, -1, 0)).toEqual({ x: -1, y: 0 }); + expect(movementToGridStep(0, 0, 0)).toBeNull(); + }); +}); diff --git a/frontend/src/game/ops/createModernOpsScene.ts b/frontend/src/game/ops/createModernOpsScene.ts new file mode 100644 index 0000000..ca98bf7 --- /dev/null +++ b/frontend/src/game/ops/createModernOpsScene.ts @@ -0,0 +1,38 @@ +import type { Engine } from '@babylonjs/core/Engines/engine'; +import { Scene } from '@babylonjs/core/scene'; +import type { CombatSessionController } from '../combat/CombatSessionController'; +import type { EncounterPreparation } from '../combat/types'; +import { OpsWorld } from './OpsWorld'; + +export interface ModernOpsSceneOptions { + demo?: boolean; +} + +export interface ModernOpsSceneHandle { + scene: Scene; + dispose(): void; +} + +export async function createModernOpsScene( + engine: Engine, + canvas: HTMLCanvasElement, + controller: CombatSessionController, + preparation: EncounterPreparation, + options: ModernOpsSceneOptions = {}, +): Promise { + const scene = new Scene(engine); + const world = new OpsWorld(scene, canvas, controller, preparation, options); + + await scene.whenReadyAsync(); + + let disposed = false; + return { + scene, + dispose() { + if (disposed) return; + disposed = true; + world.dispose(); + scene.dispose(); + }, + }; +} diff --git a/frontend/src/game/ops/opsCoordinates.ts b/frontend/src/game/ops/opsCoordinates.ts new file mode 100644 index 0000000..862e091 --- /dev/null +++ b/frontend/src/game/ops/opsCoordinates.ts @@ -0,0 +1,44 @@ +import type { GridPoint } from '../combat/types'; + +export const OPS_GRID_SIZE = 8; +export const OPS_CELL_SIZE = 3.5; + +export interface WorldPoint { + x: number; + y: number; + z: number; +} + +export function gridToWorld(point: GridPoint, y = 0): WorldPoint { + const half = (OPS_GRID_SIZE - 1) / 2; + return { + x: (point.x - half) * OPS_CELL_SIZE, + y, + z: (point.y - half) * OPS_CELL_SIZE, + }; +} + +export function worldToGrid(point: Pick): GridPoint { + const half = (OPS_GRID_SIZE - 1) / 2; + return { + x: Math.max(0, Math.min(OPS_GRID_SIZE - 1, Math.round(point.x / OPS_CELL_SIZE + half))), + y: Math.max(0, Math.min(OPS_GRID_SIZE - 1, Math.round(point.z / OPS_CELL_SIZE + half))), + }; +} + +/** Convert camera-relative movement into one cardinal grid command. */ +export function movementToGridStep( + forward: number, + strafe: number, + yawRadians: number, +): GridPoint | null { + if (Math.abs(forward) < 0.1 && Math.abs(strafe) < 0.1) return null; + + const worldX = Math.sin(yawRadians) * forward + Math.cos(yawRadians) * strafe; + const worldZ = Math.cos(yawRadians) * forward - Math.sin(yawRadians) * strafe; + + if (Math.abs(worldX) > Math.abs(worldZ)) { + return { x: Math.sign(worldX), y: 0 }; + } + return { x: 0, y: Math.sign(worldZ) }; +} From db0ab818561a70c921871c42eb246933def31094 Mon Sep 17 00:00:00 2001 From: BrandDead <1.74973198e+08+BrandDead@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:13:19 +0000 Subject: [PATCH 10/11] build: stabilize production chunk rendering --- MEMORY.md | 6 +++++ frontend/vite.config.ts | 51 ++++++++--------------------------------- 2 files changed, 16 insertions(+), 41 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index 7f0302d..2de9fa2 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -37,3 +37,9 @@ Sandbox Chromium/CDP launched the mode through MAP → Strip → OPS 3D with Web The deterministic demo reached a secured outcome and applied it through the existing block boundary. Pending income changed $840→$982, heat 1→2, morale 85%→89%, the Modern Ops portal unmounted, and the $2,200 injury/hospital follow-up opened for Lil Dre. No separate economy, crew, or territory copy was introduced. Evidence is saved under `.audit/`: `ops-third-person.png`, `ops-first-person.png`, `ops-tactical-active.png`, `ops-return-strategy.png`, `browser-findings.md`, and CDP state/event JSON files. + +## 2026-08-26 — Final Quality Gauntlet + +The completed branch passes TypeScript, all 45 frontend test files / 693 tests, the runtime asset audit (110 assets, 5.81 MB of the 20 MB budget), and all 42 offline backend tests. ESLint reports zero errors and 196 pre-existing warnings. + +The production build initially terminated during chunk rendering, reproducing the baseline sandbox failure. Vite’s application-level manual chunks also emitted circular chunk warnings. Removing those forced component chunks, keeping only dependency-oriented chunks (including a dedicated lazy Babylon vendor chunk), and changing the minifier from Terser to esbuild eliminated the circular graph. After closing the completed browser-verification process, the production build succeeded in 21.15 seconds with 2,875 modules transformed. Modern Ops remains a separate lazy chunk (`ModernOpsEncounter` about 24.4 kB plus its Babylon vendor chunk), so the 3D runtime is not loaded until the player launches OPS 3D. diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 25083b7..547627a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,18 +2,15 @@ // vite.config.ts — Sprint 7D: Performance optimizations // - Manual chunk splitting for mini-games and heavy deps // - Asset inlining threshold -// - Terser minification +// - Memory-bounded esbuild minification // - CSS code splitting // ============================================================ -import { defineConfig, splitVendorChunkPlugin } from 'vite'; +import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ - plugins: [ - react(), - splitVendorChunkPlugin(), - ], + plugins: [react()], server: { port: 3000, @@ -42,6 +39,10 @@ export default defineConfig({ if (id.includes('phaser')) { return 'vendor-phaser'; } + // Babylon — lazy-loaded only by Modern Ops + if (id.includes('@babylonjs/core')) { + return 'vendor-babylon'; + } // Framer Motion if (id.includes('framer-motion')) { return 'vendor-framer'; @@ -54,45 +55,13 @@ export default defineConfig({ if (id.includes('socket.io')) { return 'vendor-socket'; } - // Mini-game components — lazy-loaded - if ( - id.includes('/components/slide/') || - id.includes('/components/driveby/') || - id.includes('/components/alchemy/') - ) { - return 'chunk-minigames'; - } - // Economy / market screens - if ( - id.includes('/components/economy/') || - id.includes('/components/casino/') - ) { - return 'chunk-economy'; - } - // Gang / contacts screens - if ( - id.includes('/components/gang/') || - id.includes('/components/contacts/') - ) { - return 'chunk-crew'; - } - // Map components - if (id.includes('/components/map/')) { - return 'chunk-map'; - } + }, }, }, - // Terser minification options - minify: 'terser', - terserOptions: { - compress: { - drop_console: true, // remove console.log in prod - drop_debugger: true, - pure_funcs: ['console.warn', 'console.info'], - }, - }, + // esbuild keeps production bundling fast and memory-bounded in CI. + minify: 'esbuild', }, // Optimize dependency pre-bundling From e458cbae5aca27ce08d3c2e9af246821ef421c6b Mon Sep 17 00:00:00 2001 From: BrandDead <1.74973198e+08+BrandDead@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:20:14 +0000 Subject: [PATCH 11/11] docs: add GitHub finish plan beyond Stripe --- docs/GITHUB_FINISH_PLAN.md | 86 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/GITHUB_FINISH_PLAN.md diff --git a/docs/GITHUB_FINISH_PLAN.md b/docs/GITHUB_FINISH_PLAN.md new file mode 100644 index 0000000..508beb0 --- /dev/null +++ b/docs/GITHUB_FINISH_PLAN.md @@ -0,0 +1,86 @@ +# DEALT / SLIDE GitHub Finish Plan — Beyond Stripe + +**Author:** Manus AI +**Date:** 2026-08-26 +**Active integration PR:** [#119 — Recover beta work and add Modern Ops FPS/TPS slice][1] + +## Executive decision + +**Stripe is not the next product priority.** Payments should remain behind a feature flag until the game proves a stable, replayable single-player loop. The immediate priority is to merge PR #119, then finish persistence, Block DNA variety, live rival territory behavior, coherent production art, action-mode controls, and beta evidence. Monetization before those systems are dependable would optimize checkout before retention. + +PR #119 establishes the correct foundation: recovered asset, Block DNA, Ghost Crew, beta-gate, and release work; one presentation-neutral combat controller; tactical/FPS/TPS Modern Ops views; one atomic strategy-result handoff; and a successful production build. It **advances** all six open issues but should not automatically close them because each still has explicit acceptance work.[1] + +| Order | Product objective | Why it comes before Stripe | Exit gate | +|---:|---|---|---| +| 0 | Merge and stabilize PR #119 | All later work depends on the recovered foundation and shared action contract. | Required CI stays green; review feedback resolved; merge to `main-tL2525`. | +| 1 | Persistent world and rival crews | The map must change while the player plans, sells, and fights. | Rival state survives reload; named crews claim, reinforce, attack, and hold turf using shared economy rules. | +| 2 | Complete the Block DNA library | A territory game cannot retain players if every claimed block plays the same. | 30–40 cards across three tiers; authored packages; DNA persisted in Supabase; two tiers visibly and mechanically differ. | +| 3 | Production visual and asset contract | The current vertical slice proves the pipeline, not final character or vehicle fidelity. | No legacy role fallback in core views; zero high-fringe/no-alpha sprites; coherent tactical/FPS/TPS art. | +| 4 | Action-mode production pass | FPS/TPS must be controllable and legible on actual target devices. | Touch controls, gamepad support, animation states, audio, aim/reload feedback, camera collision, and device budgets pass. | +| 5 | Economy, heat, morale, and consequence balance | These systems create the strategic reason to replay the mini-games. | Tuned income/heat curves, transparent risk, jail/hospital/bail recovery, and no dominant exploit. | +| 6 | Beta gate and outside testing | Payments should follow evidence that the core loop is stable and understandable. | Release checklist complete, crash/performance telemetry, onboarding success, and tester retention evidence. | +| 7 | Stripe and monetization | Monetization becomes useful only after the loop has value and operational stability. | Products/prices mapped to a reviewed economy; receipts, refunds, entitlements, and failure states tested. | + +## Open-issue reconciliation + +| Issue | What PR #119 now provides | What still blocks closure | Recommended next PR | +|---|---|---|---| +| [#81 — NPC rival crew v1][2] | Named Ghost Crew identities, personality-driven deterministic decisions, treasury/roster/turf state, grudge memory, shared-economy behavior, store tests, and a visible threat banner. | Supabase persistence, authoritative world-tick integration, real adjacent DNA claims, contested-block handoff into combat, and the acceptance test where an ignored player loses ground after three ticks. | `feat/ghost-crew-authoritative-world` | +| [#80 — Block DNA library][3] | `BlockDNA` contracts, DNA stamping on claim, gameplay multipliers, tests, and a 17-card library recovered from the divergent branch. | Expand to 30–40 cards across all three tiers, bind authored location packages, persist DNA metadata to Supabase, and prove two tiers look and play differently without Mapbox in combat. | `feat/block-dna-40-and-persistence` | +| [#79 — Asset pipeline cleanup][4] | Runtime manifest repair, orphan registration, CI asset audit, WebP runtime paths, a 20 MB gate, and a measured 5.81 MB current runtime set. | Re-matte every flagged high-fringe source, recut all gameplay sprites with true alpha, remove duplicate originals, and make the audit explicitly fail on fringe/alpha violations. | `art/runtime-sprite-rematte` | +| [#78 — Manifest wiring][5] | Existing core wiring is preserved; the recovered work fixes additional manifest paths and registers unused runtime assets. Modern Ops also uses a registered generated facade rather than a hardcoded external asset. | Generate missing role states, replace procedural vehicles with real-alpha layers, and delete the final `gang_members.png` fallback after proving no live consumer remains. | `art/complete-role-and-vehicle-states` | +| [#77 — Canonical visual stack][6] | A real grid-to-world projection contract, authored Las Olas action target, runtime art provenance, lifecycle-safe 3D scene, and camera ownership rules. | Complete the 2.5D tactical art bible, actor animation/socket contract, shared four-direction staging, and one production-quality hero block. Modern Ops complements rather than replaces the canonical tactical diorama. | `art/las-olas-hero-block-production` | +| [#45 — Beta gate][7] | Smoke tests, release checklist, final build recovery, full frontend/backend tests, asset budget, and browser/WebGL evidence. | Check off the release list with deployed evidence, real-device FPS/memory budgets, touch testing, accessibility/onboarding review, and an outside-tester blocker sweep. | `release/beta-evidence-and-device-matrix` | + +## Pull-request and branch state + +At audit time, PR #119 is open against the actual default branch, `main-tL2525`. GitHub reports it as mergeable. The repository CI frontend and backend jobs passed, and the Vercel preview completed successfully; the optional review bot remained pending during the final check.[1] + +| Branch | Divergence from `main-tL2525` | Disposition | +|---|---:|---| +| `main-tL2525` | Default branch | Keep. Merge PR #119 here. | +| `feat/modern-gameplay-completion` | 10 branch-only commits | Keep until PR #119 merges; then delete through the normal merged-branch cleanup. | +| `copilot/dev-oplan` | 2 default-only / 6 branch-only commits | Do not merge separately. Its six unique commits were cherry-picked and reconciled into PR #119. Delete only after #119 merges and patch equivalence is confirmed. | +| `feat/driveby-bullet-camera` | 1 default-only / 0 branch-only commits | Already merged by PR #118; safe to delete now or during post-merge cleanup.[8] | +| `copilot/connect-player-locations` | 10 default-only / 0 branch-only commits | Fully contained by the default branch; safe to delete after confirming no external automation targets it. | + +No other pull request was open when PR #119 was created. The old divergent work is therefore consolidated into one review surface rather than competing branches. + +## Four execution milestones + +### Milestone A — Authoritative living city + +Move Ghost Crew and Block DNA ownership from browser-local persistence to the existing backend/Supabase contract. A world tick should select one deterministic rival action, debit its treasury, mutate ownership, and emit a City Feed event. When a rival attacks a player block, the same encounter preparation and result boundary used by Modern Ops must resolve the contest. This milestone closes the largest strategic gap because it makes planning, selling, heat, and combat affect one shared world.[2] [3] + +### Milestone B — Production Las Olas block + +Replace capsule actors and procedural cover with production silhouettes, registered animation states, real-alpha vehicles, camera collision, street dressing, rain/audio ambience, and a coherent tactical projection. Preserve the existing deterministic grid as authority while the 2.5D tactical, FPS, and TPS presenters read the same state. The acceptance bar is one block that looks intentional in all three cameras and never needs a separate result model.[4] [5] [6] + +### Milestone C — Consequence and balance pass + +Tune selling distance, income multipliers, weapon accuracy, opponent pressure, heat gain, raid probability, overdose risk, morale loss, hospital/bail cost, and life-sentence escalation as one economy. Add seeded simulation tests for 100+ ticks and assert bounded money, heat, casualties, and rival growth. This is where the mini-games become one game rather than disconnected modes. + +### Milestone D — Beta evidence + +Run the release checklist on the Vercel preview and target mobile browsers. Record cold load, memory, sustained FPS, pointer-lock fallback, touch controls, pause/resume, WebGL loss, background/foreground recovery, and the complete claim → deploy → sell → defend → recover loop. Fix blocker and high-severity findings before enabling any paid product.[7] + +## Merge and cleanup checklist + +| Step | Owner action | Evidence | +|---:|---|---| +| 1 | Review PR #119 in the documented order and resolve optional review-bot findings. | Approved review and green required checks. | +| 2 | Merge PR #119 into `main-tL2525`. | Merge commit or squash commit on default. | +| 3 | Re-run production smoke on the merged Vercel deployment. | MAP → Strip → OPS 3D → FPS/TPS/TAC → apply result → injury flow. | +| 4 | Delete `feat/modern-gameplay-completion`, `copilot/dev-oplan`, `feat/driveby-bullet-camera`, and `copilot/connect-player-locations` only after the checks above. | Branch list contains only active work. | +| 5 | Create Milestone A issues/subtasks from #81 and #80, then start the authoritative-world PR. | One scoped PR with persistence migration, world-tick tests, and rollback plan. | + +## References + +[1]: https://github.com/BrandDead/slide/pull/119 "PR #119 — Recover beta work and add Modern Ops FPS/TPS slice" +[2]: https://github.com/BrandDead/slide/issues/81 "Issue #81 — NPC rival crew v1" +[3]: https://github.com/BrandDead/slide/issues/80 "Issue #80 — Block DNA library" +[4]: https://github.com/BrandDead/slide/issues/79 "Issue #79 — Asset pipeline cleanup" +[5]: https://github.com/BrandDead/slide/issues/78 "Issue #78 — Wire assetManifest into live renderers" +[6]: https://github.com/BrandDead/slide/issues/77 "Issue #77 — Canonical visual stack" +[7]: https://github.com/BrandDead/slide/issues/45 "Issue #45 — Beta gate" +[8]: https://github.com/BrandDead/slide/pull/118 "PR #118 — Complete the drive-by bullet camera"