diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md index 67ea7ed..2fa8a6e 100644 --- a/docs/PROJECT_LOG.md +++ b/docs/PROJECT_LOG.md @@ -69,6 +69,25 @@ 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. + +### 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. +- 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/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 && ( { }); 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(); + }); +}); diff --git a/frontend/src/components/tutorial/TutorialOverlay.css b/frontend/src/components/tutorial/TutorialOverlay.css index 068c1f2..398be97 100644 --- a/frontend/src/components/tutorial/TutorialOverlay.css +++ b/frontend/src/components/tutorial/TutorialOverlay.css @@ -42,7 +42,8 @@ /* ── Next step hint ── */ .tutorial-hint { position: fixed; - bottom: 0; + /* Dock is fixed about 84px up from the bottom; keep the hint above it. */ + bottom: calc(92px + env(safe-area-inset-bottom, 0px)); left: 0; right: 0; background: rgba(10,10,15,0.95); @@ -52,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 f3353ec..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(() => { @@ -35,4 +40,137 @@ describe('TutorialOverlay', () => { 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'); + }); + + 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; +} 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 && ( { + 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..1670827 100644 --- a/frontend/src/utils/demoSeed.ts +++ b/frontend/src/utils/demoSeed.ts @@ -93,11 +93,10 @@ 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 - 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 @@ -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 = {