Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/PROJECT_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/components/map/GhostThreatBanner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<GhostThreatBanner />);

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,
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/components/map/GhostThreatBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ const dismissBtnStyle: React.CSSProperties = {
/** Events that warrant a player-facing banner. */
const BANNER_ACTIONS = new Set<GhostFeedEvent['action']>(['attack', 'claim']);

const MIN_BANNER_INSET_PX = 88;

const GhostThreatBanner: React.FC = () => {
const feed = useGhostStore(selectGhostFeed);
const answered = useGhostStore((state) => state.appliedResponseKeys);
Expand Down Expand Up @@ -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<HTMLDivElement>(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 (
<AnimatePresence>
{latest && (
<motion.div
ref={bannerRef}
key={latest.id}
initial={{ y: -80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
Expand Down
18 changes: 14 additions & 4 deletions frontend/src/components/missions/AttackPlanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
};

Expand Down Expand Up @@ -81,9 +91,9 @@ export const AttackPlanner: React.FC = () => {
</div>

<button
className={`launch-btn ${!selectedTarget ? 'disabled' : ''}`}
className={`launch-btn ${canLaunch ? '' : 'disabled'}`}
onClick={handleLaunch}
disabled={!selectedTarget}
disabled={!canLaunch}
>
LAUNCH ATTACK
</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -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, 'id' | 'name' | 'role'>): 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(<AttackPlanner />);

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(<AttackPlanner />);
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');
});
});
17 changes: 13 additions & 4 deletions frontend/src/components/topdown/TopDownShooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───

Expand Down
Original file line number Diff line number Diff line change
@@ -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, 'id' | 'name' | 'role'>): 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(<TopDownShooter />);

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(<TopDownShooter />);
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();
});
});
6 changes: 5 additions & 1 deletion frontend/src/components/tutorial/TutorialOverlay.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down
Loading
Loading