From 370b425a4e99ba21f549f43c2a7949cbaf766430 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 30 Sep 2026 20:27:57 +0000 Subject: [PATCH 1/5] fix: require a driver and shooter before Block Attack launch OPS PLAN enabled Launch Attack from a target alone, and the car treated any passenger as a shooter. Both gates now stay closed until a driver and a member who can fire are assigned. Co-authored-by: BrandDead --- docs/PROJECT_LOG.md | 5 ++ .../src/components/missions/AttackPlanner.tsx | 18 +++- .../__tests__/AttackPlanner.launch.test.tsx | 82 +++++++++++++++++ .../src/components/topdown/TopDownShooter.tsx | 17 +++- .../__tests__/TopDownShooter.launch.test.tsx | 88 +++++++++++++++++++ 5 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/missions/__tests__/AttackPlanner.launch.test.tsx create mode 100644 frontend/src/components/topdown/__tests__/TopDownShooter.launch.test.tsx diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index 67ea7ed..015efd6 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,11 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN ## Log +### 2026-09-30 — Block Attack launch requires a driver and a shooter (#193) + +- OPS PLAN no longer opens Block Attack from a target alone. Launch stays disabled until the driver slot and a different shooter (or enforcer) are assigned, and the handler ignores a click that races the disabled state. The car screen still owns the seats: an empty car stays disabled, and a lookout or dealer in a passenger seat does not count as the shooter. +- No faction-name rewrite, no economy or combat-math change, and no new encounter route. Issue #192's canonical-route choice and issue #175 stay open. + ### 2026-09-30 — Independent beta gameplay and visual critic skill (#45) - Added a portable repo-scoped `slide-game-critic` Agent Skill for Cursor and other compatible agents. It requires a separate reviewer, actual revision-bound play captures at phone and desktop sizes, reproducible frame samples, a player-action trace, and explicit NOT PLAYED when no build is accessible. diff --git a/frontend/src/components/missions/AttackPlanner.tsx b/frontend/src/components/missions/AttackPlanner.tsx index c4ca3bb..80f41b8 100644 --- a/frontend/src/components/missions/AttackPlanner.tsx +++ b/frontend/src/components/missions/AttackPlanner.tsx @@ -18,10 +18,20 @@ export const AttackPlanner: React.FC = () => { }); const availableMembers = members.filter(m => m.status === 'active'); + const memberFor = (id: string) => availableMembers.find((member) => member.id === id); + const driverMember = memberFor(assignedRoles.driver); + // The driver seat does not shoot. A second, different member in the + // scout or enforcer slot has to be someone who can fire. + const shooterMember = (['scout', 'enforcer'] as const) + .map((slot) => memberFor(assignedRoles[slot])) + .find((member) => member + && member.id !== driverMember?.id + && (member.role === 'shooter' || member.role === 'enforcer' || member.role === 'boss')); + const canLaunch = Boolean(selectedTarget && driverMember && shooterMember); const handleLaunch = () => { - if (!selectedTarget) return; - // Launch into TopDownShooter with intel + if (!canLaunch) return; + // Existing route: the car screen still owns seat assignment. navigateTo('topdown'); }; @@ -81,9 +91,9 @@ export const AttackPlanner: React.FC = () => { diff --git a/frontend/src/components/missions/__tests__/AttackPlanner.launch.test.tsx b/frontend/src/components/missions/__tests__/AttackPlanner.launch.test.tsx new file mode 100644 index 0000000..af00a91 --- /dev/null +++ b/frontend/src/components/missions/__tests__/AttackPlanner.launch.test.tsx @@ -0,0 +1,82 @@ +/** + * GG-002: OPS PLAN must not launch a Block Attack until a driver and a + * shooter are assigned. Target-only launch is the empty-seat path. + */ +import React from 'react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import AttackPlanner from '../AttackPlanner'; +import { useGangStore, useNavigationStore } from '../../../stores/gameStore'; +import type { GangMember } from '../../../types/game.types'; + +function crewMember(partial: Pick): GangMember { + return { + gangId: 'demo-gang', + nickname: partial.name, + avatarUrl: '', + backstory: '', + age: 24, + region: 'miami', + stats: { strength: 50, agility: 50, intelligence: 50, charisma: 50, luck: 50, intimidation: 50 }, + level: 2, + experience: 0, + skillPoints: 0, + skills: [], + loyalty: 80, + morale: 80, + respect: 40, + kills: 0, + arrests: 0, + dealsCompleted: 0, + moneyEarned: 0, + status: 'active', + currentAssignment: null, + joinedAt: '2026-01-01T00:00:00.000Z', + health: 100, + maxHealth: 100, + ...partial, + }; +} + +const dealer = crewMember({ id: 'demo-dealer-1', name: 'Lil Dre', role: 'dealer' }); +const shooter = crewMember({ id: 'demo-shooter-1', name: 'Big Rome', role: 'shooter' }); + +describe('AttackPlanner launch seats', () => { + beforeEach(() => { + useNavigationStore.setState({ + currentApp: 'planner', + previousApp: 'home', + appStack: ['home', 'planner'], + isTransitioning: false, + }); + useGangStore.setState({ members: [dealer, shooter], contacts: [] }); + }); + + it('keeps Launch Attack disabled when a target is chosen and the driver and shooter are empty', () => { + render(); + + fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'dummy-target' } }); + + const launch = screen.getByRole('button', { name: /launch attack/i }); + expect(launch).toBeDisabled(); + + fireEvent.click(launch); + expect(useNavigationStore.getState().currentApp).toBe('planner'); + }); + + it('launches only after a driver and a different shooter are assigned', () => { + render(); + const [target, , enforcer, driver] = screen.getAllByRole('combobox'); + + fireEvent.change(target, { target: { value: 'dummy-target' } }); + fireEvent.change(driver, { target: { value: dealer.id } }); + expect(screen.getByRole('button', { name: /launch attack/i })).toBeDisabled(); + + fireEvent.change(enforcer, { target: { value: shooter.id } }); + const launch = screen.getByRole('button', { name: /launch attack/i }); + expect(launch).toBeEnabled(); + + fireEvent.click(launch); + expect(useNavigationStore.getState().currentApp).toBe('topdown'); + }); +}); diff --git a/frontend/src/components/topdown/TopDownShooter.tsx b/frontend/src/components/topdown/TopDownShooter.tsx index 4d24adf..f020db9 100644 --- a/frontend/src/components/topdown/TopDownShooter.tsx +++ b/frontend/src/components/topdown/TopDownShooter.tsx @@ -294,10 +294,19 @@ const TopDownShooter: React.FC = () => { ); const canLaunch = useMemo(() => { - const hasDriver = seats.find(s => s.seat === 'driver')?.memberId !== null; - const hasShooter = seats.some(s => s.seat !== 'driver' && s.memberId !== null); - return hasDriver && hasShooter && targetBlock !== null; - }, [seats, targetBlock]); + const driverId = seats.find(s => s.seat === 'driver')?.memberId; + const driver = driverId + ? members.find(m => m.id === driverId && m.status === 'active') + : undefined; + // Driver drives only. A lookout or dealer in a passenger seat does not + // count as the shooter the launch line asks for. + const hasShooter = seats.some(s => { + if (s.seat === 'driver' || !s.memberId || s.memberId === driver?.id) return false; + const rider = members.find(m => m.id === s.memberId && m.status === 'active'); + return rider?.role === 'shooter' || rider?.role === 'enforcer' || rider?.role === 'boss'; + }); + return Boolean(driver && hasShooter && targetBlock); + }, [seats, members, targetBlock]); // ─── SETUP HANDLERS ─── diff --git a/frontend/src/components/topdown/__tests__/TopDownShooter.launch.test.tsx b/frontend/src/components/topdown/__tests__/TopDownShooter.launch.test.tsx new file mode 100644 index 0000000..463cdb2 --- /dev/null +++ b/frontend/src/components/topdown/__tests__/TopDownShooter.launch.test.tsx @@ -0,0 +1,88 @@ +/** + * GG-002: Block Attack must not leave setup until a driver seat and a + * shooter seat are filled. An empty car, or a passenger who cannot shoot, + * is not a launch. + */ +import React from 'react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import TopDownShooter from '../TopDownShooter'; +import { useGangStore, useNavigationStore } from '../../../stores/gameStore'; +import type { GangMember } from '../../../types/game.types'; + +function crewMember(partial: Pick): GangMember { + return { + gangId: 'demo-gang', + nickname: partial.name, + avatarUrl: '', + backstory: '', + age: 24, + region: 'miami', + stats: { strength: 50, agility: 50, intelligence: 50, charisma: 50, luck: 50, intimidation: 50 }, + level: 2, + experience: 0, + skillPoints: 0, + skills: [], + loyalty: 80, + morale: 80, + respect: 40, + kills: 0, + arrests: 0, + dealsCompleted: 0, + moneyEarned: 0, + status: 'active', + currentAssignment: null, + joinedAt: '2026-01-01T00:00:00.000Z', + health: 100, + maxHealth: 100, + ...partial, + }; +} + +const dealer = crewMember({ id: 'demo-dealer-1', name: 'Lil Dre', role: 'dealer' }); +const lookout = crewMember({ id: 'demo-lookout-1', name: 'Tasha', role: 'lookout' }); +const shooter = crewMember({ id: 'demo-shooter-1', name: 'Big Rome', role: 'shooter' }); + +function launchButton() { + return screen.getByRole('button', { name: /launch attack/i }); +} + +describe('TopDownShooter empty seats', () => { + beforeEach(() => { + useNavigationStore.setState({ + currentApp: 'topdown', + previousApp: 'planner', + appStack: ['home', 'planner', 'topdown'], + isTransitioning: false, + }); + useGangStore.setState({ members: [dealer, lookout, shooter], contacts: [] }); + }); + + it('keeps Launch Attack disabled while the driver and shooter seats are empty', () => { + render(); + + expect(screen.getByText(/driver drives only/i)).toBeInTheDocument(); + expect(launchButton()).toBeDisabled(); + + fireEvent.click(screen.getByText('5th & Main')); + expect(launchButton()).toBeDisabled(); + fireEvent.click(launchButton()); + expect(screen.getByText(/load up the car/i)).toBeInTheDocument(); + }); + + it('stays disabled when the passenger cannot shoot, then enables for a shooter', () => { + render(); + fireEvent.click(screen.getByText('5th & Main')); + + fireEvent.click(screen.getByText('🏎️ Driver')); + fireEvent.click(screen.getByText('Lil Dre')); + + fireEvent.click(screen.getByText('🔫 Front')); + fireEvent.click(screen.getByText('Tasha')); + expect(launchButton()).toBeDisabled(); + + fireEvent.click(screen.getByText('🔫 Back L')); + fireEvent.click(screen.getByText('Big Rome')); + expect(launchButton()).toBeEnabled(); + }); +}); From 7af09985a579615420897bbd0476ad4efcf3192f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 30 Sep 2026 20:29:10 +0000 Subject: [PATCH 2/5] fix: inset the phone page under the banner and above the dock The fixed Nightfall banner covered the header, and the welcome hint sat on the dock. The page now starts below the banner, and the hint clears the dock. Co-authored-by: BrandDead --- docs/PROJECT_LOG.md | 5 ++++ frontend/src/App.css | 4 +-- .../components/map/GhostThreatBanner.test.tsx | 13 +++++++++ .../src/components/map/GhostThreatBanner.tsx | 28 +++++++++++++++++++ .../components/tutorial/TutorialOverlay.css | 3 +- .../tutorial/TutorialOverlay.test.tsx | 10 +++++++ .../components/tutorial/TutorialOverlay.tsx | 1 + 7 files changed, 61 insertions(+), 3 deletions(-) diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index 015efd6..8a983a8 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,11 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN ## Log +### 2026-09-30 — Phone banner and welcome hint stop covering the header and dock (#193) + +- The Nightfall banner publishes its height as `--slide-banner-inset` (at least 88px). The page starts below that inset instead of sliding under the fixed alert. The welcome hint sits 92px above the bottom, clear of the dock, including the safe area. +- Copy, colors, and the dismiss behavior are unchanged. + ### 2026-09-30 — Block Attack launch requires a driver and a shooter (#193) - OPS PLAN no longer opens Block Attack from a target alone. Launch stays disabled until the driver slot and a different shooter (or enforcer) are assigned, and the handler ignores a click that races the disabled state. The car screen still owns the seats: an empty car stays disabled, and a lookout or dealer in a passenger seat does not count as the shooter. diff --git a/frontend/src/App.css b/frontend/src/App.css index a3ac2cf..d3e46b1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -45,9 +45,9 @@ body { .page-container { width: 100%; - height: 100%; + height: calc(100% - var(--slide-banner-inset, 0px)); position: absolute; - top: 0; + top: var(--slide-banner-inset, 0px); left: 0; overflow: auto; -webkit-overflow-scrolling: touch; diff --git a/frontend/src/components/map/GhostThreatBanner.test.tsx b/frontend/src/components/map/GhostThreatBanner.test.tsx index 5b07a2a..123b769 100644 --- a/frontend/src/components/map/GhostThreatBanner.test.tsx +++ b/frontend/src/components/map/GhostThreatBanner.test.tsx @@ -57,6 +57,19 @@ describe('GhostThreatBanner', () => { expect(screen.getByRole('alert')).toHaveTextContent('Nightfall will remember your hit.'); }); + it('reserves space under the banner so the header is not covered, then clears it', async () => { + useGhostStore.setState({ feed: [stripAttack] }); + render(); + + const inset = document.documentElement.style.getPropertyValue('--slide-banner-inset'); + expect(Number.parseInt(inset, 10)).toBeGreaterThanOrEqual(88); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss rival activity' })); + await waitFor(() => { + expect(document.documentElement.style.getPropertyValue('--slide-banner-inset')).toBe(''); + }); + }); + it('clears every open threat on one dismiss and only shows newer ones after', async () => { const olderClaim: GhostFeedEvent = { ...stripAttack, diff --git a/frontend/src/components/map/GhostThreatBanner.tsx b/frontend/src/components/map/GhostThreatBanner.tsx index efc3de7..3368d48 100644 --- a/frontend/src/components/map/GhostThreatBanner.tsx +++ b/frontend/src/components/map/GhostThreatBanner.tsx @@ -72,6 +72,8 @@ const dismissBtnStyle: React.CSSProperties = { /** Events that warrant a player-facing banner. */ const BANNER_ACTIONS = new Set(['attack', 'claim']); +const MIN_BANNER_INSET_PX = 88; + const GhostThreatBanner: React.FC = () => { const feed = useGhostStore(selectGhostFeed); const answered = useGhostStore((state) => state.appliedResponseKeys); @@ -101,11 +103,37 @@ const GhostThreatBanner: React.FC = () => { return next; }); const onStrip = latest?.action === 'attack' && latest.targetBlockId === BLOCK_LOOP_IDS.blockId; + const bannerRef = React.useRef(null); + + // The banner is fixed, and the page underneath is absolutely positioned, + // so a flow layout cannot push the header down. Publish the measured + // height (at least one phone row) for `.page-container` to inset. + React.useLayoutEffect(() => { + const root = document.documentElement; + if (!latest) { + root.style.removeProperty('--slide-banner-inset'); + return; + } + const publish = () => { + const measured = bannerRef.current?.getBoundingClientRect().height ?? 0; + const inset = Math.max(Math.ceil(measured), MIN_BANNER_INSET_PX); + root.style.setProperty('--slide-banner-inset', `${inset}px`); + }; + publish(); + const node = bannerRef.current; + const observer = node && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(publish) : null; + observer?.observe(node as HTMLDivElement); + return () => { + observer?.disconnect(); + root.style.removeProperty('--slide-banner-inset'); + }; + }, [latest]); return ( {latest && ( { expect(screen.getByText('Welcome to SLIDE')).toBeInTheDocument(); }); + + it('sits the welcome hint above the phone dock', () => { + render(); + + const hint = screen.getByText('Welcome to SLIDE').closest('.tutorial-hint'); + expect(hint).toBeInstanceOf(HTMLElement); + const bottom = (hint as HTMLElement).style.bottom; + expect(bottom).toContain('92px'); + expect(bottom).toContain('safe-area-inset-bottom'); + }); }); diff --git a/frontend/src/components/tutorial/TutorialOverlay.tsx b/frontend/src/components/tutorial/TutorialOverlay.tsx index 6a4f204..725937c 100644 --- a/frontend/src/components/tutorial/TutorialOverlay.tsx +++ b/frontend/src/components/tutorial/TutorialOverlay.tsx @@ -56,6 +56,7 @@ const TutorialOverlay: React.FC = ({ hidden = false }) => {!hidden && !showTutorialOverlay && nextStep && ( Date: Wed, 30 Sep 2026 20:30:25 +0000 Subject: [PATCH 3/5] fix: dedupe demo contacts when the seed runs again removeMember left the contact card in place, and addMember appended another with the same id. The demo seed now clears those cards first so each crew id mounts once. Co-authored-by: BrandDead --- docs/PROJECT_LOG.md | 4 ++ .../utils/__tests__/demoSeedContacts.test.ts | 50 +++++++++++++++++++ frontend/src/utils/demoSeed.ts | 17 ++++--- 3 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 frontend/src/utils/__tests__/demoSeedContacts.test.ts diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index 8a983a8..9553569 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,10 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN ## Log +### 2026-09-30 — Demo reseed keeps one contact per crew id (#193) + +- `applyDemoSeed` drops the demo crew's contact cards before `removeMember` / `addMember`. A second load no longer appends another `demo-dealer-1` (or the shooter, lookout, and enforcer ids). Contacts that are not part of the demo seed stay on the roster. + ### 2026-09-30 — Phone banner and welcome hint stop covering the header and dock (#193) - The Nightfall banner publishes its height as `--slide-banner-inset` (at least 88px). The page starts below that inset instead of sliding under the fixed alert. The welcome hint sits 92px above the bottom, clear of the dock, including the safe area. diff --git a/frontend/src/utils/__tests__/demoSeedContacts.test.ts b/frontend/src/utils/__tests__/demoSeedContacts.test.ts new file mode 100644 index 0000000..d4ad03e --- /dev/null +++ b/frontend/src/utils/__tests__/demoSeedContacts.test.ts @@ -0,0 +1,50 @@ +/** + * GG-003: reseeding the demo crew must not leave two contact cards + * with the same id. removeMember drops the member and addMember + * appends another contact. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { applyDemoSeed } from '../demoSeed'; +import { useGangStore } from '../../stores/gameStore'; +import { BLOCK_LOOP_IDS } from '../../game/loop/blockLoopTypes'; + +const DEMO_IDS = [ + BLOCK_LOOP_IDS.dealerId, + BLOCK_LOOP_IDS.shooterId, + BLOCK_LOOP_IDS.lookoutId, + BLOCK_LOOP_IDS.enforcerId, +]; + +describe('demo contact ids', () => { + beforeEach(() => { + useGangStore.setState({ members: [], contacts: [] }); + }); + + it('keeps one contact per demo id when the seed runs twice', () => { + applyDemoSeed(); + applyDemoSeed(); + + const contacts = useGangStore.getState().contacts; + const ids = contacts.map((contact) => contact.id); + expect(new Set(ids).size).toBe(ids.length); + for (const id of DEMO_IDS) { + expect(contacts.filter((contact) => contact.id === id)).toHaveLength(1); + } + expect(useGangStore.getState().members.filter((member) => member.id === BLOCK_LOOP_IDS.dealerId)).toHaveLength(1); + }); + + it('keeps a non-demo contact when the demo crew is reseeded', () => { + applyDemoSeed(); + useGangStore.getState().addContact({ + id: 'custom-jay', + name: 'Jay', + role: 'dealer', + status: 'active', + }); + applyDemoSeed(); + + const contacts = useGangStore.getState().contacts; + expect(contacts.filter((contact) => contact.id === 'custom-jay')).toHaveLength(1); + expect(new Set(contacts.map((contact) => contact.id)).size).toBe(contacts.length); + }); +}); diff --git a/frontend/src/utils/demoSeed.ts b/frontend/src/utils/demoSeed.ts index 642c14f..3faf92f 100644 --- a/frontend/src/utils/demoSeed.ts +++ b/frontend/src/utils/demoSeed.ts @@ -93,7 +93,6 @@ export function applyDemoSeed(): void { // a placement that references a nonexistent member. const blockStore = useBlockStore.getState(); const playerStore = usePlayerStore.getState(); - const gangStore = useGangStore.getState(); // The demo block is always overwritten by upsertBlock below. // Remove stale demo members and re-upsert them @@ -247,14 +246,20 @@ export function applyDemoSeed(): void { ]; // Rebuild the roster: remove any stale demo members then add fresh ones. - // We use the store's own removeMember/addMember actions so middleware - // (devtools, persist) stays consistent. + // removeMember leaves the contact card behind, and addMember always + // appends one, so a second seed used to mount two cards per demo id. + // Drop those contacts first. Other roster contacts stay. + useGangStore.setState((state) => ({ + contacts: state.contacts.filter((contact) => + !DEMO_IDS.has(contact.id) && !(contact.memberId && DEMO_IDS.has(contact.memberId)), + ), + })); DEMO_IDS.forEach((id) => { - if (gangStore.members.some((m) => m.id === id)) { - gangStore.removeMember(id); + if (useGangStore.getState().members.some((m) => m.id === id)) { + useGangStore.getState().removeMember(id); } }); - demoMembers.forEach((m) => gangStore.addMember(m)); + demoMembers.forEach((m) => useGangStore.getState().addMember(m)); // ── 4. Pre-claimed DNA board ──────────────────────────────── const dealerCard = { From 2e433666eccda6c89e99ff3220f6b2a151193312 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 30 Sep 2026 20:31:30 +0000 Subject: [PATCH 4/5] fix: type the demo id set as strings Contact ids are plain strings. The demo id set has to accept them so the reseed filter typechecks. Co-authored-by: BrandDead --- frontend/src/utils/demoSeed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/utils/demoSeed.ts b/frontend/src/utils/demoSeed.ts index 3faf92f..1670827 100644 --- a/frontend/src/utils/demoSeed.ts +++ b/frontend/src/utils/demoSeed.ts @@ -96,7 +96,7 @@ export function applyDemoSeed(): void { // The demo block is always overwritten by upsertBlock below. // Remove stale demo members and re-upsert them - const DEMO_IDS = new Set([DEMO_DEALER_ID, DEMO_SHOOTER_ID, DEMO_LOOKOUT_ID, DEMO_ENFORCER_ID]); + const DEMO_IDS = new Set([DEMO_DEALER_ID, DEMO_SHOOTER_ID, DEMO_LOOKOUT_ID, DEMO_ENFORCER_ID]); // ── 2. Player ───────────────────────────────────────────── // Preserve earned progress (level/XP) if the demo player has advanced beyond seed defaults From 3b947e73afef30d8bcee5b5911d1a1d9f569aa02 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 30 Sep 2026 20:54:37 +0000 Subject: [PATCH 5/5] fix: let phone taps pass through the welcome hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed tutorial hint covered the enabled Launch Attack control at 375×812 and won elementFromPoint(188, 679). pointer-events: none keeps the hint on screen without stealing that tap. Co-authored-by: BrandDead --- docs/PROJECT_LOG.md | 5 + .../components/tutorial/TutorialOverlay.css | 3 + .../tutorial/TutorialOverlay.test.tsx | 130 +++++++++++++++++- 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index 9553569..2fa8a6e 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,11 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN ## Log +### 2026-09-30 — Welcome hint no longer steals the phone Launch Attack tap (#193) + +- At 375×812 the fixed welcome hint sits on top of the enabled OPS PLAN Launch Attack control. The hint stays visible so it can still teach the next step, and `pointer-events: none` lets the tap pass through to the button. +- A DOM regression uses the founder coordinates (hint y674–720, tap 188×679) and the real overlay stylesheet. No second launch writer, and no change to gang labels, economy, encounters, or Supabase. + ### 2026-09-30 — Demo reseed keeps one contact per crew id (#193) - `applyDemoSeed` drops the demo crew's contact cards before `removeMember` / `addMember`. A second load no longer appends another `demo-dealer-1` (or the shooter, lookout, and enforcer ids). Contacts that are not part of the demo seed stay on the roster. diff --git a/frontend/src/components/tutorial/TutorialOverlay.css b/frontend/src/components/tutorial/TutorialOverlay.css index 38a6970..398be97 100644 --- a/frontend/src/components/tutorial/TutorialOverlay.css +++ b/frontend/src/components/tutorial/TutorialOverlay.css @@ -53,6 +53,9 @@ align-items: center; gap: 10px; z-index: 50; + /* The hint teaches the next step. It must not steal taps when a control + scrolls underneath it (Launch Attack at 375×812). */ + pointer-events: none; font-family: 'SF Mono', 'Fira Code', monospace; } diff --git a/frontend/src/components/tutorial/TutorialOverlay.test.tsx b/frontend/src/components/tutorial/TutorialOverlay.test.tsx index 27a1243..9a92b57 100644 --- a/frontend/src/components/tutorial/TutorialOverlay.test.tsx +++ b/frontend/src/components/tutorial/TutorialOverlay.test.tsx @@ -1,8 +1,13 @@ import React from 'react'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { beforeEach, describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import TutorialOverlay from './TutorialOverlay'; +import AttackPlanner from '../missions/AttackPlanner'; import { useTutorialProgressStore } from '../../stores/tutorialProgressStore'; +import { useGangStore, useNavigationStore } from '../../stores/gameStore'; +import type { GangMember } from '../../types/game.types'; describe('TutorialOverlay', () => { beforeEach(() => { @@ -45,4 +50,127 @@ describe('TutorialOverlay', () => { expect(bottom).toContain('92px'); expect(bottom).toContain('safe-area-inset-bottom'); }); + + it('lets an enabled Launch Attack tap pass through the phone hint', () => { + const css = readFileSync(resolve(__dirname, 'TutorialOverlay.css'), 'utf8'); + const style = document.createElement('style'); + style.textContent = css; + document.head.appendChild(style); + + useNavigationStore.setState({ + currentApp: 'planner', + previousApp: 'home', + appStack: ['home', 'planner'], + isTransitioning: false, + }); + useGangStore.setState({ + members: [ + crewMember({ id: 'demo-dealer-1', name: 'Lil Dre', role: 'dealer' }), + crewMember({ id: 'demo-shooter-1', name: 'Big Rome', role: 'shooter' }), + ], + contacts: [], + }); + + render( + <> + + + , + ); + + const [target, , enforcer, driver] = screen.getAllByRole('combobox'); + fireEvent.change(target, { target: { value: 'dummy-target' } }); + fireEvent.change(driver, { target: { value: 'demo-dealer-1' } }); + fireEvent.change(enforcer, { target: { value: 'demo-shooter-1' } }); + + const launch = screen.getByRole('button', { name: /launch attack/i }); + expect(launch).toBeEnabled(); + + const hint = screen.getByText('Welcome to SLIDE').closest('.tutorial-hint'); + expect(hint).toBeInstanceOf(HTMLElement); + + // jsdom has no layout engine. These boxes are the 375×812 measurements + // from the founder review: hint y674–720, Launch Attack center (188, 679). + const boxes = new Map([ + [hint as HTMLElement, { left: 0, top: 674, right: 375, bottom: 720 }], + [launch, { left: 16, top: 654, right: 359, bottom: 704 }], + ]); + + expect(phoneHitTarget(188, 679, boxes)).toBe(launch); + expect(phoneHitTarget(188, 679, boxes)?.classList.contains('launch-btn')).toBe(true); + }); }); + +function crewMember(partial: Pick): GangMember { + return { + gangId: 'demo-gang', + nickname: partial.name, + avatarUrl: '', + backstory: '', + age: 24, + region: 'miami', + stats: { strength: 50, agility: 50, intelligence: 50, charisma: 50, luck: 50, intimidation: 50 }, + level: 2, + experience: 0, + skillPoints: 0, + skills: [], + loyalty: 80, + morale: 80, + respect: 40, + kills: 0, + arrests: 0, + dealsCompleted: 0, + moneyEarned: 0, + status: 'active', + currentAssignment: null, + joinedAt: '2026-01-01T00:00:00.000Z', + health: 100, + maxHealth: 100, + ...partial, + }; +} + +type PhoneBox = { left: number; top: number; right: number; bottom: number }; + +/** + * Same rule Chromium uses for elementFromPoint: the highest stacking box + * that contains the point wins, unless pointer-events is none on that node + * or an ancestor. jsdom does not implement elementFromPoint. + */ +function phoneHitTarget( + x: number, + y: number, + boxes: Map, +): HTMLElement | null { + const hits = [...boxes.entries()].filter(([, box]) => ( + x >= box.left && x <= box.right && y >= box.top && y <= box.bottom + )); + hits.sort(([a], [b]) => stackingScore(b) - stackingScore(a) || domOrder(b, a)); + for (const [el] of hits) { + if (!pointerEventsBlocked(el)) return el; + } + return null; +} + +function stackingScore(el: HTMLElement): number { + const cs = getComputedStyle(el); + const positioned = cs.position !== 'static'; + const z = cs.zIndex === 'auto' ? 0 : Number.parseInt(cs.zIndex, 10); + return (positioned ? 1000 : 0) + (Number.isFinite(z) ? z : 0); +} + +function domOrder(a: HTMLElement, b: HTMLElement): number { + const pos = a.compareDocumentPosition(b); + if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1; + if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1; + return 0; +} + +function pointerEventsBlocked(el: HTMLElement): boolean { + let node: HTMLElement | null = el; + while (node) { + if (getComputedStyle(node).pointerEvents === 'none') return true; + node = node.parentElement; + } + return false; +}