feat: recover beta work and add Modern Ops FPS/TPS slice - #119
Conversation
…dit as CI gate, fix stale .png refs Co-authored-by: BrandDead <174973198+BrandDead@users.noreply.github.com>
…ssetManifest paths to on-disk webp Co-authored-by: BrandDead <174973198+BrandDead@users.noreply.github.com>
…claim, scale income by DNA multiplier Co-authored-by: BrandDead <174973198+BrandDead@users.noreply.github.com>
…grudge memory (#81) Co-authored-by: BrandDead <174973198+BrandDead@users.noreply.github.com>
…dation Co-authored-by: BrandDead <174973198+BrandDead@users.noreply.github.com>
…ater pure, fix test loop condition Co-authored-by: BrandDead <174973198+BrandDead@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Ghost grudge cleared before complete
- CarCrewSelector now uses consumePendingTarget() instead of reset() to preserve targetCrewId until after DriveByGame records the grudge attack.
- ✅ Fixed: Combat FX replay after log trim
- OpsWorld.handleEvents now detects when latestEventId has been trimmed from the event log and updates the tracking ID without replaying events.
Or push these changes by commenting:
@cursor push d2e1d4a913
Preview (d2e1d4a913)
diff --git a/frontend/src/components/driveby/CarCrewSelector.tsx b/frontend/src/components/driveby/CarCrewSelector.tsx
--- a/frontend/src/components/driveby/CarCrewSelector.tsx
+++ b/frontend/src/components/driveby/CarCrewSelector.tsx
@@ -258,7 +258,7 @@
? { address: normalizeAddressText(typed), seedMode: 'text-seed' }
: null);
onConfirm({ seats, targetBlock: finalTarget });
- useCombatIntentStore.getState().reset();
+ useCombatIntentStore.getState().consumePendingTarget();
}}
disabled={!canLaunch}
whileTap={canLaunch ? { scale: 0.95 } : {}}
diff --git a/frontend/src/components/driveby/DriveByGame.tsx b/frontend/src/components/driveby/DriveByGame.tsx
--- a/frontend/src/components/driveby/DriveByGame.tsx
+++ b/frontend/src/components/driveby/DriveByGame.tsx
@@ -7,11 +7,11 @@
import React, { useState, useCallback } from 'react';
import { useNavigationStore, usePlayerStore, useGangStore } from '../../stores/gameStore';
+import { useCombatIntentStore } from '../../stores/combatIntentStore';
+import { useGhostStore } from '../../stores/ghostCrewStore';
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;
@@ -55,6 +55,7 @@
if (targetCrewId && targetBlockId && stats.kills > 0) {
useGhostStore.getState().recordPlayerAttack(targetCrewId, targetBlockId);
}
+ useCombatIntentStore.getState().reset();
// Update crew member XP/stats based on performance
if (crew) {
diff --git a/frontend/src/game/ops/OpsWorld.ts b/frontend/src/game/ops/OpsWorld.ts
--- a/frontend/src/game/ops/OpsWorld.ts
+++ b/frontend/src/game/ops/OpsWorld.ts
@@ -350,9 +350,15 @@
}
private handleEvents(events: CombatEvent[]): void {
- const startIndex = this.latestEventId
- ? events.findIndex((event) => event.id === this.latestEventId) + 1
- : 0;
+ if (events.length === 0) return;
+ const foundIndex = this.latestEventId
+ ? events.findIndex((event) => event.id === this.latestEventId)
+ : -1;
+ if (this.latestEventId && foundIndex === -1) {
+ this.latestEventId = events.at(-1)?.id ?? this.latestEventId;
+ return;
+ }
+ const startIndex = foundIndex + 1;
const nextEvents = startIndex > 0 ? events.slice(startIndex) : events;
nextEvents.forEach((event) => this.renderEvent(event));
this.latestEventId = events.at(-1)?.id ?? this.latestEventId;You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit db0ab81. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: db0ab81856
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const targetCrewId = useCombatIntentStore.getState().targetCrewId; | ||
| const targetBlockId = crew?.targetBlock?.placeId ?? null; | ||
| if (targetCrewId && targetBlockId && stats.kills > 0) { | ||
| useGhostStore.getState().recordPlayerAttack(targetCrewId, targetBlockId); |
There was a problem hiding this comment.
Preserve the ghost target through drive-by completion
For every Maps-originated drive-by, CarCrewSelector calls reset() immediately after onConfirm, and that reset clears targetCrewId. By the time handleGameEnd reads this field it is therefore always null, so successful kills on ghost turf never invoke recordPlayerAttack and the advertised grudge/retaliation path cannot run. Carry the crew ID into the launched CarCrew or defer clearing it until the result is processed.
Useful? React with 👍 / 👎.
| 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')); |
There was a problem hiding this comment.
Add touch controls before exposing OPS on mobile
On touch-only iOS devices, OPS has no way to produce movement because movement() is driven exclusively by keyboard codes; reload, extract, and retreat are likewise keyboard-only, while the rendered mobile layout exposes only pause and camera buttons. Consequently a player can enter this full-screen encounter but cannot reach the exit or complete it normally without a hardware keyboard. Add touch movement/action controls or hide the mode on unsupported input devices.
AGENTS.md reference: AGENTS.md:L9-L9
Useful? React with 👍 / 👎.
| if (action.type === 'claim' && action.claimedDnaId) { | ||
| const dna = getDNAById(action.claimedDnaId); | ||
| if (dna) { | ||
| blockStore.upsertBlock(buildGhostBlock(updated, dna)); | ||
| ctx.ghostOwnedBlockIds.add(`ghost-${dna.id}`); |
There was a problem hiding this comment.
Apply ghost attacks to the targeted player block
Whenever decideGhostAction returns attack, this loop only updates the attacking crew and emits feed/notification entries; the sole blockStore mutation is guarded by action.type === 'claim'. The target remains player-owned with unchanged morale, placements, and income, so rival attacks can never damage or capture held turf despite announcing that a crew is moving on it. Resolve the attack against action.targetBlockId or create a pending encounter instead of treating it as notification-only.
Useful? React with 👍 / 👎.
| // 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, |
There was a problem hiding this comment.
Enforce the stamped Block DNA deployment cap
Claimed blocks now store resolved.maxMembers as a deployment cap, but neither blockStore.placeMember nor either placement UI reads block.maxMembers; they continue accepting distinct members until the grid or global roster is exhausted. Thus a starter block capped at five can host six or more members, erasing the intended DNA balance difference. Reject new placements once the block reaches its cap while still allowing an already-deployed member to move.
Useful? React with 👍 / 👎.
| const latest = feed.find( | ||
| (e) => BANNER_ACTIONS.has(e.action) && e.id !== dismissedId, | ||
| ); |
There was a problem hiding this comment.
Dismiss the banner instead of revealing older events
When the feed contains multiple claim or attack events—which a single multi-crew tick can produce—dismissing the current banner merely excludes that one ID, causing find() to immediately select and display the next older event. Users must click through every retained historical event, and old banners return after remount because the dismissal is local state. Track an acknowledged cutoff/set or hide the banner until a newer event arrives.
Useful? React with 👍 / 👎.
| dnaId: resolved.dna.id, | ||
| incomeMultiplier: resolved.incomeMultiplier, | ||
| heatDecayMultiplier: resolved.dna.heatDecayMultiplier, | ||
| maxMembers: resolved.maxMembers, |
There was a problem hiding this comment.
Persist the Block DNA fields with claimed blocks
In authenticated play, these DNA values survive only in the browser's local Zustand snapshot: the Supabase persistence metadata and Flask hydration mapper omit dnaId, incomeMultiplier, heatDecayMultiplier, and maxMembers. After clearing local storage or signing in on another device, the claimed block is hydrated with all four undefined, so subsequent placements fall back to a 1× income multiplier and the block loses its authored identity and modifiers. Persist these fields remotely or deterministically reconstruct them before inserting hydrated blocks.
Useful? React with 👍 / 👎.
| {result && ( | ||
| <div className="ops-result" role="dialog" aria-modal="true" aria-label="Modern Ops result"> | ||
| <span className={`ops-result__badge outcome-${result.outcome}`}>{result.outcome.toUpperCase()}</span> | ||
| <h3>{result.summary}</h3> | ||
| <p>{result.oppositionDown.length} opposition down · {result.crewDown.length} crew injured · heat {result.heatDelta >= 0 ? '+' : ''}{result.heatDelta}</p> | ||
| <button type="button" onClick={commitResult}>Apply result to the block</button> |
There was a problem hiding this comment.
Release pointer lock when the OPS result resolves
If the player has fired with the mouse in FPS/TPS mode, the canvas owns pointer lock when the result dialog appears. Pointer-lock mouse events remain directed to the canvas, but applying the result is available only through the dialog button and there is no keyboard equivalent, so the button cannot be clicked until the player independently knows to press Escape. Exit pointer lock when state.snapshot.phase becomes resolved, or provide a keyboard action for commitResult.
Useful? React with 👍 / 👎.
| if (cancelled) { | ||
| sceneHandle.dispose(); | ||
| engine.dispose(); |
There was a problem hiding this comment.
Ignore errors from cancelled OPS initialization
Because the app is wrapped in React.StrictMode, the first effect setup can be cleaned up while createModernOpsScene() is still awaiting readiness; that cleanup sets the captured engine to null. When the await resumes, the cancelled branch calls engine.dispose() unconditionally, throws, and the catch invokes onError, so the stale first initialization can place an error overlay over the healthy second StrictMode initialization. Keep a non-null local engine reference and suppress error reporting after cancellation.
Useful? React with 👍 / 👎.


Summary
This branch recovers the completed but unmerged
copilot/dev-oplanwork, adds a repository-specific completion roadmap and reusable skill, and applies that workflow to a playable Babylon.js Modern Ops vertical slice. The existing strategy layer can now launch one deterministic encounter in tactical, first-person, or third-person presentation while preserving the same combat session and applying one atomic result back to the block economy, heat, morale, crew injuries, and hospital/bail flow.Included work
docs/MODERN_GAMEPLAY_COMPLETION_PLAN.mdandskills/dealt-slide-game-completion/.CombatSessionControlleras the presentation-neutral action adapter over the existing deterministic combat model.OPS 3Dinto the claimed-block Strip view and returns results through the existingapplyEncounterResultboundary.ASSETS.md.Verification
Scope and issue relationship
This PR advances #45, #77, #78, #79, #80, and #81. It does not auto-close them because the repository still needs full asset replacement, a larger 30–40 Block DNA catalog, broader Ghost Crew balancing/persistence hardening, production character/vehicle art, mobile/touch action controls, performance budgets on real devices, and final beta/release evidence.
Review order
docs/MODERN_GAMEPLAY_COMPLETION_PLAN.mdskills/dealt-slide-game-completion/SKILL.mdfrontend/src/game/combat/CombatSessionController.tsfrontend/src/game/ops/frontend/src/components/ops/frontend/src/components/map/BlockModeView.tsxfrontend/vite.config.tsNotes
The project’s default branch is
main-tL2525. The oldercopilot/dev-oplanbranch is now superseded by this branch because its six unique commits were recovered and reconciled here.Note
Medium Risk
Touches the encounter result boundary, economy/heat application, and adds a large lazy 3D stack, but keeps authoritative combat in the existing session with new tests and CI asset gates.
Overview
Adds a lazy-loaded Babylon.js “OPS 3D” encounter from block view: tactical, first-person, and third-person cameras share one
CombatSessionControllerover the existing deterministicCombatSession, with HUD, input, procedural grid-to-world scene, and the sameapplyEncounterResulthandoff as the Phaser encounter.Also lands recovered beta work: Block DNA library 8 → 17 with claim-time stamping and DNA-scaled income; persistent Ghost Crew world ticks, map NPC turf, grudge on drive-by/attack, and
GhostThreatBanner; runtime assets pointed at WebP +runtimeManifest, new Las Olas / Modern Ops art entries,npm run assets:auditin CI, beta smoke tests, and release checklist docs.Combat pacing changes: extraction away from crew spawn, slower/weaker opposition turns, plus controller and coordinate tests. Build:
@babylonjs/coreas a dedicated lazy vendor chunk (per existing Vite manual-chunk strategy).Reviewed by Cursor Bugbot for commit e458cba. Bugbot is set up for automated code reviews on this repo. Configure here.