diff --git a/frontend/package.json b/frontend/package.json index 4e73233..2752db6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -58,6 +58,6 @@ "tsx": "^4.7.0", "typescript": "^5.2.2", "vite": "^5.0.8", - "vitest": "^4.1.10" + "vitest": "^2.1.8" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 68c687b..079327d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,6 +50,7 @@ const Missions = React.lazy(() => import('./components/missions/Mission const Leaderboard = React.lazy(() => import('./components/hub/Leaderboard')); const TopDownShooter = React.lazy(() => import('./components/topdown/TopDownShooter')); const BipNDipGame = React.lazy(() => import('./components/topdown/BipNDipGame')); +const PoliceRaidGame = React.lazy(() => import('./components/raid/PoliceRaidGame')); const GangManagement = React.lazy(() => import('./components/gang/GangManagement')); const DealtModeSelector = React.lazy(() => import('./components/dealt-v2/DealtModeSelector')); const CocaineCrush = React.lazy(() => import('./components/cocaine-crush/CocaineCrush')); @@ -165,6 +166,7 @@ const App: React.FC = () => { case 'driveby': return }>; case 'topdown': return }>; case 'bipndip': return }>; + case 'raid': return }>; case 'gang_hq': return }>; case 'alchemy': return }>; case 'shoebox': return }>; diff --git a/frontend/src/components/contacts/BailHospitalPanel.css b/frontend/src/components/contacts/BailHospitalPanel.css new file mode 100644 index 0000000..d568e5b --- /dev/null +++ b/frontend/src/components/contacts/BailHospitalPanel.css @@ -0,0 +1,129 @@ +/* ============================================================ + SLIDE — Bail & Hospital panel (Sprint 14-B) + ============================================================ */ + +.bhp-root { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 4px; +} + +.bhp-summary { + display: flex; + gap: 10px; +} + +.bhp-summary > div { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); +} + +.bhp-summary-label { + font-size: 0.62rem; + letter-spacing: 0.12em; + color: #8b8f98; + text-transform: uppercase; +} + +.bhp-summary-value { + font-size: 1.05rem; + font-weight: 800; + font-variant-numeric: tabular-nums; +} + +.bhp-summary-value.short { color: #ff6b6b; } + +.bhp-warning { + margin: 0; + font-size: 0.72rem; + color: #f4a04c; + text-align: center; + line-height: 1.4; +} + +.bhp-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.bhp-row { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 12px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); +} + +.bhp-row.bleeding { + box-shadow: inset 3px 0 0 #ff6b6b; + background: rgba(239, 68, 68, 0.1); +} + +.bhp-icon { font-size: 1.15rem; } + +.bhp-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.bhp-name { + font-weight: 700; + font-size: 0.88rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bhp-status { + font-size: 0.66rem; + letter-spacing: 0.05em; + color: #8b8f98; +} + +.bhp-row.bleeding .bhp-status { color: #ff8f8f; } + +.bhp-pay { + border: none; + border-radius: 10px; + padding: 9px 14px; + background: #6ee7a8; + color: #06210f; + font-weight: 800; + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + cursor: pointer; + white-space: nowrap; +} + +.bhp-pay.disabled { + background: rgba(255, 255, 255, 0.1); + color: #6b7280; + cursor: default; +} + +.bhp-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 40px 20px; + text-align: center; +} + +.bhp-empty-icon { font-size: 2rem; } +.bhp-empty p { margin: 0; font-size: 0.9rem; } +.bhp-empty .bhp-sub { color: #8b8f98; font-size: 0.78rem; } diff --git a/frontend/src/components/contacts/BailHospitalPanel.tsx b/frontend/src/components/contacts/BailHospitalPanel.tsx new file mode 100644 index 0000000..a7521c1 --- /dev/null +++ b/frontend/src/components/contacts/BailHospitalPanel.tsx @@ -0,0 +1,140 @@ +// ============================================================ +// SLIDE — Bail & Hospital panel (Sprint 14-B, Task 2) +// frontend/src/components/contacts/BailHospitalPanel.tsx +// +// The persistent recovery screen. BailModal handles the moment right +// after an incident; this handles every moment after that, so a member +// dismissed at the modal is not stranded for the rest of the run. +// ============================================================ + +import React, { useCallback, useMemo } from 'react'; +import { + usePlayerStore, + useGangStore, + useNotificationStore, +} from '../../stores/gameStore'; +import { + quoteRecovery, + needsRecovery, + RECOVERY_CONFIG, + type RecoveryQuote, +} from '../../utils/bailHospitalSystem'; +import './BailHospitalPanel.css'; + +interface BailHospitalPanelProps { + /** memberId -> ticks down, when the caller is tracking it. */ + ticksHeld?: Record; +} + +export const BailHospitalPanel: React.FC = ({ ticksHeld = {} }) => { + const player = usePlayerStore((s) => s.player); + const updateMoney = usePlayerStore((s) => s.updateMoney); + const members = useGangStore((s) => s.members); + const releaseMember = useGangStore((s) => s.releaseMember); + const addNotification = useNotificationStore((s) => s.addNotification); + + const quotes = useMemo( + () => + members + .filter(needsRecovery) + .map((m) => quoteRecovery(m, player.money, ticksHeld[m.id] ?? 0)) + .filter((q): q is RecoveryQuote => q !== null), + [members, player.money, ticksHeld], + ); + + const totalOwed = quotes.reduce((sum, q) => sum + q.cost, 0); + + const handleRecover = useCallback( + (quote: RecoveryQuote) => { + if (player.money < quote.cost) { + addNotification({ + type: 'warning', + title: 'Not Enough Cash', + message: `${quote.kind === 'bail' ? 'Bail' : 'The hospital'} wants $${quote.cost.toLocaleString()}. You have $${player.money.toLocaleString()}.`, + priority: 'normal', + }); + return; + } + + updateMoney(-quote.cost); + // releaseMember restores 'active' on both the member and the + // contact record, which is what the deploy screens read. + releaseMember(quote.memberId); + + addNotification({ + type: 'success', + title: quote.kind === 'bail' ? 'Bailed Out' : 'Discharged', + message: + quote.kind === 'bail' + ? `${quote.memberName} walked. Paid $${quote.cost.toLocaleString()}.` + : `${quote.memberName} is patched up and back on rotation. Paid $${quote.cost.toLocaleString()}.`, + priority: 'normal', + }); + }, + [player.money, updateMoney, releaseMember, addNotification], + ); + + if (quotes.length === 0) { + return ( +
+ +

Everybody's on the street.

+

No bail or hospital bills outstanding.

+
+ ); + } + + return ( +
+
+
+ Owed + ${totalOwed.toLocaleString()} +
+
+ On hand + + ${player.money.toLocaleString()} + +
+
+ +

+ Anyone down more than {RECOVERY_CONFIG.ABANDON_GRACE_TICKS} ticks costs the crew{' '} + {RECOVERY_CONFIG.ABANDON_MORALE_PENALTY_PCT}% morale every check. +

+ +
    + {quotes.map((quote) => ( +
  • + + {quote.kind === 'bail' ? '⛓️' : '🏥'} + + +
    + {quote.memberName} + + {quote.kind === 'bail' ? 'Locked up' : 'Laid up'} + {quote.ticksHeld > 0 && ` · ${quote.ticksHeld} ticks`} + {quote.costingMorale && ' · BLEEDING MORALE'} + +
    + + +
  • + ))} +
+
+ ); +}; + +export default BailHospitalPanel; diff --git a/frontend/src/components/contacts/Contacts.tsx b/frontend/src/components/contacts/Contacts.tsx index d95cf03..b2cced2 100644 --- a/frontend/src/components/contacts/Contacts.tsx +++ b/frontend/src/components/contacts/Contacts.tsx @@ -20,10 +20,12 @@ import { import { getMoraleDescription, getMoraleConsequences, rollMoraleConsequences, calculateBailImpact, calculateHospitalImpact } from '../../utils/moraleSystem'; import { getMemberHeatContribution } from '../../utils/memberProgression'; import type { GangMember, Contact, MemberStatus, GetBackRequest } from '../../types/game.types'; +import BailHospitalPanel from './BailHospitalPanel'; +import { needsRecovery } from '../../utils/bailHospitalSystem'; import { soundManager } from '../../utils/SoundManager'; import './Contacts.css'; -type TabType = 'active' | 'jailed' | 'dead' | 'requests'; +type TabType = 'active' | 'jailed' | 'recovery' | 'dead' | 'requests'; // ============ MAIN COMPONENT ============ @@ -51,6 +53,9 @@ const Contacts: React.FC = () => { const activeMembers = contacts.filter(c => c.status === 'active'); const jailedMembers = contacts.filter(c => c.status === 'jailed'); const deadMembers = contacts.filter(c => c.status === 'dead' || c.status === 'backdoored'); + // Counted off gang members, not contacts: injured statuses live on the + // member record and never make it onto a contact card. + const recoverableCount = members.filter(needsRecovery).length; const pendingRequests = getBackRequests.filter(r => r.status === 'pending'); // Drug inventory for equipping @@ -256,6 +261,9 @@ const Contacts: React.FC = () => { + @@ -292,6 +300,8 @@ const Contacts: React.FC = () => { /> ))} + {activeTab === 'recovery' && } + {activeTab === 'dead' && deadMembers.map(contact => ( setSelectedContact(contact)} showDeathInfo /> ))} diff --git a/frontend/src/components/raid/PoliceRaidGame.css b/frontend/src/components/raid/PoliceRaidGame.css new file mode 100644 index 0000000..d3b5646 --- /dev/null +++ b/frontend/src/components/raid/PoliceRaidGame.css @@ -0,0 +1,230 @@ +/* ============================================================ + SLIDE — Police Raid (Sprint 14-B) + ============================================================ */ + +.raid-root { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + background: #08090c; + color: #e8e8ea; + font-family: system-ui, -apple-system, sans-serif; + overflow-y: auto; +} + +.raid-header { + display: flex; + align-items: center; + gap: 12px; +} + +.raid-title { + flex: 1; + margin: 0; + font-size: 1rem; + letter-spacing: 0.18em; + font-weight: 800; + color: #7fb2ff; +} + +.raid-clock { + font-variant-numeric: tabular-nums; + font-weight: 800; + font-size: 1.3rem; + padding: 2px 12px; + border-radius: 10px; + background: rgba(127, 178, 255, 0.14); +} + +.raid-clock.critical { + background: rgba(239, 68, 68, 0.22); + color: #ff6b6b; + animation: raid-flash 0.5s steps(2, end) infinite; +} + +@keyframes raid-flash { 50% { opacity: 0.4; } } + +.raid-count { + font-size: 0.66rem; + letter-spacing: 0.12em; + color: #f4a04c; +} + +.raid-hint { + margin: 0; + text-align: center; + font-size: 0.8rem; + color: #8b8f98; +} + +/* ─── Grid ───────────────────────────────────────────────── */ + +.raid-grid { + display: grid; + gap: 3px; + aspect-ratio: 1; + width: 100%; + max-width: 460px; + margin: 0 auto; +} + +.raid-cell { + position: relative; + border-radius: 5px; + background: rgba(255, 255, 255, 0.045); + display: flex; + align-items: center; + justify-content: center; + min-width: 0; +} + +.raid-cell.threatened { + background: rgba(244, 160, 76, 0.16); +} + +.raid-cell.cop { + background: rgba(59, 130, 246, 0.3); + box-shadow: inset 0 0 0 1px rgba(147, 197, 253, 0.55); +} + +.raid-cop { + position: absolute; + font-size: 1rem; + opacity: 0.9; + pointer-events: none; +} + +/* ─── Members ────────────────────────────────────────────── */ + +.raid-member { + position: relative; + width: 100%; + height: 100%; + border: none; + border-radius: 5px; + background: rgba(110, 231, 168, 0.22); + color: #d7ffe9; + font-size: 0.5rem; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + overflow: hidden; + padding: 0 2px; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; +} + +.raid-member:disabled { cursor: default; } + +.raid-member.status-deployed { + box-shadow: inset 0 0 0 1px rgba(110, 231, 168, 0.6); + animation: raid-beckon 1.2s ease-in-out infinite; +} + +@keyframes raid-beckon { + 50% { box-shadow: inset 0 0 0 2px rgba(110, 231, 168, 0.95); } +} + +.raid-member.status-evacuating { background: rgba(250, 204, 21, 0.22); color: #fde68a; } +.raid-member.status-safe { background: rgba(110, 231, 168, 0.4); opacity: 0.55; } +.raid-member.status-caught { background: rgba(239, 68, 68, 0.34); color: #ffd7d7; } + +.raid-member-name { + position: relative; + z-index: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.raid-evac-bar { + position: absolute; + left: 0; + bottom: 0; + height: 3px; + background: #facc15; + transition: width 0.08s linear; +} + +.raid-badge { + position: absolute; + top: 1px; + right: 2px; + font-size: 0.4rem; + letter-spacing: 0.06em; +} + +.raid-badge.good { color: #6ee7a8; } +.raid-badge.bad { color: #ff8f8f; } + +/* ─── Results ────────────────────────────────────────────── */ + +.raid-results { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.05); +} + +.raid-outcome { + margin: 0; + text-align: center; + letter-spacing: 0.16em; + font-size: 1.05rem; +} + +.raid-outcome.good { color: #6ee7a8; } +.raid-outcome.warn { color: #facc15; } +.raid-outcome.bad { color: #ff6b6b; } + +.raid-summary { display: flex; flex-direction: column; gap: 6px; } + +.raid-summary > div { + display: flex; + justify-content: space-between; + font-size: 0.84rem; +} + +.raid-summary > div > span:last-child { + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.raid-note { + margin: 0; + font-size: 0.76rem; + color: #f4a04c; + text-align: center; +} + +.raid-primary { + padding: 13px; + border: none; + border-radius: 12px; + background: #7fb2ff; + color: #06152b; + font-weight: 800; + letter-spacing: 0.1em; + font-size: 0.8rem; + cursor: pointer; +} + +.raid-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + text-align: center; +} + +.raid-empty .raid-sub { color: #8b8f98; font-size: 0.84rem; margin: 0; } diff --git a/frontend/src/components/raid/PoliceRaidGame.tsx b/frontend/src/components/raid/PoliceRaidGame.tsx new file mode 100644 index 0000000..718a8cb --- /dev/null +++ b/frontend/src/components/raid/PoliceRaidGame.tsx @@ -0,0 +1,214 @@ +// ============================================================ +// SLIDE — Police Raid (Sprint 14-B, Task 1) +// frontend/src/components/raid/PoliceRaidGame.tsx +// +// Renders the 8x8 grid, drives one animation-frame clock, and hands +// every rule decision to policeRaidEngine. +// +// The clock is a single rAF loop off performance.now() rather than a +// setInterval per unit. Police positions are a pure function of elapsed +// time, so a dropped frame or a backgrounded tab can never desync a +// unit from the member it is walking toward. +// ============================================================ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigationStore } from '../../stores/gameStore'; +import { useBlockStore } from '../../stores/blockStore'; +import { + createRaidState, + advanceRaid, + expireRaid, + tapMember, + evacProgress, + secondsRemaining, + isTileThreatened, + RAID_CONFIG, + type RaidState, +} from '../../utils/policeRaidEngine'; +import { applyRaidConsequences, type RaidConsequences } from './policeRaidRewards'; +import './PoliceRaidGame.css'; + +const GRID = RAID_CONFIG.GRID_SIZE; +const CELLS = Array.from({ length: GRID * GRID }, (_, i) => i); + +const OUTCOME_COPY = { + clean: { title: 'BLOCK CLEARED', tone: 'good' }, + partial: { title: 'THEY GOT SOME', tone: 'warn' }, + disaster: { title: 'BLOCK SWEPT', tone: 'bad' }, +} as const; + +export const PoliceRaidGame: React.FC = () => { + const { goBack } = useNavigationStore(); + const selectedBlockId = useBlockStore((s) => s.selectedBlockId); + const blocks = useBlockStore((s) => s.blocks); + + const placements = useMemo(() => { + const block = selectedBlockId ? blocks[selectedBlockId] : undefined; + return block?.placements ?? []; + }, [selectedBlockId, blocks]); + + const [state, setState] = useState(() => + createRaidState(placements, selectedBlockId), + ); + const [consequences, setConsequences] = useState(null); + + const startedAtRef = useRef(null); + const frameRef = useRef(null); + const settledRef = useRef(false); + + const isOver = state.outcome !== 'in_progress'; + + // ─── Clock ───────────────────────────────────────────────── + useEffect(() => { + if (isOver) return; + + const step = () => { + const now = performance.now(); + if (startedAtRef.current === null) startedAtRef.current = now; + const elapsed = now - startedAtRef.current; + + setState((prev) => { + if (prev.outcome !== 'in_progress') return prev; + return elapsed >= RAID_CONFIG.DURATION_MS + ? expireRaid(prev) + : advanceRaid(prev, elapsed); + }); + + frameRef.current = requestAnimationFrame(step); + }; + + frameRef.current = requestAnimationFrame(step); + return () => { + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + }; + }, [isOver]); + + // ─── Settle once ─────────────────────────────────────────── + useEffect(() => { + if (!isOver || settledRef.current) return; + settledRef.current = true; + setConsequences(applyRaidConsequences(state)); + }, [isOver, state]); + + const handleTap = useCallback((memberId: string) => { + setState((prev) => tapMember(prev, memberId)); + }, []); + + // ─── Derived view model ──────────────────────────────────── + + const memberAt = useMemo(() => { + const map = new Map(); + for (const m of state.members) map.set(m.y * GRID + m.x, m); + return map; + }, [state.members]); + + const unitAt = useMemo(() => { + const set = new Set(); + for (const u of state.units) set.add(u.y * GRID + u.x); + return set; + }, [state.units]); + + const seconds = secondsRemaining(state); + const stillOut = state.members.filter( + (m) => m.status === 'deployed' || m.status === 'evacuating', + ).length; + + // ─── Empty block ─────────────────────────────────────────── + if (state.members.length === 0) { + return ( +
+
+

POLICE RAID

+
+
+

Nobody was working this block.

+

The sweep came up empty.

+ +
+
+ ); + } + + return ( +
+
+

POLICE RAID

+
+ {seconds}s +
+ {stillOut} EXPOSED +
+ + {!isOver && ( +

Tap your people to pull them out. Takes 1.5s each.

+ )} + +
+ {CELLS.map((idx) => { + const x = idx % GRID; + const y = Math.floor(idx / GRID); + const member = memberAt.get(idx); + const hasUnit = unitAt.has(idx); + const threatened = !hasUnit && isTileThreatened(state, x, y); + + return ( +
+ {hasUnit && 🚓} + + {member && ( + + )} +
+ ); + })} +
+ + {isOver && state.outcome !== 'in_progress' && ( +
+

+ {OUTCOME_COPY[state.outcome].title} +

+ + {consequences && ( +
+
Pulled out{consequences.savedIds.length}
+
Jailed{consequences.jailedIds.length}
+
Cash seized${consequences.cashSeized.toLocaleString()}
+
Product seized{consequences.drugsSeized}
+
+ )} + + {consequences && consequences.jailedIds.length > 0 && ( +

+ Bail your people out from the CREW app before they cost you morale. +

+ )} + + +
+ )} +
+ ); +}; + +export default PoliceRaidGame; diff --git a/frontend/src/components/raid/__tests__/policeRaid.test.ts b/frontend/src/components/raid/__tests__/policeRaid.test.ts new file mode 100644 index 0000000..3b1523c --- /dev/null +++ b/frontend/src/components/raid/__tests__/policeRaid.test.ts @@ -0,0 +1,392 @@ +/** + * Police Raid — engine rules and store consequences. + * + * The engine advances off an explicit elapsed-ms value, so a whole + * 30-second raid resolves synchronously here with no fake timers. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + createRaidState, + createRaidMember, + spawnUnits, + tapMember, + advanceRaid, + expireRaid, + evacProgress, + secondsRemaining, + caughtMembers, + savedMembers, + estimateHeld, + isTileThreatened, + RAID_CONFIG, + type RaidState, +} from '../../../utils/policeRaidEngine'; +import { applyRaidConsequences } from '../policeRaidRewards'; +import { selectRaidTarget, suppressesBackgroundRaid, RAID_TRIGGER_CONFIG } from '../../../utils/raidTrigger'; +import { usePlayerStore, useGangStore, useEconomyStore } from '../../../stores/gameStore'; +import { useBlockStore } from '../../../stores/blockStore'; +import type { BlockPlacement, BlockData } from '../../../types/block.types'; + +// ─── Fixtures ──────────────────────────────────────────────── + +function placement(over: Partial = {}): BlockPlacement { + return { + memberId: 'm1', + memberName: 'Trap Mike', + role: 'dealer', + x: 3, + y: 4, + zoneType: 'corner', + incomePerTick: 60, + exposureRisk: 20, + level: 2, + health: 100, + ...over, + } as BlockPlacement; +} + +function block(over: Partial = {}): BlockData { + return { + id: 'blk-1', + address: 'Test Block', + lat: 0, + lng: 0, + owner: 'player', + grid: useBlockStore.getState().generateDefaultGrid(), + placements: [placement()], + incomePerTick: 60, + heat: 5, + morale: 60, + members: 1, + viewMode: 'topdown', + pendingIncome: 0, + ...over, + } as BlockData; +} + +// ─── Setup ─────────────────────────────────────────────────── + +describe('policeRaid — setup', () => { + it('starts in progress with no seizures', () => { + const s = createRaidState([placement()]); + expect(s.outcome).toBe('in_progress'); + expect(s.seizedCash).toBe(0); + expect(s.elapsedMs).toBe(0); + }); + + it('spawns a unit at each edge of every occupied column', () => { + const members = [placement({ x: 2 }), placement({ memberId: 'm2', x: 5 })].map(createRaidMember); + const units = spawnUnits(members); + expect(units).toHaveLength(4); + expect(units.filter((u) => u.y === 0)).toHaveLength(2); + expect(units.filter((u) => u.y === RAID_CONFIG.GRID_SIZE - 1)).toHaveLength(2); + }); + + it('does not spawn duplicate lanes for two members in one column', () => { + const members = [placement({ x: 4, y: 2 }), placement({ memberId: 'm2', x: 4, y: 6 })].map(createRaidMember); + expect(spawnUnits(members)).toHaveLength(2); + }); + + it('still spawns a token pair when nobody is deployed', () => { + expect(spawnUnits([])).toHaveLength(2); + }); + + it('derives held cash and product from the corner income', () => { + const held = estimateHeld(placement({ incomePerTick: 60 })); + expect(held.cash).toBe(480); + expect(held.drugs).toBe(5); + }); + + it('a zero-income placement still carries at least one unit of product', () => { + expect(estimateHeld(placement({ incomePerTick: 0 })).drugs).toBe(1); + }); +}); + +// ─── Evacuation ────────────────────────────────────────────── + +describe('policeRaid — evacuation', () => { + it('tapping a member starts their evac clock', () => { + const s = tapMember(createRaidState([placement()]), 'm1'); + expect(s.members[0].status).toBe('evacuating'); + expect(s.members[0].evacStartedAt).toBe(0); + }); + + it('an unknown member id is a no-op', () => { + const s = createRaidState([placement()]); + expect(tapMember(s, 'nobody')).toBe(s); + }); + + it('re-tapping does not restart the timer', () => { + let s = tapMember(createRaidState([placement()]), 'm1'); + s = advanceRaid(s, 500); + const retapped = tapMember(s, 'm1'); + expect(retapped.members[0].evacStartedAt).toBe(0); + }); + + it('completes after the evac duration', () => { + let s = tapMember(createRaidState([placement({ x: 0, y: 4 })]), 'm1'); + s = advanceRaid(s, RAID_CONFIG.EVAC_DURATION_MS); + expect(s.members[0].status).toBe('safe'); + }); + + it('is still in flight one tick before completion', () => { + let s = tapMember(createRaidState([placement({ x: 0, y: 4 })]), 'm1'); + s = advanceRaid(s, RAID_CONFIG.EVAC_DURATION_MS - 100); + expect(s.members[0].status).toBe('evacuating'); + }); + + it('reports progress between 0 and 1', () => { + let s = tapMember(createRaidState([placement({ x: 0, y: 4 })]), 'm1'); + s = advanceRaid(s, 750); + expect(evacProgress(s.members[0], s.elapsedMs)).toBeCloseTo(0.5, 1); + }); +}); + +// ─── Police advance & capture ──────────────────────────────── + +describe('policeRaid — capture', () => { + it('units advance one tile per second', () => { + const s = advanceRaid(createRaidState([placement({ x: 3, y: 4 })]), 3_000); + expect(s.units.find((u) => u.direction === 1)?.y).toBe(3); + }); + + it('units stop at the far edge instead of walking off the grid', () => { + const s = advanceRaid(createRaidState([placement({ x: 3, y: 4 })]), 60_000); + expect(s.units.every((u) => u.y >= 0 && u.y < RAID_CONFIG.GRID_SIZE)).toBe(true); + }); + + it('advancing is idempotent — many small steps land where one big step does', () => { + // Regression: units were previously advanced from their CURRENT row + // rather than their spawn row, so each call compounded. The component + // drives this from requestAnimationFrame (~60 calls/sec), which made + // police cross the whole board almost instantly. + // y=3 sits outside the 2s reach of both lanes (top gets to y=2, bottom + // to y=5), so the raid stays in progress and units keep advancing. + const start = createRaidState([placement({ x: 3, y: 3 })]); + + let stepped = start; + for (let t = 100; t <= 2_000; t += 100) stepped = advanceRaid(stepped, t); + const jumped = advanceRaid(start, 2_000); + + expect(stepped.units.map((u) => u.y)).toEqual(jumped.units.map((u) => u.y)); + }); + + it('holds the advertised one-tile-per-second pace under a frame-rate loop', () => { + let s = createRaidState([placement({ x: 3, y: 3 })]); + for (let t = 16; t <= 2_000; t += 16) s = advanceRaid(s, t); + expect(s.units.find((u) => u.direction === 1)?.y).toBe(2); + expect(s.units.find((u) => u.direction === -1)?.y).toBe(5); + }); + + it('catches a member who was never tapped', () => { + // Top unit starts at y=0 and needs 2s to reach y=2. + const s = advanceRaid(createRaidState([placement({ x: 3, y: 2 })]), 2_000); + expect(s.members[0].status).toBe('caught'); + expect(caughtMembers(s)).toHaveLength(1); + }); + + it('seizes what a caught member was holding', () => { + const s = advanceRaid(createRaidState([placement({ x: 3, y: 2, incomePerTick: 60 })]), 2_000); + expect(s.seizedCash).toBe(480); + expect(s.seizedDrugs).toBe(5); + }); + + it('a member who got out first is not caught when the unit arrives', () => { + let s = createRaidState([placement({ x: 3, y: 2 })]); + s = tapMember(s, 'm1'); + s = advanceRaid(s, RAID_CONFIG.EVAC_DURATION_MS); + s = advanceRaid(s, 2_000); + expect(s.members[0].status).toBe('safe'); + expect(s.seizedCash).toBe(0); + }); + + it('a tie between evac completion and arrival goes to the player', () => { + // Unit reaches y=3 at t=3000; evac started at 1500 completes at 3000. + let s = createRaidState([placement({ x: 3, y: 3 })]); + s = advanceRaid(s, 1_500); + s = tapMember(s, 'm1'); + s = advanceRaid(s, 3_000); + expect(s.members[0].status).toBe('safe'); + }); + + it('an evacuation still in flight when police arrive fails', () => { + let s = createRaidState([placement({ x: 3, y: 2 })]); + s = advanceRaid(s, 1_500); + s = tapMember(s, 'm1'); + s = advanceRaid(s, 2_000); + expect(s.members[0].status).toBe('caught'); + }); + + it('flags tiles a unit is about to step onto', () => { + const s = advanceRaid(createRaidState([placement({ x: 3, y: 4 })]), 2_000); + expect(isTileThreatened(s, 3, 3)).toBe(true); + expect(isTileThreatened(s, 0, 0)).toBe(false); + }); +}); + +// ─── Outcomes ──────────────────────────────────────────────── + +describe('policeRaid — outcomes', () => { + it('everyone out is a clean result', () => { + let s = createRaidState([placement({ x: 0, y: 4 })]); + s = tapMember(s, 'm1'); + s = advanceRaid(s, RAID_CONFIG.EVAC_DURATION_MS); + expect(s.outcome).toBe('clean'); + expect(savedMembers(s)).toHaveLength(1); + }); + + it('everyone taken is a disaster', () => { + const s = advanceRaid(createRaidState([placement({ x: 3, y: 2 })]), 2_000); + expect(s.outcome).toBe('disaster'); + }); + + it('a mixed result is partial', () => { + let s = createRaidState([ + placement({ memberId: 'm1', x: 3, y: 2 }), + placement({ memberId: 'm2', x: 5, y: 4 }), + ]); + s = tapMember(s, 'm2'); + s = advanceRaid(s, RAID_CONFIG.EVAC_DURATION_MS); + s = advanceRaid(s, 2_000); + expect(s.outcome).toBe('partial'); + }); + + it('anyone still out when the clock expires is caught', () => { + const s = expireRaid(createRaidState([placement({ x: 0, y: 4 })])); + expect(s.members[0].status).toBe('caught'); + expect(s.outcome).toBe('disaster'); + }); + + it('a resolved raid ignores further advances', () => { + const done = expireRaid(createRaidState([placement({ x: 0, y: 4 })])); + expect(advanceRaid(done, 99_000)).toBe(done); + }); + + it('counts remaining seconds down from the full duration', () => { + const s = createRaidState([placement()]); + expect(secondsRemaining(s)).toBe(30); + expect(secondsRemaining(advanceRaid(s, 10_000))).toBe(20); + }); +}); + +// ─── Store consequences ────────────────────────────────────── + +describe('policeRaid — consequences', () => { + beforeEach(() => { + usePlayerStore.setState({ + player: { ...usePlayerStore.getState().player, money: 10_000 }, + } as never); + useEconomyStore.setState({ + inventory: [ + { id: '1', type: 'drug', itemId: 'weed', name: 'Cannabis', quantity: 10, value: 20 }, + { id: '2', type: 'drug', itemId: 'coke', name: 'Cocaine', quantity: 10, value: 80 }, + ], + } as never); + useGangStore.setState({ + members: [{ id: 'm1', name: 'Trap Mike', role: 'dealer', level: 2, status: 'active' }], + contacts: [], + } as never); + useBlockStore.setState({ blocks: {}, selectedBlockId: null } as never); + }); + + function raidedState(): RaidState { + useBlockStore.getState().upsertBlock(block()); + return advanceRaid(createRaidState([placement({ x: 3, y: 2 })], 'blk-1'), 2_000); + } + + it('jails every caught member', () => { + const result = applyRaidConsequences(raidedState()); + expect(result.jailedIds).toEqual(['m1']); + expect(useGangStore.getState().members[0].status).toBe('jailed'); + }); + + it('takes the seized cash off the player', () => { + const result = applyRaidConsequences(raidedState()); + expect(result.cashSeized).toBe(480); + expect(usePlayerStore.getState().player.money).toBe(10_000 - 480); + }); + + it('never seizes more cash than the player has', () => { + usePlayerStore.setState({ + player: { ...usePlayerStore.getState().player, money: 100 }, + } as never); + const result = applyRaidConsequences(raidedState()); + expect(result.cashSeized).toBe(100); + expect(usePlayerStore.getState().player.money).toBe(0); + }); + + it('draws product from the cheapest stock first', () => { + applyRaidConsequences(raidedState()); + const inv = useEconomyStore.getState().inventory; + expect(inv.find((i) => i.itemId === 'weed')?.quantity).toBe(5); + expect(inv.find((i) => i.itemId === 'coke')?.quantity).toBe(10); + }); + + it('drops block heat after the raid', () => { + const result = applyRaidConsequences(raidedState()); + expect(result.blockHeatAfter).toBe(RAID_CONFIG.POST_RAID_HEAT); + expect(useBlockStore.getState().getBlock('blk-1')?.heat).toBe(RAID_CONFIG.POST_RAID_HEAT); + }); + + it('pulls caught members off the block grid', () => { + applyRaidConsequences(raidedState()); + expect(useBlockStore.getState().getBlock('blk-1')?.placements).toHaveLength(0); + }); + + it('a clean raid costs nothing and jails nobody', () => { + useBlockStore.getState().upsertBlock(block()); + let s = createRaidState([placement({ x: 0, y: 4 })], 'blk-1'); + s = tapMember(s, 'm1'); + s = advanceRaid(s, RAID_CONFIG.EVAC_DURATION_MS); + + const result = applyRaidConsequences(s); + expect(result.jailedIds).toHaveLength(0); + expect(result.cashSeized).toBe(0); + expect(usePlayerStore.getState().player.money).toBe(10_000); + }); +}); + +// ─── Trigger ───────────────────────────────────────────────── + +describe('policeRaid — trigger', () => { + beforeEach(() => { + useBlockStore.setState({ blocks: {}, selectedBlockId: null } as never); + }); + + it('fires on a player block at max heat with crew deployed', () => { + const decision = selectRaidTarget({ 'blk-1': block({ heat: 5 }) }, {}, 0); + expect(decision?.blockId).toBe('blk-1'); + expect(suppressesBackgroundRaid(decision)).toBe(true); + }); + + it('does not fire below the heat threshold', () => { + expect(selectRaidTarget({ 'blk-1': block({ heat: 4 }) }, {}, 0)).toBeNull(); + }); + + it('does not fire on an empty block — nothing is at stake', () => { + expect(selectRaidTarget({ 'blk-1': block({ placements: [] }) }, {}, 0)).toBeNull(); + }); + + it('ignores blocks the player does not own', () => { + expect(selectRaidTarget({ 'blk-1': block({ owner: 'npc' }) }, {}, 0)).toBeNull(); + }); + + it('respects the per-block cooldown', () => { + const blocks = { 'blk-1': block() }; + expect(selectRaidTarget(blocks, { 'blk-1': 0 }, 3)).toBeNull(); + expect(selectRaidTarget(blocks, { 'blk-1': 0 }, RAID_TRIGGER_CONFIG.COOLDOWN_TICKS)).not.toBeNull(); + }); + + it('picks the busiest block when several are maxed', () => { + const busy = block({ + id: 'blk-2', + placements: [placement(), placement({ memberId: 'm2', x: 5 })], + }); + const decision = selectRaidTarget({ 'blk-1': block(), 'blk-2': busy }, {}, 0); + expect(decision?.blockId).toBe('blk-2'); + }); + + it('lets the background roll through when nothing qualifies', () => { + expect(suppressesBackgroundRaid(null)).toBe(false); + }); +}); diff --git a/frontend/src/components/raid/policeRaidRewards.ts b/frontend/src/components/raid/policeRaidRewards.ts new file mode 100644 index 0000000..5c88930 --- /dev/null +++ b/frontend/src/components/raid/policeRaidRewards.ts @@ -0,0 +1,98 @@ +// ============================================================ +// SLIDE — Police Raid consequences (Sprint 14-B, Task 1) +// frontend/src/components/raid/policeRaidRewards.ts +// +// The single place a finished interactive raid touches game state. +// policeRaidEngine stays pure; this translates its result. +// ============================================================ + +import { usePlayerStore, useGangStore, useEconomyStore } from '../../stores/gameStore'; +import { useBlockStore } from '../../stores/blockStore'; +import { + caughtMembers, + savedMembers, + RAID_CONFIG, + type RaidState, +} from '../../utils/policeRaidEngine'; + +export interface RaidConsequences { + jailedIds: string[]; + savedIds: string[]; + cashSeized: number; + drugsSeized: number; + blockHeatAfter: number | null; +} + +/** + * Apply a resolved raid. + * + * Call once — it moves money and member status. PoliceRaidGame guards + * re-entry with a ref, matching the BipNDip pattern. + */ +export function applyRaidConsequences(state: RaidState): RaidConsequences { + const caught = caughtMembers(state); + const saved = savedMembers(state); + + const playerStore = usePlayerStore.getState(); + const gangStore = useGangStore.getState(); + const economyStore = useEconomyStore.getState(); + + // ─── Cash ────────────────────────────────────────────────── + // Never seize more than the player actually has. A raid should not be + // able to push the balance negative and softlock every purchase. + const cashSeized = Math.min(state.seizedCash, Math.max(0, playerStore.player.money)); + if (cashSeized > 0) playerStore.updateMoney(-cashSeized); + + // ─── Product ─────────────────────────────────────────────── + // Drawn down across whatever drugs are in inventory, cheapest first, + // so a bust does not selectively wipe the player's best stock. + let drugsRemaining = state.seizedDrugs; + let drugsSeized = 0; + if (drugsRemaining > 0) { + const drugs = economyStore.inventory + .filter((i) => i.type === 'drug' && i.quantity > 0) + .sort((a, b) => (a.value ?? 0) - (b.value ?? 0)); + + for (const item of drugs) { + if (drugsRemaining <= 0) break; + const take = Math.min(item.quantity, drugsRemaining); + economyStore.removeInventoryItem(item.itemId, take); + drugsRemaining -= take; + drugsSeized += take; + } + } + + // ─── Members ─────────────────────────────────────────────── + for (const member of caught) { + gangStore.jailMember(member.memberId); + } + + // ─── Block ───────────────────────────────────────────────── + // Heat drops after a raid regardless of outcome — the block has + // already been hit, so it stops being a target for a while. Caught + // members are also pulled off the grid; leaving a jailed member + // standing on a tile would keep earning income from a cell. + let blockHeatAfter: number | null = null; + if (state.blockId) { + const blockStore = useBlockStore.getState(); + const block = blockStore.getBlock(state.blockId); + if (block) { + const caughtIds = new Set(caught.map((m) => m.memberId)); + blockHeatAfter = RAID_CONFIG.POST_RAID_HEAT; + blockStore.upsertBlock({ + ...block, + heat: RAID_CONFIG.POST_RAID_HEAT, + placements: block.placements.filter((p) => !caughtIds.has(p.memberId)), + members: Math.max(0, block.placements.length - caught.length), + }); + } + } + + return { + jailedIds: caught.map((m) => m.memberId), + savedIds: saved.map((m) => m.memberId), + cashSeized, + drugsSeized, + blockHeatAfter, + }; +} diff --git a/frontend/src/components/topdown/PoliceRaidGame.tsx b/frontend/src/components/topdown/PoliceRaidGame.tsx index 20279ad..9380bc0 100644 --- a/frontend/src/components/topdown/PoliceRaidGame.tsx +++ b/frontend/src/components/topdown/PoliceRaidGame.tsx @@ -1,8 +1,8 @@ // ============================================================ -// PoliceRaidGame.tsx — Police Raid mini-game +// PoliceRaidGame.tsx — Police Raid mini-game (Sprint 14-A/B) // // Triggered when block heat >= 5. A timed grid-clear: -// • Police advance from top and bottom edges +// • Police advance from top and bottom edges (time-based rAF) // • Player taps members to begin evacuation // • Caught members go to jail; their product is seized // • Results feed back to blockStore + gangStore @@ -11,19 +11,25 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { createRaidState, - startEvacuation, - tickRaid, - getRaidSummary, + tapMember, + advanceRaid, + expireRaid, + caughtMembers, + savedMembers, + secondsRemaining, + evacProgress, + isTileThreatened, + RAID_CONFIG, type RaidState, type RaidMember, type PoliceUnit, - RAID_GRID_COLS, - RAID_GRID_ROWS, } from '../../utils/policeRaidEngine'; import { useBlockStore } from '../../stores/blockStore'; import { useGangStore, usePlayerStore } from '../../stores/gameStore'; import './PoliceRaidGame.css'; +const GRID_SIZE = RAID_CONFIG.GRID_SIZE; + interface PoliceRaidGameProps { blockId: string; onResolved: (caught: string[], cashSeized: number) => void; @@ -44,94 +50,104 @@ const ROLE_COLORS: Record = { export const PoliceRaidGame: React.FC = ({ blockId, onResolved }) => { const { blocks, upsertBlock, removeMemberFromBlock } = useBlockStore(); - const { members, updateMember } = useGangStore(); + const { updateMember } = useGangStore(); const { updateMoney } = usePlayerStore(); const block = blocks[blockId]; const [raidState, setRaidState] = useState(null); const [showResults, setShowResults] = useState(false); - const tickRef = useRef | null>(null); + + const rafRef = useRef(null); + const startTimeRef = useRef(null); const resolvedRef = useRef(false); // ── Initialise ────────────────────────────────────────── useEffect(() => { if (!block) return; - const policeCount = Math.min(2 + block.heat, 6); // scales with heat - const placements = block.placements.map((p) => ({ - memberId: p.memberId, - memberName: p.memberName, - role: p.role, - x: p.x, - y: p.y, - heldCash: Math.floor(p.incomePerTick * 3), - heldDrugs: 1, - })); - const initial = createRaidState(placements, policeCount); - setRaidState({ ...initial, phase: 'active' }); - }, [blockId]); + const placements = block.placements ?? []; + const initial = createRaidState(placements, blockId); + setRaidState(initial); + startTimeRef.current = null; + resolvedRef.current = false; + }, [blockId]); // eslint-disable-line react-hooks/exhaustive-deps - // ── Tick loop ──────────────────────────────────────────── + // ── rAF game loop ──────────────────────────────────────── useEffect(() => { - if (!raidState || raidState.phase === 'resolved') return; - tickRef.current = setInterval(() => { + if (!raidState || raidState.outcome !== 'in_progress') return; + + const tick = (now: number) => { + if (startTimeRef.current === null) startTimeRef.current = now; + const elapsed = now - startTimeRef.current; + setRaidState((prev) => { - if (!prev || prev.phase === 'resolved') return prev; - return tickRaid(prev); + if (!prev || prev.outcome !== 'in_progress') return prev; + if (elapsed >= RAID_CONFIG.DURATION_MS) { + return expireRaid(prev); + } + return advanceRaid(prev, elapsed); }); - }, 1000); - return () => { if (tickRef.current) clearInterval(tickRef.current); }; - }, [raidState?.phase]); + + rafRef.current = requestAnimationFrame(tick); + }; + + rafRef.current = requestAnimationFrame(tick); + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + }, [raidState?.outcome]); // ── Detect resolution ──────────────────────────────────── useEffect(() => { - if (!raidState || raidState.phase !== 'resolved' || resolvedRef.current) return; + if (!raidState || raidState.outcome === 'in_progress' || resolvedRef.current) return; resolvedRef.current = true; - if (tickRef.current) clearInterval(tickRef.current); + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); setShowResults(true); - // Apply consequences - const summary = getRaidSummary(raidState); + const caught = caughtMembers(raidState); + const cashSeized = raidState.seizedCash; // Jail caught members - for (const m of raidState.caught) { + for (const m of caught) { updateMember(m.memberId, { status: 'jailed' }); removeMemberFromBlock(blockId, m.memberId); } // Seize cash - if (summary.cashSeized > 0) { - updateMoney(-summary.cashSeized); + if (cashSeized > 0) { + updateMoney(-cashSeized); } - // Reduce block heat (raid "satisfied" the police) - if (block && summary.heatReduction > 0) { - upsertBlock({ ...block, heat: Math.max(0, block.heat - summary.heatReduction) }); + // Reduce block heat after raid + if (block) { + upsertBlock({ ...block, heat: RAID_CONFIG.POST_RAID_HEAT }); } - }, [raidState?.phase]); + }, [raidState?.outcome]); // eslint-disable-line react-hooks/exhaustive-deps const handleMemberTap = useCallback((memberId: string) => { - setRaidState((prev) => prev ? startEvacuation(prev, memberId) : prev); + setRaidState((prev) => prev ? tapMember(prev, memberId) : prev); }, []); const handleDone = useCallback(() => { if (!raidState) return; - const caught = raidState.caught.map((m) => m.memberId); - onResolved(caught, raidState.cashSeized); + const caught = caughtMembers(raidState).map((m) => m.memberId); + onResolved(caught, raidState.seizedCash); }, [raidState, onResolved]); if (!raidState) { return
Initialising raid...
; } - const summary = raidState.phase === 'resolved' ? getRaidSummary(raidState) : null; + const secsLeft = secondsRemaining(raidState); + const caught = caughtMembers(raidState); + const safe = savedMembers(raidState); return (
{/* Header */}
🚔 POLICE RAID
-
- {raidState.ticksRemaining}s +
+ {secsLeft}s
TAP MEMBERS TO EVACUATE
@@ -140,16 +156,16 @@ export const PoliceRaidGame: React.FC = ({ blockId, onResol
{/* Zone cells */} - {Array.from({ length: RAID_GRID_ROWS }, (_, r) => - Array.from({ length: RAID_GRID_COLS }, (_, c) => ( + {Array.from({ length: GRID_SIZE }, (_, r) => + Array.from({ length: GRID_SIZE }, (_, c) => (
= ({ blockId, onResol )} {/* Police units */} - {raidState.police.map((cop: PoliceUnit) => ( + {raidState.units.map((cop: PoliceUnit) => ( 🚔 @@ -177,38 +193,41 @@ export const PoliceRaidGame: React.FC = ({ blockId, onResol ))} {/* Member sprites */} - {raidState.members.map((m: RaidMember) => ( - handleMemberTap(m.memberId)} - whileTap={{ scale: 0.9 }} - > - {m.role.slice(0, 3).toUpperCase()} - {m.status === 'evacuating' && ( -
-
-
- )} - - ))} + {raidState.members.map((m: RaidMember) => { + const progress = evacProgress(m, raidState.elapsedMs); + return ( + handleMemberTap(m.memberId)} + whileTap={{ scale: 0.9 }} + > + {m.role.slice(0, 3).toUpperCase()} + {m.status === 'evacuating' && ( +
+
+
+ )} + + ); + })} - {/* Evacuated flash */} - {raidState.evacuated.map((m: RaidMember) => ( + {/* Safe flash */} + {safe.map((m: RaidMember) => ( = ({ blockId, onResol {/* Status bar */}
- Safe: {raidState.evacuated.length} - Caught: {raidState.caught.length} - At Risk: {raidState.members.length} + Safe: {safe.length} + Caught: {caught.length} + At Risk: {raidState.members.filter(m => m.status === 'deployed' || m.status === 'evacuating').length}
{/* Results overlay */} - {showResults && summary && ( + {showResults && raidState.outcome !== 'in_progress' && ( = ({ blockId, onResol

RAID OVER

- {summary.evacuated} + {safe.length} Escaped
- {summary.caught} + {caught.length} Jailed
- ${summary.cashSeized.toLocaleString()} + ${raidState.seizedCash.toLocaleString()} Cash Seized
- {summary.drugsSeized} + {raidState.seizedDrugs} Drugs Seized
- {summary.caught > 0 && ( + {caught.length > 0 && (

Jailed members can be bailed out from the Contacts app.

diff --git a/frontend/src/utils/__tests__/bailHospitalSystem.test.ts b/frontend/src/utils/__tests__/bailHospitalSystem.test.ts new file mode 100644 index 0000000..b3e7873 --- /dev/null +++ b/frontend/src/utils/__tests__/bailHospitalSystem.test.ts @@ -0,0 +1,384 @@ +/** + * bailHospitalSystem — tests (Sprint 14-B) + * + * Covers the new severity/repeat-offense cost scaling and wait-time + * system, plus the existing abandonment penalty and detention tracking. + */ +import { describe, it, expect } from 'vitest'; +import { + RECOVERY_CONFIG, + isJailed, + isInjured, + needsRecovery, + recoveryKindFor, + recoveryCost, + severityTier, + scaledBailCost, + scaledHospitalCost, + waitTicks, + isLifer, + quoteRecovery, + overdueMembers, + applyAbandonmentPenalty, + updateHeldSince, + type RecoveryKind, +} from '../bailHospitalSystem'; +import type { GangMember } from '../../types/game.types'; + +// ─── Fixtures ──────────────────────────────────────────────── + +function member( + id: string, + status: GangMember['status'], + arrests = 0, + priorInjuries = 0, +): Pick & { priorInjuries?: number } { + return { id, name: `Member ${id}`, status, arrests, priorInjuries }; +} + +// ─── Status helpers ─────────────────────────────────────────── + +describe('bailHospital — status helpers', () => { + it('treats jailed and arrested as bail cases', () => { + expect(isJailed(member('a', 'jailed'))).toBe(true); + expect(isJailed(member('a', 'arrested'))).toBe(true); + }); + + it('treats the three injury statuses as hospital cases', () => { + expect(isInjured(member('a', 'injured'))).toBe(true); + expect(isInjured(member('a', 'hospitalized'))).toBe(true); + expect(isInjured(member('a', 'hospital'))).toBe(true); + }); + + it('an active member needs nothing', () => { + expect(needsRecovery(member('a', 'active'))).toBe(false); + expect(recoveryKindFor(member('a', 'active'))).toBeNull(); + }); + + it('a dead member is not recoverable — bail does not resurrect', () => { + expect(needsRecovery(member('a', 'dead'))).toBe(false); + expect(recoveryCost(member('a', 'dead'))).toBe(0); + }); + + it('a backdoored member is not recoverable', () => { + expect(needsRecovery(member('a', 'backdoored'))).toBe(false); + }); +}); + +// ─── severityTier ───────────────────────────────────────────── + +describe('severityTier — bail', () => { + it('0 charges → tier 0 (minor)', () => { + expect(severityTier(0, 'bail')).toBe(0); + }); + + it('1-2 charges → tier 1 (moderate)', () => { + expect(severityTier(1, 'bail')).toBe(1); + expect(severityTier(2, 'bail')).toBe(1); + }); + + it('3-4 charges → tier 2 (serious)', () => { + expect(severityTier(3, 'bail')).toBe(2); + expect(severityTier(4, 'bail')).toBe(2); + }); + + it('5-6 charges → tier 3 (major)', () => { + expect(severityTier(5, 'bail')).toBe(3); + expect(severityTier(6, 'bail')).toBe(3); + }); + + it('7+ charges → tier 4 (critical)', () => { + expect(severityTier(7, 'bail')).toBe(4); + expect(severityTier(20, 'bail')).toBe(4); + }); +}); + +describe('severityTier — hospital', () => { + it('<20 damage → tier 0 (minor)', () => { + expect(severityTier(0, 'hospital')).toBe(0); + expect(severityTier(19, 'hospital')).toBe(0); + }); + + it('20-39 damage → tier 1 (moderate)', () => { + expect(severityTier(20, 'hospital')).toBe(1); + expect(severityTier(39, 'hospital')).toBe(1); + }); + + it('40-59 damage → tier 2 (serious)', () => { + expect(severityTier(40, 'hospital')).toBe(2); + expect(severityTier(59, 'hospital')).toBe(2); + }); + + it('60-79 damage → tier 3 (major)', () => { + expect(severityTier(60, 'hospital')).toBe(3); + expect(severityTier(79, 'hospital')).toBe(3); + }); + + it('80+ damage → tier 4 (critical)', () => { + expect(severityTier(80, 'hospital')).toBe(4); + expect(severityTier(100, 'hospital')).toBe(4); + }); +}); + +// ─── scaledBailCost ─────────────────────────────────────────── + +describe('scaledBailCost', () => { + it('first offense, no charges → base cost', () => { + expect(scaledBailCost(0, 0)).toBe(RECOVERY_CONFIG.BAIL_BASE_COST); + }); + + it('increases with severity tier', () => { + const tier0 = scaledBailCost(0, 0); + const tier2 = scaledBailCost(3, 0); + const tier4 = scaledBailCost(7, 0); + expect(tier2).toBeGreaterThan(tier0); + expect(tier4).toBeGreaterThan(tier2); + }); + + it('increases with prior arrests', () => { + const first = scaledBailCost(0, 0); + const third = scaledBailCost(0, 2); + expect(third).toBeGreaterThan(first); + }); + + it('repeat multiplier is capped', () => { + const manyPriors = scaledBailCost(0, 100); + const capCost = Math.round( + RECOVERY_CONFIG.BAIL_BASE_COST * + RECOVERY_CONFIG.SEVERITY_COST_MULTIPLIERS[0] * + RECOVERY_CONFIG.REPEAT_COST_CAP, + ); + expect(manyPriors).toBe(capCost); + }); + + it('tier 4 with cap priors is the most expensive possible bail', () => { + const max = scaledBailCost(10, 100); + const expected = Math.round( + RECOVERY_CONFIG.BAIL_BASE_COST * + RECOVERY_CONFIG.SEVERITY_COST_MULTIPLIERS[4] * + RECOVERY_CONFIG.REPEAT_COST_CAP, + ); + expect(max).toBe(expected); + }); +}); + +// ─── scaledHospitalCost ─────────────────────────────────────── + +describe('scaledHospitalCost', () => { + it('light injury, first time → base cost', () => { + expect(scaledHospitalCost(0, 0)).toBe(RECOVERY_CONFIG.HOSPITAL_BASE_COST); + }); + + it('increases with damage taken', () => { + const light = scaledHospitalCost(10, 0); + const critical = scaledHospitalCost(90, 0); + expect(critical).toBeGreaterThan(light); + }); + + it('increases with prior injuries', () => { + const first = scaledHospitalCost(30, 0); + const repeat = scaledHospitalCost(30, 3); + expect(repeat).toBeGreaterThan(first); + }); + + it('repeat multiplier is capped', () => { + const manyPriors = scaledHospitalCost(0, 100); + const capCost = Math.round( + RECOVERY_CONFIG.HOSPITAL_BASE_COST * + RECOVERY_CONFIG.SEVERITY_COST_MULTIPLIERS[0] * + RECOVERY_CONFIG.REPEAT_COST_CAP, + ); + expect(manyPriors).toBe(capCost); + }); +}); + +// ─── waitTicks ──────────────────────────────────────────────── + +describe('waitTicks', () => { + it('bail: first offense, no charges → base wait', () => { + expect(waitTicks('bail', 0, 0)).toBe(RECOVERY_CONFIG.BAIL_BASE_WAIT_TICKS); + }); + + it('hospital: first injury, light damage → base wait', () => { + expect(waitTicks('hospital', 0, 0)).toBe(RECOVERY_CONFIG.HOSPITAL_BASE_WAIT_TICKS); + }); + + it('increases with severity tier', () => { + const tier0 = waitTicks('bail', 0, 0); + const tier4 = waitTicks('bail', 10, 0); + expect(tier4).toBeGreaterThan(tier0); + }); + + it('increases with prior offenses', () => { + const first = waitTicks('bail', 0, 0); + const repeat = waitTicks('bail', 0, 5); + expect(repeat).toBeGreaterThan(first); + }); + + it('is capped at WAIT_TICKS_CAP', () => { + expect(waitTicks('bail', 10, 100)).toBe(RECOVERY_CONFIG.WAIT_TICKS_CAP); + expect(waitTicks('hospital', 100, 100)).toBe(RECOVERY_CONFIG.WAIT_TICKS_CAP); + }); +}); + +// ─── isLifer ───────────────────────────────────────────────── + +describe('isLifer', () => { + it('returns false below the threshold', () => { + expect(isLifer({ arrests: RECOVERY_CONFIG.LIFER_ARREST_THRESHOLD - 1 })).toBe(false); + }); + + it('returns true at and above the threshold', () => { + expect(isLifer({ arrests: RECOVERY_CONFIG.LIFER_ARREST_THRESHOLD })).toBe(true); + expect(isLifer({ arrests: 99 })).toBe(true); + }); +}); + +// ─── quoteRecovery ──────────────────────────────────────────── + +describe('quoteRecovery', () => { + it('returns null for a member who needs nothing', () => { + expect(quoteRecovery(member('a', 'active'), 10_000)).toBeNull(); + }); + + it('quotes bail for a jailed member', () => { + const q = quoteRecovery(member('a', 'jailed'), 10_000); + expect(q?.kind).toBe('bail'); + }); + + it('quotes hospital for an injured member', () => { + const q = quoteRecovery(member('a', 'injured'), 10_000); + expect(q?.kind).toBe('hospital'); + }); + + it('marks affordable when player can cover the cost', () => { + const q = quoteRecovery(member('a', 'jailed'), 999_999); + expect(q?.affordable).toBe(true); + }); + + it('marks unaffordable when player cannot cover the cost', () => { + const q = quoteRecovery(member('a', 'jailed'), 0); + expect(q?.affordable).toBe(false); + }); + + it('flags morale bleed once past grace period', () => { + const grace = RECOVERY_CONFIG.ABANDON_GRACE_TICKS; + expect(quoteRecovery(member('a', 'jailed'), 10_000, grace)?.costingMorale).toBe(false); + expect(quoteRecovery(member('a', 'jailed'), 10_000, grace + 1)?.costingMorale).toBe(true); + }); + + it('includes waitTicksRemaining', () => { + const q = quoteRecovery(member('a', 'jailed'), 10_000, 0, 0); + expect(q?.waitTicksRemaining).toBe(RECOVERY_CONFIG.BAIL_BASE_WAIT_TICKS); + }); + + it('waitTicksRemaining decreases as ticksHeld increases', () => { + const base = quoteRecovery(member('a', 'jailed'), 10_000, 0, 0)!.waitTicksRemaining; + const later = quoteRecovery(member('a', 'jailed'), 10_000, 2, 0)!.waitTicksRemaining; + expect(later).toBe(base - 2); + }); + + it('waitTicksRemaining floors at 0', () => { + const q = quoteRecovery(member('a', 'jailed'), 10_000, 999, 0); + expect(q?.waitTicksRemaining).toBe(0); + }); + + it('cost scales with severity value', () => { + const minor = quoteRecovery(member('a', 'jailed'), 10_000, 0, 0)!.cost; + const major = quoteRecovery(member('a', 'jailed'), 10_000, 0, 7)!.cost; + expect(major).toBeGreaterThan(minor); + }); + + it('cost scales with prior arrests', () => { + const first = quoteRecovery(member('a', 'jailed', 0), 10_000, 0, 0)!.cost; + const repeat = quoteRecovery(member('a', 'jailed', 3), 10_000, 0, 0)!.cost; + expect(repeat).toBeGreaterThan(first); + }); + + it('flags a lifer correctly', () => { + const lifer = member('a', 'jailed', RECOVERY_CONFIG.LIFER_ARREST_THRESHOLD); + const q = quoteRecovery(lifer, 10_000); + expect(q?.lifer).toBe(true); + }); + + it('non-lifer is not flagged', () => { + const q = quoteRecovery(member('a', 'jailed', 0), 10_000); + expect(q?.lifer).toBe(false); + }); + + it('includes severityTier in the quote', () => { + const q = quoteRecovery(member('a', 'jailed'), 10_000, 0, 5); + expect(q?.severityTier).toBe(3); // 5 charges → tier 3 (major) + }); +}); + +// ─── Detention tracking ────────────────────────────────────── + +describe('bailHospital — detention tracking', () => { + it('stamps a newly downed member with the current tick', () => { + const held = updateHeldSince([member('a', 'jailed')], {}, 7); + expect(held.a).toBe(7); + }); + + it('preserves the original stamp across ticks', () => { + const held = updateHeldSince([member('a', 'jailed')], { a: 2 }, 9); + expect(held.a).toBe(2); + }); + + it('drops a recovered member so a second stint starts fresh', () => { + const held = updateHeldSince([member('a', 'active')], { a: 2 }, 9); + expect(held.a).toBeUndefined(); + }); + + it('does not track members who need nothing', () => { + const held = updateHeldSince([member('a', 'active'), member('b', 'dead')], {}, 3); + expect(Object.keys(held)).toHaveLength(0); + }); +}); + +// ─── Abandonment penalty ───────────────────────────────────── + +describe('bailHospital — abandonment', () => { + const grace = RECOVERY_CONFIG.ABANDON_GRACE_TICKS; + + it('reports nobody overdue inside the grace period', () => { + expect(overdueMembers([member('a', 'jailed')], { a: 0 }, grace)).toHaveLength(0); + }); + + it('reports a member overdue once the grace period lapses', () => { + expect(overdueMembers([member('a', 'jailed')], { a: 0 }, grace + 1)).toEqual(['a']); + }); + + it('does not penalise a member with no recorded start tick', () => { + expect(overdueMembers([member('a', 'jailed')], {}, 99)).toHaveLength(0); + }); + + it('counts injured members as abandonable too', () => { + expect(overdueMembers([member('a', 'injured')], { a: 0 }, grace + 2)).toEqual(['a']); + }); + + it('ignores members who have since been recovered', () => { + expect(overdueMembers([member('a', 'active')], { a: 0 }, grace + 5)).toHaveLength(0); + }); + + it('drops morale by the configured percentage per abandoned member', () => { + expect(applyAbandonmentPenalty(100, 1)).toBe(95); + }); + + it('compounds the penalty across several abandoned members', () => { + expect(applyAbandonmentPenalty(100, 2)).toBe(90); + expect(applyAbandonmentPenalty(100, 3)).toBe(86); + }); + + it('leaves morale alone when nobody is abandoned', () => { + expect(applyAbandonmentPenalty(72, 0)).toBe(72); + }); + + it('never drives morale below zero', () => { + expect(applyAbandonmentPenalty(1, 50)).toBeGreaterThanOrEqual(0); + }); + + it('is compounding, not linear — it can never zero a crew outright', () => { + expect(applyAbandonmentPenalty(100, 20)).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/utils/__tests__/policeRaidEngine.test.ts b/frontend/src/utils/__tests__/policeRaidEngine.test.ts index b891505..040a90f 100644 --- a/frontend/src/utils/__tests__/policeRaidEngine.test.ts +++ b/frontend/src/utils/__tests__/policeRaidEngine.test.ts @@ -1,287 +1,402 @@ -// ============================================================ -// policeRaidEngine.test.ts — unit tests for the police raid engine -// ============================================================ +/** + * policeRaidEngine — time-based engine tests (Sprint 14-B) + * + * The engine advances off an explicit elapsed-ms value, so a whole + * 30-second raid resolves synchronously here with no fake timers. + */ import { describe, it, expect } from 'vitest'; import { createRaidState, - startEvacuation, - tickRaid, - getRaidSummary, - membersAtRisk, - RAID_DURATION_TICKS, - EVAC_TICKS, - RAID_GRID_COLS, - RAID_GRID_ROWS, + createRaidMember, + spawnUnits, + tapMember, + advanceRaid, + expireRaid, + evacProgress, + secondsRemaining, + caughtMembers, + savedMembers, + estimateHeld, + isTileThreatened, + RAID_CONFIG, } from '../policeRaidEngine'; +import type { BlockPlacement } from '../../types/block.types'; -// ─── Factory ───────────────────────────────────────────────── +// ─── Fixtures ──────────────────────────────────────────────── -describe('createRaidState', () => { - it('creates the correct number of police units', () => { - const state = createRaidState([], 4); - expect(state.police).toHaveLength(4); +function placement(over: Partial = {}): BlockPlacement { + return { + memberId: 'm1', + memberName: 'Trap Mike', + role: 'dealer', + x: 3, + y: 4, + zoneType: 'corner', + incomePerTick: 60, + exposureRisk: 20, + level: 2, + health: 100, + ...over, + } as BlockPlacement; +} + +// ─── RAID_CONFIG ───────────────────────────────────────────── + +describe('RAID_CONFIG', () => { + it('exports correct grid size', () => { + expect(RAID_CONFIG.GRID_SIZE).toBe(8); }); - it('sets phase to active and correct tick count', () => { - const state = createRaidState([], 2); - expect(state.phase).toBe('countdown'); - expect(state.ticksRemaining).toBe(RAID_DURATION_TICKS); + it('exports 30-second duration in ms', () => { + expect(RAID_CONFIG.DURATION_MS).toBe(30_000); }); - it('maps placements to RaidMembers correctly', () => { - const state = createRaidState([ - { memberId: 'm1', memberName: 'Dre', role: 'dealer', x: 2, y: 3, heldCash: 500, heldDrugs: 2 }, - ], 2); - expect(state.members).toHaveLength(1); - const m = state.members[0]; - expect(m.memberId).toBe('m1'); - expect(m.col).toBe(2); - expect(m.row).toBe(3); + it('exports 1-second advance interval', () => { + expect(RAID_CONFIG.ADVANCE_INTERVAL_MS).toBe(1_000); + }); + + it('exports 1.5-second evacuation duration', () => { + expect(RAID_CONFIG.EVAC_DURATION_MS).toBe(1_500); + }); +}); + +// ─── estimateHeld ───────────────────────────────────────────── + +describe('estimateHeld', () => { + it('derives cash from income per tick', () => { + const held = estimateHeld(placement({ incomePerTick: 60 })); + expect(held.cash).toBe(480); // 60 * 8 + }); + + it('derives drugs from income per tick', () => { + const held = estimateHeld(placement({ incomePerTick: 60 })); + expect(held.drugs).toBe(5); // round(60/12) + }); + + it('zero-income placement still carries at least one unit of product', () => { + const held = estimateHeld(placement({ incomePerTick: 0 })); + expect(held.drugs).toBeGreaterThanOrEqual(1); + }); +}); + +// ─── createRaidMember ───────────────────────────────────────── + +describe('createRaidMember', () => { + it('creates a deployed member from a placement', () => { + const m = createRaidMember(placement()); expect(m.status).toBe('deployed'); - expect(m.heldCash).toBe(500); - expect(m.heldDrugs).toBe(2); + expect(m.memberId).toBe('m1'); + expect(m.x).toBe(3); + expect(m.y).toBe(4); + expect(m.evacStartedAt).toBeNull(); }); - it('spawns police at top (row 0) and bottom (row GRID_ROWS-1) edges', () => { - const state = createRaidState([], 4); - const topCops = state.police.filter(c => c.row === 0); - const botCops = state.police.filter(c => c.row === RAID_GRID_ROWS - 1); - expect(topCops.length).toBeGreaterThan(0); - expect(botCops.length).toBeGreaterThan(0); + it('sets held cash from estimateHeld', () => { + const m = createRaidMember(placement({ incomePerTick: 60 })); + expect(m.heldCash).toBe(480); }); +}); - it('top cops have direction +1, bottom cops have direction -1', () => { - const state = createRaidState([], 4); - for (const cop of state.police) { - if (cop.row === 0) expect(cop.direction).toBe(1); - if (cop.row === RAID_GRID_ROWS - 1) expect(cop.direction).toBe(-1); - } +// ─── spawnUnits ─────────────────────────────────────────────── + +describe('spawnUnits', () => { + it('spawns a unit at each edge of every occupied column', () => { + const members = [ + createRaidMember(placement({ x: 2 })), + createRaidMember(placement({ memberId: 'm2', x: 5 })), + ]; + const units = spawnUnits(members); + expect(units).toHaveLength(4); + expect(units.filter((u) => u.y === 0)).toHaveLength(2); + expect(units.filter((u) => u.y === RAID_CONFIG.GRID_SIZE - 1)).toHaveLength(2); }); - it('handles zero placements gracefully', () => { - const state = createRaidState([], 2); - expect(state.members).toHaveLength(0); - expect(state.evacuated).toHaveLength(0); - expect(state.caught).toHaveLength(0); + it('does not spawn duplicate lanes for two members in one column', () => { + const members = [ + createRaidMember(placement({ x: 4, y: 2 })), + createRaidMember(placement({ memberId: 'm2', x: 4, y: 6 })), + ]; + expect(spawnUnits(members)).toHaveLength(2); }); - it('defaults heldCash and heldDrugs to 0 when not provided', () => { - const state = createRaidState([ - { memberId: 'm1', memberName: 'X', role: 'shooter', x: 0, y: 0 }, - ], 1); - expect(state.members[0].heldCash).toBe(0); - expect(state.members[0].heldDrugs).toBe(0); + it('still spawns a token pair when nobody is deployed', () => { + expect(spawnUnits([])).toHaveLength(2); }); }); -// ─── startEvacuation ───────────────────────────────────────── +// ─── createRaidState ───────────────────────────────────────── -describe('startEvacuation', () => { - it('transitions a deployed member to evacuating', () => { - const state = createRaidState([ - { memberId: 'm1', memberName: 'Dre', role: 'dealer', x: 3, y: 3 }, - ], 2); - const active = { ...state, phase: 'active' as const }; - const next = startEvacuation(active, 'm1'); - const m = next.members.find(m => m.memberId === 'm1')!; - expect(m.status).toBe('evacuating'); - expect(m.evacTicksLeft).toBe(EVAC_TICKS); - }); - - it('does not change members that are not the target', () => { - const state = createRaidState([ - { memberId: 'm1', memberName: 'A', role: 'dealer', x: 1, y: 1 }, - { memberId: 'm2', memberName: 'B', role: 'shooter', x: 5, y: 5 }, - ], 2); - const active = { ...state, phase: 'active' as const }; - const next = startEvacuation(active, 'm1'); - const m2 = next.members.find(m => m.memberId === 'm2')!; - expect(m2.status).toBe('deployed'); - }); - - it('is a no-op when phase is not active', () => { - const state = createRaidState([ - { memberId: 'm1', memberName: 'A', role: 'dealer', x: 1, y: 1 }, - ], 2); - // phase is 'countdown' by default - const next = startEvacuation(state, 'm1'); - expect(next.members[0].status).toBe('deployed'); - }); - - it('does not re-evacuate an already evacuating member', () => { - const state = createRaidState([ - { memberId: 'm1', memberName: 'A', role: 'dealer', x: 1, y: 1 }, - ], 2); - const active = { ...state, phase: 'active' as const }; - const once = startEvacuation(active, 'm1'); - const twice = startEvacuation(once, 'm1'); - // evacTicksLeft should not reset - expect(twice.members[0].evacTicksLeft).toBe(EVAC_TICKS); +describe('createRaidState', () => { + it('starts in_progress with no seizures', () => { + const s = createRaidState([placement()]); + expect(s.outcome).toBe('in_progress'); + expect(s.seizedCash).toBe(0); + expect(s.seizedDrugs).toBe(0); + expect(s.elapsedMs).toBe(0); + }); + + it('creates members from placements', () => { + const s = createRaidState([placement(), placement({ memberId: 'm2', x: 5 })]); + expect(s.members).toHaveLength(2); + }); + + it('stores the blockId', () => { + const s = createRaidState([placement()], 'blk-99'); + expect(s.blockId).toBe('blk-99'); + }); + + it('handles zero placements gracefully', () => { + const s = createRaidState([]); + expect(s.members).toHaveLength(0); + expect(s.seizedCash).toBe(0); }); }); -// ─── tickRaid ──────────────────────────────────────────────── +// ─── tapMember ──────────────────────────────────────────────── -describe('tickRaid', () => { - it('decrements ticksRemaining by 1 each tick', () => { - const state = { ...createRaidState([], 2), phase: 'active' as const }; - const next = tickRaid(state); - expect(next.ticksRemaining).toBe(RAID_DURATION_TICKS - 1); +describe('tapMember', () => { + it('starts evacuation for a deployed member', () => { + const s = createRaidState([placement()]); + const next = tapMember(s, 'm1'); + const m = next.members.find((m) => m.memberId === 'm1')!; + expect(m.status).toBe('evacuating'); + expect(m.evacStartedAt).toBe(0); }); - it('advances police toward the centre', () => { - const state = { ...createRaidState([], 2), phase: 'active' as const }; - const topCop = state.police.find(c => c.direction === 1)!; - const next = tickRaid(state); - const movedCop = next.police.find(c => c.id === topCop.id)!; - expect(movedCop.row).toBe(topCop.row + 1); + it('does not restart an already-evacuating member', () => { + let s = createRaidState([placement()]); + s = tapMember(s, 'm1'); + s = advanceRaid(s, 500); + const before = s.members.find((m) => m.memberId === 'm1')!.evacStartedAt; + const next = tapMember(s, 'm1'); + const after = next.members.find((m) => m.memberId === 'm1')!.evacStartedAt; + expect(after).toBe(before); }); - it('completes evacuation after EVAC_TICKS ticks', () => { - let state = createRaidState([ - { memberId: 'm1', memberName: 'A', role: 'dealer', x: 4, y: 4 }, - ], 0); // 0 police so no collisions - state = { ...state, phase: 'active' as const }; - state = startEvacuation(state, 'm1'); - for (let i = 0; i < EVAC_TICKS; i++) { - state = tickRaid(state); - } - expect(state.evacuated).toHaveLength(1); - expect(state.evacuated[0].memberId).toBe('m1'); - }); - - it('catches a member when police reach their tile', () => { - // Place member at row 1 and a top cop at row 0, same col - const state = createRaidState([ - { memberId: 'm1', memberName: 'A', role: 'dealer', x: 0, y: 1, heldCash: 200 }, - ], 1); - // Force the police to be at col 0, row 0 - const forcedState = { - ...state, - phase: 'active' as const, - police: [{ id: 'cop-top-0', col: 0, row: 0, direction: 1 as const }], + it('is a no-op for a caught member', () => { + const s = createRaidState([placement()]); + const caught = { + ...s, + members: s.members.map((m) => ({ ...m, status: 'caught' as const })), }; - const next = tickRaid(forcedState); - expect(next.caught).toHaveLength(1); - expect(next.caught[0].memberId).toBe('m1'); - expect(next.cashSeized).toBe(200); - }); - - it('resolves when all members are evacuated', () => { - let state = createRaidState([ - { memberId: 'm1', memberName: 'A', role: 'dealer', x: 4, y: 4 }, - ], 0); - state = { ...state, phase: 'active' as const }; - state = startEvacuation(state, 'm1'); - for (let i = 0; i < EVAC_TICKS; i++) { - state = tickRaid(state); + const next = tapMember(caught, 'm1'); + expect(next.members[0].status).toBe('caught'); + }); + + it('is a no-op for an unknown memberId', () => { + const s = createRaidState([placement()]); + const next = tapMember(s, 'no-such-member'); + expect(next).toBe(s); + }); +}); + +// ─── advanceRaid ───────────────────────────────────────────── + +describe('advanceRaid', () => { + it('is a no-op when outcome is not in_progress', () => { + const s = createRaidState([]); + const expired = expireRaid(s); + const next = advanceRaid(expired, 5_000); + expect(next).toBe(expired); + }); + + it('moves police units forward over time', () => { + const s = createRaidState([placement({ x: 3, y: 4 })]); + const next = advanceRaid(s, 1_000); + const topUnit = next.units.find((u) => u.x === 3 && u.spawnY === 0)!; + expect(topUnit.y).toBe(1); + }); + + it('catches a member when a police unit reaches their tile', () => { + // Place member at y=1 so top unit (starting at y=0) reaches them after 1 second + const s = createRaidState([placement({ x: 3, y: 1 })]); + const next = advanceRaid(s, 1_000); + const m = next.members.find((m) => m.memberId === 'm1')!; + expect(m.status).toBe('caught'); + expect(next.seizedCash).toBeGreaterThan(0); + }); + + it('marks a member safe when evacuation completes', () => { + let s = createRaidState([placement({ x: 3, y: 4 })]); + s = tapMember(s, 'm1'); // evacStartedAt = 0 + s = advanceRaid(s, 2_000); // past EVAC_DURATION_MS (1500ms) + const m = s.members.find((m) => m.memberId === 'm1')!; + expect(m.status).toBe('safe'); + }); + + it('resolves clean when all members evacuate', () => { + let s = createRaidState([placement({ x: 3, y: 4 })]); + s = tapMember(s, 'm1'); + s = advanceRaid(s, 2_000); + expect(s.outcome).toBe('clean'); + }); + + it('resolves disaster when all members are caught', () => { + // Top unit spawns at y=0 and advances to y=1 after 1 s (ADVANCE_INTERVAL_MS). + // Place the only member at y=1 so the unit catches them at exactly 1 s. + let s = createRaidState([placement({ x: 3, y: 1 })]); + s = advanceRaid(s, 1_000); + expect(s.outcome).toBe('disaster'); + }); + + it('units stop at grid edges', () => { + const s = createRaidState([placement()]); + const next = advanceRaid(s, 100_000); + for (const unit of next.units) { + expect(unit.y).toBeGreaterThanOrEqual(0); + expect(unit.y).toBeLessThanOrEqual(RAID_CONFIG.GRID_SIZE - 1); } - expect(state.phase).toBe('resolved'); - }); - - it('resolves when timer runs out and catches remaining members', () => { - let state = createRaidState([ - { memberId: 'm1', memberName: 'A', role: 'dealer', x: 4, y: 4, heldCash: 100 }, - ], 0); - state = { ...state, phase: 'active' as const, ticksRemaining: 1 }; - state = tickRaid(state); - expect(state.phase).toBe('resolved'); - expect(state.caught).toHaveLength(1); - expect(state.cashSeized).toBe(100); - }); - - it('is a no-op when already resolved', () => { - const state = createRaidState([], 2); - const resolved = { ...state, phase: 'resolved' as const }; - const next = tickRaid(resolved); - expect(next).toBe(resolved); - }); - - it('does not move police past grid edges', () => { - const state = createRaidState([], 2); - const botCop = state.police.find(c => c.direction === -1)!; - // Force the cop to row 0 (already at top edge, direction -1 means it would go to -1) - const forcedState = { - ...state, - phase: 'active' as const, - police: [{ ...botCop, row: 0 }], - }; - const next = tickRaid(forcedState); - // Should stay at row 0, not go to -1 - expect(next.police[0].row).toBe(0); }); }); -// ─── membersAtRisk ──────────────────────────────────────────── +// ─── expireRaid ─────────────────────────────────────────────── + +describe('expireRaid', () => { + it('catches all remaining deployed members', () => { + const s = createRaidState([ + placement({ memberId: 'm1', x: 3, y: 4 }), + placement({ memberId: 'm2', x: 5, y: 2 }), + ]); + const expired = expireRaid(s); + expect(caughtMembers(expired)).toHaveLength(2); + }); + + it('does not re-catch already-safe members', () => { + let s = createRaidState([ + placement({ memberId: 'm1', x: 3, y: 4 }), + placement({ memberId: 'm2', x: 5, y: 2 }), + ]); + s = tapMember(s, 'm1'); + s = advanceRaid(s, 2_000); // m1 is now safe + const expired = expireRaid(s); + const safe = savedMembers(expired); + expect(safe.some((m) => m.memberId === 'm1')).toBe(true); + expect(caughtMembers(expired).some((m) => m.memberId === 'm1')).toBe(false); + }); + + it('sets elapsedMs to DURATION_MS', () => { + const s = createRaidState([]); + const expired = expireRaid(s); + expect(expired.elapsedMs).toBe(RAID_CONFIG.DURATION_MS); + }); -describe('membersAtRisk', () => { - it('counts deployed and evacuating members', () => { - let state = createRaidState([ - { memberId: 'm1', memberName: 'A', role: 'dealer', x: 1, y: 1 }, - { memberId: 'm2', memberName: 'B', role: 'shooter', x: 5, y: 5 }, - ], 0); - state = { ...state, phase: 'active' as const }; - expect(membersAtRisk(state)).toBe(2); - state = startEvacuation(state, 'm1'); - expect(membersAtRisk(state)).toBe(2); // still at risk while evacuating + it('resolves disaster when all members were deployed', () => { + const s = createRaidState([placement()]); + const expired = expireRaid(s); + expect(expired.outcome).toBe('disaster'); }); - it('returns 0 when all are resolved', () => { - const state = createRaidState([], 0); - expect(membersAtRisk(state)).toBe(0); + it('resolves clean when all members were already safe', () => { + let s = createRaidState([placement()]); + s = tapMember(s, 'm1'); + s = advanceRaid(s, 2_000); + const expired = expireRaid(s); + expect(expired.outcome).toBe('clean'); }); }); -// ─── getRaidSummary ─────────────────────────────────────────── - -describe('getRaidSummary', () => { - it('returns correct evacuated and caught counts', () => { - const state = createRaidState([], 0); - const withResults = { - ...state, - phase: 'resolved' as const, - evacuated: [{ memberId: 'm1', memberName: 'A', role: 'dealer', col: 0, row: 0, status: 'evacuated' as const, evacTicksLeft: 0, heldCash: 0, heldDrugs: 0 }], - caught: [{ memberId: 'm2', memberName: 'B', role: 'shooter', col: 1, row: 1, status: 'caught' as const, evacTicksLeft: 0, heldCash: 500, heldDrugs: 1 }], - cashSeized: 500, - drugsSeized: 1, - }; - const summary = getRaidSummary(withResults); - expect(summary.evacuated).toBe(1); - expect(summary.caught).toBe(1); - expect(summary.cashSeized).toBe(500); - expect(summary.drugsSeized).toBe(1); - }); - - it('calculates heatReduction proportional to caught ratio', () => { - const state = createRaidState([], 0); - // All caught → max heat reduction - const allCaught = { - ...state, - phase: 'resolved' as const, - evacuated: [], - caught: [ - { memberId: 'm1', memberName: 'A', role: 'dealer', col: 0, row: 0, status: 'caught' as const, evacTicksLeft: 0, heldCash: 0, heldDrugs: 0 }, - { memberId: 'm2', memberName: 'B', role: 'dealer', col: 1, row: 1, status: 'caught' as const, evacTicksLeft: 0, heldCash: 0, heldDrugs: 0 }, - ], - cashSeized: 0, - drugsSeized: 0, - }; - const summary = getRaidSummary(allCaught); - expect(summary.heatReduction).toBe(2); // 100% caught → 2 heat reduction +// ─── secondsRemaining ──────────────────────────────────────── + +describe('secondsRemaining', () => { + it('returns 30 at the start', () => { + const s = createRaidState([]); + expect(secondsRemaining(s)).toBe(30); + }); + + it('returns 0 after expiry', () => { + const s = expireRaid(createRaidState([])); + expect(secondsRemaining(s)).toBe(0); + }); + + it('counts down correctly', () => { + const s = advanceRaid(createRaidState([placement()]), 10_000); + expect(secondsRemaining(s)).toBe(20); }); +}); + +// ─── evacProgress ──────────────────────────────────────────── + +describe('evacProgress', () => { + it('returns 0 for a deployed member', () => { + const s = createRaidState([placement()]); + const m = s.members[0]; + expect(evacProgress(m, 0)).toBe(0); + }); + + it('returns 1 for a safe member', () => { + let s = createRaidState([placement()]); + s = tapMember(s, 'm1'); + s = advanceRaid(s, 2_000); + const m = s.members.find((m) => m.memberId === 'm1')!; + expect(evacProgress(m, s.elapsedMs)).toBe(1); + }); + + it('returns a fraction during evacuation', () => { + let s = createRaidState([placement({ x: 3, y: 4 })]); + s = tapMember(s, 'm1'); // evacStartedAt = 0 + const m = s.members[0]; + const progress = evacProgress(m, 750); + expect(progress).toBeCloseTo(0.5); + }); + + it('clamps to 1 even if elapsed exceeds evac duration', () => { + let s = createRaidState([placement()]); + s = tapMember(s, 'm1'); + const m = s.members[0]; + expect(evacProgress(m, 99_999)).toBe(1); + }); +}); - it('returns 0 heatReduction when no members total', () => { - const state = createRaidState([], 0); - const summary = getRaidSummary({ ...state, phase: 'resolved' as const }); - expect(summary.heatReduction).toBe(0); +// ─── caughtMembers / savedMembers ──────────────────────────── + +describe('caughtMembers / savedMembers', () => { + it('caughtMembers returns only caught members', () => { + const s = createRaidState([ + placement({ memberId: 'm1', x: 3, y: 1 }), + placement({ memberId: 'm2', x: 5, y: 4 }), + ]); + const next = advanceRaid(s, 1_000); // m1 caught + expect(caughtMembers(next).map((m) => m.memberId)).toContain('m1'); + expect(caughtMembers(next).map((m) => m.memberId)).not.toContain('m2'); + }); + + it('savedMembers returns only safe members', () => { + let s = createRaidState([placement()]); + s = tapMember(s, 'm1'); + s = advanceRaid(s, 2_000); + expect(savedMembers(s).map((m) => m.memberId)).toContain('m1'); + }); + + it('returns empty arrays when no members match', () => { + const s = createRaidState([placement()]); + expect(caughtMembers(s)).toHaveLength(0); + expect(savedMembers(s)).toHaveLength(0); }); }); -// ─── Grid constants ─────────────────────────────────────────── +// ─── isTileThreatened ──────────────────────────────────────── + +describe('isTileThreatened', () => { + it('returns true for a tile a unit is on', () => { + const s = createRaidState([placement({ x: 3, y: 4 })]); + // Top unit starts at y=0, x=3 + expect(isTileThreatened(s, 3, 0)).toBe(true); + }); + + it('returns true for a tile one step ahead of a unit', () => { + const s = createRaidState([placement({ x: 3, y: 4 })]); + // Top unit at y=0, direction=1 — y=1 is one step ahead + expect(isTileThreatened(s, 3, 1)).toBe(true); + }); + + it('returns false for a tile not near any unit', () => { + const s = createRaidState([placement({ x: 3, y: 4 })]); + expect(isTileThreatened(s, 3, 4)).toBe(false); + }); -describe('grid constants', () => { - it('exports correct grid dimensions', () => { - expect(RAID_GRID_COLS).toBe(8); - expect(RAID_GRID_ROWS).toBe(8); + it('returns false for a different column', () => { + const s = createRaidState([placement({ x: 3, y: 4 })]); + // Column 0 has no units (only column 3 does) + expect(isTileThreatened(s, 0, 0)).toBe(false); }); }); diff --git a/frontend/src/utils/bailHospitalSystem.ts b/frontend/src/utils/bailHospitalSystem.ts new file mode 100644 index 0000000..ae31c29 --- /dev/null +++ b/frontend/src/utils/bailHospitalSystem.ts @@ -0,0 +1,361 @@ +// ============================================================ +// SLIDE — Bail & Hospital System (Sprint 14-B, Task 2) +// frontend/src/utils/bailHospitalSystem.ts +// +// Handles the cost and wait-time logic for getting jailed or injured +// gang members back to the block. +// +// DESIGN (per player spec): +// • Base cost scales with severity — how much damage was taken +// (hospital) or how many charges were filed (bail). +// • Repeat offenses compound cost and wait time — a member who has +// been arrested 3 times before is harder and more expensive to +// spring than someone who caught their first case. +// • The player can pay to skip the wait entirely. The amount of time +// they wait without paying depends on the same severity + history +// factors. +// • Members not bailed / treated past the grace period bleed morale. +// +// Pure and store-free — the component / game loop owns state, this +// module owns the rules. +// ============================================================ + +import type { GangMember, MemberStatus } from '../types/game.types'; + +// ─── Config ────────────────────────────────────────────────── + +export const RECOVERY_CONFIG = { + // ── Base costs ────────────────────────────────────────── + /** Minimum bail cost (first offense, minor charges). */ + BAIL_BASE_COST: 1_500, + /** Minimum hospital cost (light injuries). */ + HOSPITAL_BASE_COST: 800, + + // ── Severity multipliers ──────────────────────────────── + /** + * Each severity tier (0-4) multiplies the base cost. + * Tier 0 = minor, Tier 4 = critical / life sentence risk. + */ + SEVERITY_COST_MULTIPLIERS: [1.0, 1.5, 2.5, 4.0, 7.0] as const, + + // ── Repeat-offense scaling ────────────────────────────── + /** + * Each prior arrest / hospitalization adds this fraction to the cost. + * e.g., 0.25 means a third arrest (priorCount=2) costs 1 + 2*0.25 = 1.5×. + */ + REPEAT_COST_FACTOR: 0.25, + /** + * Hard cap on the repeat multiplier so costs don't become impossible. + * A member with 10+ priors still has a finite cost. + */ + REPEAT_COST_CAP: 3.0, + + // ── Wait times (in game ticks) ─────────────────────────── + /** Base wait ticks for bail (first offense, minor charges). */ + BAIL_BASE_WAIT_TICKS: 6, + /** Base wait ticks for hospital (light injuries). */ + HOSPITAL_BASE_WAIT_TICKS: 3, + /** Each severity tier adds this many ticks to the base wait. */ + SEVERITY_WAIT_TICKS_PER_TIER: 4, + /** Each prior offense adds this many ticks to the wait. */ + REPEAT_WAIT_TICKS_PER_PRIOR: 2, + /** + * Hard cap on total wait ticks. A member caught too many times will + * eventually be in jail for life — represented here as a very long + * wait that the player cannot afford to skip. + */ + WAIT_TICKS_CAP: 60, + /** + * If a member has been arrested this many times or more, they are + * considered a "lifer" — the system still quotes a cost and wait, but + * the UI should warn the player that release is unlikely. + */ + LIFER_ARREST_THRESHOLD: 5, + + // ── Abandonment ───────────────────────────────────────── + /** Ticks a member can sit unrecovered before the crew reacts. */ + ABANDON_GRACE_TICKS: 3, + /** Morale lost per abandoned member, as a percentage of current. */ + ABANDON_MORALE_PENALTY_PCT: 5, + + // ── Legacy flat costs (kept for backward compat) ──────── + /** @deprecated Use scaledBailCost() instead. */ + BAIL_COST: 5_000, + /** @deprecated Use scaledHospitalCost() instead. */ + HOSPITAL_COST: 2_000, +} as const; + +// ─── Severity ──────────────────────────────────────────────── + +/** + * Severity tier 0-4. + * + * For bail: derived from arrest count in a single incident (charges). + * For hospital: derived from damage taken (0-100 health lost). + */ +export type SeverityTier = 0 | 1 | 2 | 3 | 4; + +/** + * Map a raw severity value to a tier. + * + * @param value - For bail: number of charges (0-10+). + * For hospital: health damage taken (0-100). + * @param kind - 'bail' or 'hospital' (different thresholds). + */ +export function severityTier(value: number, kind: RecoveryKind): SeverityTier { + if (kind === 'bail') { + // charges: 0 = minor, 1-2 = moderate, 3-4 = serious, 5-6 = major, 7+ = critical + if (value <= 0) return 0; + if (value <= 2) return 1; + if (value <= 4) return 2; + if (value <= 6) return 3; + return 4; + } else { + // health damage: <20 = minor, 20-39 = moderate, 40-59 = serious, 60-79 = major, 80+ = critical + if (value < 20) return 0; + if (value < 40) return 1; + if (value < 60) return 2; + if (value < 80) return 3; + return 4; + } +} + +// ─── Types ─────────────────────────────────────────────────── + +export type RecoveryKind = 'bail' | 'hospital'; + +/** Statuses recoverable by posting bail. */ +const JAILED_STATUSES: MemberStatus[] = ['jailed', 'arrested']; +/** Statuses recoverable by paying medical bills. */ +const INJURED_STATUSES: MemberStatus[] = ['injured', 'hospitalized', 'hospital']; + +// ─── Status helpers ────────────────────────────────────────── + +export function isJailed(member: Pick): boolean { + return JAILED_STATUSES.includes(member.status); +} + +export function isInjured(member: Pick): boolean { + return INJURED_STATUSES.includes(member.status); +} + +/** Members needing recovery. Dead and backdoored members are not on this list. */ +export function needsRecovery(member: Pick): boolean { + return isJailed(member) || isInjured(member); +} + +export function recoveryKindFor( + member: Pick, +): RecoveryKind | null { + if (isJailed(member)) return 'bail'; + if (isInjured(member)) return 'hospital'; + return null; +} + +// ─── Scaled cost ───────────────────────────────────────────── + +/** + * Compute the bail cost for a jailed member. + * + * @param charges - Number of charges filed (determines severity tier). + * @param priorArrests - Number of times this member has been arrested before. + */ +export function scaledBailCost(charges: number, priorArrests: number): number { + const tier = severityTier(charges, 'bail'); + const severityMult = RECOVERY_CONFIG.SEVERITY_COST_MULTIPLIERS[tier]; + const repeatMult = Math.min( + RECOVERY_CONFIG.REPEAT_COST_CAP, + 1 + priorArrests * RECOVERY_CONFIG.REPEAT_COST_FACTOR, + ); + return Math.round(RECOVERY_CONFIG.BAIL_BASE_COST * severityMult * repeatMult); +} + +/** + * Compute the hospital cost for an injured member. + * + * @param damageTaken - Health lost (0-100). + * @param priorInjuries - Number of times this member has been hospitalized before. + */ +export function scaledHospitalCost(damageTaken: number, priorInjuries: number): number { + const tier = severityTier(damageTaken, 'hospital'); + const severityMult = RECOVERY_CONFIG.SEVERITY_COST_MULTIPLIERS[tier]; + const repeatMult = Math.min( + RECOVERY_CONFIG.REPEAT_COST_CAP, + 1 + priorInjuries * RECOVERY_CONFIG.REPEAT_COST_FACTOR, + ); + return Math.round(RECOVERY_CONFIG.HOSPITAL_BASE_COST * severityMult * repeatMult); +} + +/** + * Flat cost for a member (legacy path, uses status only). + * Prefer scaledBailCost / scaledHospitalCost when severity data is available. + */ +export function recoveryCost(member: Pick): number { + const kind = recoveryKindFor(member); + if (kind === 'bail') return RECOVERY_CONFIG.BAIL_COST; + if (kind === 'hospital') return RECOVERY_CONFIG.HOSPITAL_COST; + return 0; +} + +// ─── Wait time ─────────────────────────────────────────────── + +/** + * Compute how many game ticks a member must wait before they are + * automatically released (without the player paying). + * + * @param kind - 'bail' or 'hospital'. + * @param severityValue - Charges (bail) or damage taken (hospital). + * @param priorCount - Prior arrests (bail) or prior hospitalizations (hospital). + */ +export function waitTicks( + kind: RecoveryKind, + severityValue: number, + priorCount: number, +): number { + const tier = severityTier(severityValue, kind); + const base = + kind === 'bail' + ? RECOVERY_CONFIG.BAIL_BASE_WAIT_TICKS + : RECOVERY_CONFIG.HOSPITAL_BASE_WAIT_TICKS; + const severityAdd = tier * RECOVERY_CONFIG.SEVERITY_WAIT_TICKS_PER_TIER; + const repeatAdd = priorCount * RECOVERY_CONFIG.REPEAT_WAIT_TICKS_PER_PRIOR; + return Math.min(RECOVERY_CONFIG.WAIT_TICKS_CAP, base + severityAdd + repeatAdd); +} + +/** + * True if a member's arrest history qualifies them as a "lifer" — + * the system still quotes costs, but the UI should warn the player. + */ +export function isLifer(member: Pick): boolean { + return member.arrests >= RECOVERY_CONFIG.LIFER_ARREST_THRESHOLD; +} + +// ─── Quote ─────────────────────────────────────────────────── + +export interface RecoveryQuote { + memberId: string; + memberName: string; + kind: RecoveryKind; + /** Cost to pay now and skip the wait entirely. */ + cost: number; + affordable: boolean; + /** Ticks this member has been out of action, when tracked. */ + ticksHeld: number; + /** True once the grace period has lapsed and morale is bleeding. */ + costingMorale: boolean; + /** Ticks remaining before automatic release (if player does not pay). */ + waitTicksRemaining: number; + /** True if the member's history makes them unlikely to be released. */ + lifer: boolean; + /** Severity tier (0-4) for UI display. */ + severityTier: SeverityTier; +} + +/** + * Full recovery quote for a member. + * + * @param member - The gang member. + * @param playerMoney - Current player balance. + * @param ticksHeld - How many ticks this member has been detained. + * @param severityValue - Charges filed (bail) or damage taken (hospital). + * Defaults to 0 (minimum cost) when not provided. + */ +export function quoteRecovery( + member: Pick & { + /** Number of prior hospitalizations, if available. */ + priorInjuries?: number; + }, + playerMoney: number, + ticksHeld = 0, + severityValue = 0, +): RecoveryQuote | null { + const kind = recoveryKindFor(member); + if (!kind) return null; + + const priorCount = + kind === 'bail' ? (member.arrests ?? 0) : (member.priorInjuries ?? 0); + + const cost = + kind === 'bail' + ? scaledBailCost(severityValue, priorCount) + : scaledHospitalCost(severityValue, priorCount); + + const totalWait = waitTicks(kind, severityValue, priorCount); + const waitTicksRemaining = Math.max(0, totalWait - ticksHeld); + const tier = severityTier(severityValue, kind); + + return { + memberId: member.id, + memberName: member.name, + kind, + cost, + affordable: playerMoney >= cost, + ticksHeld, + costingMorale: ticksHeld > RECOVERY_CONFIG.ABANDON_GRACE_TICKS, + waitTicksRemaining, + lifer: isLifer(member), + severityTier: tier, + }; +} + +// ─── Abandonment penalty ───────────────────────────────────── + +/** + * Members held past the grace period. + * + * `heldSince` maps memberId to the tick they went down. Anyone missing + * from it is treated as newly detained rather than instantly overdue — + * a member captured before tick tracking existed should not trigger a + * penalty the moment the feature ships. + */ +export function overdueMembers( + members: Array>, + heldSince: Record, + currentTick: number, +): string[] { + return members + .filter((m) => needsRecovery(m)) + .filter((m) => { + const since = heldSince[m.id]; + if (since === undefined) return false; + return currentTick - since > RECOVERY_CONFIG.ABANDON_GRACE_TICKS; + }) + .map((m) => m.id); +} + +/** + * Morale after applying the penalty for abandoned members. + * + * Percentage of current, compounded per member, so abandoning four + * people hurts more than abandoning one but never zeroes morale + * outright — a crew that feels bad is still a crew. + */ +export function applyAbandonmentPenalty( + currentMorale: number, + abandonedCount: number, +): number { + if (abandonedCount <= 0) return currentMorale; + const factor = 1 - RECOVERY_CONFIG.ABANDON_MORALE_PENALTY_PCT / 100; + const next = currentMorale * Math.pow(factor, abandonedCount); + return Math.max(0, Math.round(next)); +} + +/** + * Track when members went down. + * + * Returns a new map: newly-down members get stamped with the current + * tick, recovered members are dropped so a second stint starts its own + * grace period rather than inheriting the first one's clock. + */ +export function updateHeldSince( + members: Array>, + heldSince: Record, + currentTick: number, +): Record { + const next: Record = {}; + for (const member of members) { + if (!needsRecovery(member)) continue; + next[member.id] = heldSince[member.id] ?? currentTick; + } + return next; +} diff --git a/frontend/src/utils/gameLoopEngine.ts b/frontend/src/utils/gameLoopEngine.ts index 26177ed..7659ec4 100644 --- a/frontend/src/utils/gameLoopEngine.ts +++ b/frontend/src/utils/gameLoopEngine.ts @@ -31,6 +31,14 @@ import { getMoraleDescription, type MoraleFactors, } from './moraleSystem'; +import { + overdueMembers, + updateHeldSince, + applyAbandonmentPenalty, + RECOVERY_CONFIG, +} from './bailHospitalSystem'; +import { selectRaidTarget, suppressesBackgroundRaid } from './raidTrigger'; +import { useNavigationStore } from '../stores/gameStore'; import { usePlayerStore, useGangStore, @@ -223,6 +231,15 @@ export function useGameLoop(): GameLoopState { const intervalRef = useRef | null>(null); const tickRef = useRef(0); + /** + * memberId -> tick they went down. Held in a ref, not state: it feeds + * the next tick's penalty calculation and must never itself trigger a + * re-render mid-loop. + */ + const heldSinceRef = useRef>({}); + + /** blockId -> tick of its last interactive raid, for cooldown. */ + const lastRaidTickRef = useRef>({}); const getStores = useCallback((): GameStores => ({ player: usePlayerStore.getState(), @@ -405,7 +422,40 @@ export function useGameLoop(): GameLoopState { level: updatedHeat, }; - if (rollForRaid(raidHeatState)) { + // ── Interactive raid (Sprint 14-B) ── + // A block at max heat hands control to PoliceRaidGame instead of + // resolving in the background, and suppresses the dice roll below so + // the same crew cannot be jailed twice for one tick. + const blockStore = useBlockStore.getState(); + const raidTarget = selectRaidTarget( + blockStore.blocks, + lastRaidTickRef.current, + tickRef.current, + ); + + if (raidTarget) { + lastRaidTickRef.current[raidTarget.blockId] = tickRef.current; + blockStore.selectBlock(raidTarget.blockId); + + const raidWarning = createEvent( + 'raid', + '🚨 RAID IN PROGRESS', + 'Heat maxed out. Police are hitting the block — get your people out.', + 'critical', + ); + setLastEvent(raidWarning); + stores.notifications.addNotification({ + type: 'danger', + title: raidWarning.title, + message: raidWarning.message, + priority: 'critical', + timestamp: Date.now(), + }); + + useNavigationStore.getState().navigateTo('raid'); + } + + if (!suppressesBackgroundRaid(raidTarget) && rollForRaid(raidHeatState)) { const memberIds = members.map((m) => m.id); const drugCount = stores.economy.inventory .filter((i) => i.type === 'drug') @@ -532,7 +582,32 @@ export function useGameLoop(): GameLoopState { playerReputation: player.reputation, }; - const morale = calculateMorale(factors); + let morale = calculateMorale(factors); + + // ── Abandonment penalty (Sprint 14-B) ── + // Members left jailed or injured past the grace period cost the + // crew morale every check, compounding until they are recovered. + heldSinceRef.current = updateHeldSince( + stores.gang.members, + heldSinceRef.current, + tickRef.current, + ); + const abandoned = overdueMembers( + stores.gang.members, + heldSinceRef.current, + tickRef.current, + ); + if (abandoned.length > 0) { + morale = applyAbandonmentPenalty(morale, abandoned.length); + stores.notifications.addNotification({ + type: 'warning', + title: 'Crew Left Behind', + message: `${abandoned.length} ${abandoned.length === 1 ? 'member has' : 'members have'} been down more than ${RECOVERY_CONFIG.ABANDON_GRACE_TICKS} ticks. Morale is slipping — bail them out from the CREW app.`, + priority: 'high', + timestamp: Date.now(), + }); + } + setGangMorale(morale); const moraleDesc = getMoraleDescription(morale); diff --git a/frontend/src/utils/policeRaidEngine.ts b/frontend/src/utils/policeRaidEngine.ts index 9f75d35..fd92cfe 100644 --- a/frontend/src/utils/policeRaidEngine.ts +++ b/frontend/src/utils/policeRaidEngine.ts @@ -1,261 +1,296 @@ // ============================================================ -// policeRaidEngine.ts — pure game logic for the Police Raid -// mini-game. No Phaser, no React — fully testable in Node. +// SLIDE — Police Raid engine (Sprint 14-B, Task 1) +// frontend/src/utils/policeRaidEngine.ts // -// Rules: -// • 8×8 grid, same layout as the block -// • Police units spawn at row 0 and row 7 edges, advance -// 1 tile per tick toward the centre -// • Player taps a deployed member to begin evacuation -// (takes EVAC_TICKS ticks to complete) -// • If a police unit reaches a member's tile before they -// evacuate, that member is "caught" (jailed) -// • Game ends when all members are evacuated or caught, -// or when the timer runs out +// A timed grid-clear. Police enter from the top and bottom edges and +// walk inward one tile per second. The player has 30 seconds to tap +// each deployed member and start a 1.5s evacuation. A unit reaching an +// occupied tile before that evacuation completes takes the member. +// +// Pure and store-free, like bipNDipEngine — the component owns the +// clock, this owns the rules. Everything advances off an explicit +// elapsed-milliseconds value rather than reading Date.now(), so a test +// can drive a whole raid deterministically. +// +// NOTE ON SCOPE: this is the *interactive* raid. The existing +// heatSystem.executeRaid() is a non-interactive dice roll used by the +// background game loop. Both are kept — see raidTrigger.ts for how the +// loop decides which one fires. // ============================================================ -export const RAID_GRID_COLS = 8; -export const RAID_GRID_ROWS = 8; -export const RAID_DURATION_TICKS = 30; // 30 seconds at 1 tick/s -export const EVAC_TICKS = 2; // ticks to evacuate a member -export const POLICE_ADVANCE_TICKS = 1; // police move every N ticks +import type { BlockPlacement } from '../types/block.types'; + +// ─── Config ────────────────────────────────────────────────── + +export const RAID_CONFIG = { + GRID_SIZE: 8, + /** Total time the player has, in ms. */ + DURATION_MS: 30_000, + /** Police move one tile per this interval. */ + ADVANCE_INTERVAL_MS: 1_000, + /** How long a tapped member takes to get clear. */ + EVAC_DURATION_MS: 1_500, + /** Heat the block drops to once the raid resolves, win or lose. */ + POST_RAID_HEAT: 1, +} as const; // ─── Types ─────────────────────────────────────────────────── -export type RaidMemberStatus = 'deployed' | 'evacuating' | 'evacuated' | 'caught'; +export type RaidOutcome = 'in_progress' | 'clean' | 'partial' | 'disaster'; + +export interface PoliceUnit { + id: string; + x: number; + y: number; + /** + * The row this unit entered from. Position is derived from THIS, not + * from `y`, so advancing is idempotent: the component's rAF loop calls + * advanceRaid ~60x a second and each call must land the unit in the + * same place as a single call to the same timestamp would. + */ + spawnY: number; + /** +1 walks down the grid, -1 walks up. */ + direction: 1 | -1; +} + +export type EvacueeStatus = 'deployed' | 'evacuating' | 'safe' | 'caught'; export interface RaidMember { memberId: string; memberName: string; role: string; - col: number; - row: number; - status: RaidMemberStatus; - /** Ticks remaining until evacuation completes (only when status === 'evacuating') */ - evacTicksLeft: number; - /** Drugs / cash this member is carrying (seized if caught) */ + x: number; + y: number; + status: EvacueeStatus; + /** Elapsed-ms stamp when the tap landed; null until tapped. */ + evacStartedAt: number | null; + /** Cash this member was holding, seized on capture. */ heldCash: number; + /** Drug units this member was holding, seized on capture. */ heldDrugs: number; } -export interface PoliceUnit { - id: string; - col: number; - row: number; - /** Direction of advance: +1 = moving down (from row 0), -1 = moving up (from row 7) */ - direction: 1 | -1; -} - -export type RaidPhase = 'countdown' | 'active' | 'resolved'; - export interface RaidState { - phase: RaidPhase; - ticksRemaining: number; + elapsedMs: number; + units: PoliceUnit[]; members: RaidMember[]; - police: PoliceUnit[]; - /** Members who escaped with their product */ - evacuated: RaidMember[]; - /** Members who were caught (jailed, product seized) */ - caught: RaidMember[]; - /** Total cash seized */ - cashSeized: number; - /** Total drugs seized (units) */ - drugsSeized: number; + outcome: RaidOutcome; + seizedCash: number; + seizedDrugs: number; + blockId: string | null; } -// ─── Factory ───────────────────────────────────────────────── +// ─── Setup ─────────────────────────────────────────────────── /** - * Create the initial raid state from the current block placements. - * policeCount: how many police units to spawn (scales with heat). + * Cash and product on a member scale with the value of the corner they + * were working. Deriving it from incomePerTick means a raid on a busy + * block hurts more than one on a dead corner, without a second source + * of truth for what each member is carrying. */ -export function createRaidState( - placements: Array<{ - memberId: string; - memberName: string; - role: string; - x: number; - y: number; - heldCash?: number; - heldDrugs?: number; - }>, - policeCount = 4 -): RaidState { - const members: RaidMember[] = placements.map((p) => ({ - memberId: p.memberId, - memberName: p.memberName, - role: p.role, - col: p.x, - row: p.y, +export function estimateHeld(placement: BlockPlacement): { cash: number; drugs: number } { + const income = Math.max(0, placement.incomePerTick ?? 0); + return { + cash: Math.round(income * 8), + drugs: Math.max(1, Math.round(income / 12)), + }; +} + +export function createRaidMember(placement: BlockPlacement): RaidMember { + const held = estimateHeld(placement); + return { + memberId: placement.memberId, + memberName: placement.memberName, + role: placement.role, + x: placement.x, + y: placement.y, status: 'deployed', - evacTicksLeft: 0, - heldCash: p.heldCash ?? 0, - heldDrugs: p.heldDrugs ?? 0, - })); - - // Spawn police evenly across the top and bottom edges - const police: PoliceUnit[] = []; - const topCount = Math.ceil(policeCount / 2); - const botCount = policeCount - topCount; - - for (let i = 0; i < topCount; i++) { - police.push({ - id: `cop-top-${i}`, - col: Math.round((i / Math.max(topCount - 1, 1)) * (RAID_GRID_COLS - 1)), - row: 0, - direction: 1, - }); - } - for (let i = 0; i < botCount; i++) { - police.push({ - id: `cop-bot-${i}`, - col: Math.round((i / Math.max(botCount - 1, 1)) * (RAID_GRID_COLS - 1)), - row: RAID_GRID_ROWS - 1, + evacStartedAt: null, + heldCash: held.cash, + heldDrugs: held.drugs, + }; +} + +/** + * Police spawn in the columns that actually have someone in them, so a + * raid always applies pressure rather than sweeping empty lanes. With + * nobody deployed we still spawn a token pair — a raid on an empty + * block should resolve immediately, not hang with no units on screen. + */ +export function spawnUnits(members: RaidMember[]): PoliceUnit[] { + const occupied = [...new Set(members.map((m) => m.x))].sort((a, b) => a - b); + // One token lane when nobody is deployed: a raid on an empty block + // should still show units, but there is nothing to converge on. + const columns = occupied.length > 0 ? occupied : [4]; + + const units: PoliceUnit[] = []; + for (const x of columns) { + units.push({ id: `cop-top-${x}`, x, y: 0, spawnY: 0, direction: 1 }); + units.push({ + id: `cop-bot-${x}`, + x, + y: RAID_CONFIG.GRID_SIZE - 1, + spawnY: RAID_CONFIG.GRID_SIZE - 1, direction: -1, }); } + return units; +} +export function createRaidState( + placements: BlockPlacement[], + blockId: string | null = null, +): RaidState { + const members = placements.map(createRaidMember); return { - phase: 'countdown', - ticksRemaining: RAID_DURATION_TICKS, + elapsedMs: 0, + units: spawnUnits(members), members, - police, - evacuated: [], - caught: [], - cashSeized: 0, - drugsSeized: 0, + outcome: 'in_progress', + seizedCash: 0, + seizedDrugs: 0, + blockId, }; } -// ─── Actions ───────────────────────────────────────────────── +// ─── Input ─────────────────────────────────────────────────── /** - * Player taps a member to begin evacuation. - * Returns a new state (immutable update). + * Tap a member to start their evacuation. + * + * Only a 'deployed' member can be tapped. Re-tapping someone already + * running does NOT restart their timer — otherwise mashing a tile would + * be strictly worse than tapping once, which reads as a bug to a player. */ -export function startEvacuation(state: RaidState, memberId: string): RaidState { - if (state.phase !== 'active') return state; - const members = state.members.map((m) => { - if (m.memberId !== memberId || m.status !== 'deployed') return m; - return { ...m, status: 'evacuating' as RaidMemberStatus, evacTicksLeft: EVAC_TICKS }; - }); - return { ...state, members }; +export function tapMember(state: RaidState, memberId: string): RaidState { + const target = state.members.find((m) => m.memberId === memberId); + if (!target || target.status !== 'deployed') return state; + + return { + ...state, + members: state.members.map((m) => + m.memberId === memberId + ? { ...m, status: 'evacuating' as const, evacStartedAt: state.elapsedMs } + : m, + ), + }; } -// ─── Tick ──────────────────────────────────────────────────── +// ─── Simulation ────────────────────────────────────────────── + +function unitPositionAt(unit: PoliceUnit, elapsedMs: number): PoliceUnit { + const steps = Math.floor(elapsedMs / RAID_CONFIG.ADVANCE_INTERVAL_MS); + const raw = unit.spawnY + unit.direction * steps; + // Units stop at the far edge rather than walking off the board. + const y = Math.max(0, Math.min(RAID_CONFIG.GRID_SIZE - 1, raw)); + return { ...unit, y }; +} /** - * Advance the raid by one tick. - * Returns a new RaidState (immutable). + * Advance the raid to `elapsedMs`. + * + * Resolution order per step matters and is deliberate: evacuations that + * have finished are settled BEFORE police positions are checked. A + * member whose 1.5s completes on the same tick a unit arrives gets out. + * Ties going to the player keeps the timing readable — the alternative + * makes a correctly-timed tap feel stolen. */ -export function tickRaid(state: RaidState): RaidState { - if (state.phase === 'resolved') return state; - - // Transition countdown → active on first tick - let phase = state.phase === 'countdown' ? 'active' as RaidPhase : state.phase; - let ticksRemaining = state.ticksRemaining - 1; - - // ── Advance evacuating members ────────────────────────── - let members = state.members.map((m): RaidMember => { - if (m.status !== 'evacuating') return m; - const left = m.evacTicksLeft - 1; - if (left <= 0) return { ...m, status: 'evacuated', evacTicksLeft: 0 }; - return { ...m, evacTicksLeft: left }; - }); +export function advanceRaid(state: RaidState, elapsedMs: number): RaidState { + if (state.outcome !== 'in_progress') return state; + if (elapsedMs <= state.elapsedMs) return { ...state, elapsedMs }; - // ── Advance police ────────────────────────────────────── - const police = state.police.map((cop): PoliceUnit => { - const nextRow = cop.row + cop.direction; - if (nextRow < 0 || nextRow >= RAID_GRID_ROWS) return cop; // already at edge - return { ...cop, row: nextRow }; - }); + const units = state.units.map((u) => unitPositionAt(u, elapsedMs)); + + let seizedCash = state.seizedCash; + let seizedDrugs = state.seizedDrugs; - // ── Collision detection ───────────────────────────────── - let cashSeized = state.cashSeized; - let drugsSeized = state.drugsSeized; - - members = members.map((m): RaidMember => { - if (m.status !== 'deployed' && m.status !== 'evacuating') return m; - const caught = police.some((cop) => cop.col === m.col && cop.row === m.row); - if (caught) { - cashSeized += m.heldCash; - drugsSeized += m.heldDrugs; - return { ...m, status: 'caught', evacTicksLeft: 0 }; + const members = state.members.map((m) => { + if (m.status === 'safe' || m.status === 'caught') return m; + + // 1. Settle completed evacuations first. + if ( + m.status === 'evacuating' && + m.evacStartedAt !== null && + elapsedMs - m.evacStartedAt >= RAID_CONFIG.EVAC_DURATION_MS + ) { + return { ...m, status: 'safe' as const }; + } + + // 2. Then check whether a unit is standing on them. + const reached = units.some((u) => u.x === m.x && u.y === m.y); + if (reached) { + seizedCash += m.heldCash; + seizedDrugs += m.heldDrugs; + return { ...m, status: 'caught' as const }; } + return m; }); - // ── Collect resolved members ──────────────────────────── - const evacuated = [ - ...state.evacuated, - ...members.filter((m) => m.status === 'evacuated'), - ]; - const caught = [ - ...state.caught, - ...members.filter((m) => m.status === 'caught'), - ]; - members = members.filter( - (m) => m.status !== 'evacuated' && m.status !== 'caught' - ); + const next: RaidState = { ...state, elapsedMs, units, members, seizedCash, seizedDrugs }; + return { ...next, outcome: resolveOutcome(next) }; +} - // ── Check end conditions ──────────────────────────────── - const allResolved = members.length === 0; - const timedOut = ticksRemaining <= 0; - - if (allResolved || timedOut) { - // Any remaining deployed/evacuating members are caught on timeout - if (timedOut && members.length > 0) { - for (const m of members) { - cashSeized += m.heldCash; - drugsSeized += m.heldDrugs; - caught.push({ ...m, status: 'caught' }); - } - members = []; - } - phase = 'resolved'; - } +/** + * Anyone still on the board when the clock expires is caught. Standing + * still is not a survival strategy. + */ +export function expireRaid(state: RaidState): RaidState { + let seizedCash = state.seizedCash; + let seizedDrugs = state.seizedDrugs; - return { - phase, - ticksRemaining: Math.max(0, ticksRemaining), + const members = state.members.map((m) => { + if (m.status === 'safe' || m.status === 'caught') return m; + seizedCash += m.heldCash; + seizedDrugs += m.heldDrugs; + return { ...m, status: 'caught' as const }; + }); + + const next: RaidState = { + ...state, + elapsedMs: RAID_CONFIG.DURATION_MS, members, - police, - evacuated, - caught, - cashSeized, - drugsSeized, + seizedCash, + seizedDrugs, }; + return { ...next, outcome: resolveOutcome(next, true) }; +} + +function resolveOutcome(state: RaidState, forceFinal = false): RaidOutcome { + const pending = state.members.filter( + (m) => m.status === 'deployed' || m.status === 'evacuating', + ); + const timeUp = state.elapsedMs >= RAID_CONFIG.DURATION_MS; + + if (!forceFinal && pending.length > 0 && !timeUp) return 'in_progress'; + + const caught = state.members.filter((m) => m.status === 'caught').length; + if (caught === 0) return 'clean'; + if (caught === state.members.length) return 'disaster'; + return 'partial'; } // ─── Selectors ─────────────────────────────────────────────── -/** How many members are still at risk (deployed or evacuating) */ -export function membersAtRisk(state: RaidState): number { - return state.members.filter( - (m) => m.status === 'deployed' || m.status === 'evacuating' - ).length; +export function evacProgress(member: RaidMember, elapsedMs: number): number { + if (member.status === 'safe') return 1; + if (member.status !== 'evacuating' || member.evacStartedAt === null) return 0; + return Math.min(1, (elapsedMs - member.evacStartedAt) / RAID_CONFIG.EVAC_DURATION_MS); } -/** Summary for the results screen */ -export interface RaidSummary { - evacuated: number; - caught: number; - cashSeized: number; - drugsSeized: number; - heatReduction: number; // heat drops after a successful raid +export function secondsRemaining(state: RaidState): number { + return Math.max(0, Math.ceil((RAID_CONFIG.DURATION_MS - state.elapsedMs) / 1000)); } -export function getRaidSummary(state: RaidState): RaidSummary { - const evacuated = state.evacuated.length; - const caught = state.caught.length; - const total = evacuated + caught; - // Heat drops proportionally to how many were caught (raid "satisfied" the police) - const heatReduction = total > 0 ? Math.round((caught / total) * 2) : 0; - return { - evacuated, - caught, - cashSeized: state.cashSeized, - drugsSeized: state.drugsSeized, - heatReduction, - }; +export function caughtMembers(state: RaidState): RaidMember[] { + return state.members.filter((m) => m.status === 'caught'); +} + +export function savedMembers(state: RaidState): RaidMember[] { + return state.members.filter((m) => m.status === 'safe'); } + +/** True when a unit will step onto this tile within one advance. */ +export function isTileThreatened(state: RaidState, x: number, y: number): boolean { + return state.units.some((u) => u.x === x && Math.abs(u.y - y) <= 1); +} \ No newline at end of file diff --git a/frontend/src/utils/raidTrigger.ts b/frontend/src/utils/raidTrigger.ts new file mode 100644 index 0000000..8f360c8 --- /dev/null +++ b/frontend/src/utils/raidTrigger.ts @@ -0,0 +1,68 @@ +// ============================================================ +// SLIDE — Interactive raid trigger (Sprint 14-B, Task 1) +// frontend/src/utils/raidTrigger.ts +// +// Decides whether the game loop should hand control to the playable +// PoliceRaidGame instead of resolving a raid in the background. +// +// TWO RAID PATHS, ONE DECISION POINT +// ---------------------------------- +// heatSystem.executeRaid() is a dice roll against PLAYER heat (0-100) +// and produces a result the player only reads about. That stays — it +// is what covers blocks the player is not looking at. +// +// This module governs the INTERACTIVE raid, which keys off BLOCK heat +// (0-5, a completely separate scale) hitting the ceiling. The two are +// mutually exclusive per tick: firing both would jail the same crew +// twice over the same 30 seconds. +// ============================================================ + +import type { BlockData } from '../types/block.types'; + +export const RAID_TRIGGER_CONFIG = { + /** Block heat, on the 0-5 band, at which the playable raid fires. */ + BLOCK_HEAT_THRESHOLD: 5, + /** Ticks before the same block can be raided interactively again. */ + COOLDOWN_TICKS: 10, +} as const; + +export interface RaidTriggerDecision { + blockId: string; + reason: 'block_heat_max'; +} + +/** + * Pick a block to raid, or null. + * + * Only player-owned blocks with someone deployed are eligible. Raiding + * an empty block produces a 30-second minigame with nothing at stake, + * which reads as a bug; the background roll handles those instead. + * + * When several blocks are maxed, the busiest is chosen — that is where + * the player has the most to lose, so it is the raid worth interrupting + * them for. + */ +export function selectRaidTarget( + blocks: Record, + lastRaidTick: Record, + currentTick: number, +): RaidTriggerDecision | null { + const eligible = Object.values(blocks) + .filter((b) => b.owner === 'player') + .filter((b) => (b.heat ?? 0) >= RAID_TRIGGER_CONFIG.BLOCK_HEAT_THRESHOLD) + .filter((b) => (b.placements?.length ?? 0) > 0) + .filter((b) => { + const last = lastRaidTick[b.id]; + if (last === undefined) return true; + return currentTick - last >= RAID_TRIGGER_CONFIG.COOLDOWN_TICKS; + }) + .sort((a, b) => (b.placements?.length ?? 0) - (a.placements?.length ?? 0)); + + const target = eligible[0]; + return target ? { blockId: target.id, reason: 'block_heat_max' } : null; +} + +/** True when the background dice-roll raid should be suppressed this tick. */ +export function suppressesBackgroundRaid(decision: RaidTriggerDecision | null): boolean { + return decision !== null; +}