diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 85e8737..cadb0e9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,7 @@ import { IS_DEMO_MODE, seedDemoState } from './utils/demoSeed'; import OSShell from './components/layout/OSShell'; import GameEventOverlay from './components/layout/GameEventOverlay'; import RaidEventOverlay from './components/layout/RaidEventOverlay'; +import GetBackClock from './components/common/GetBackClock'; import TutorialOverlay from './components/tutorial/TutorialOverlay'; import NPCThreatBanner from './components/map/NPCThreatBanner'; @@ -63,6 +64,7 @@ const WeeklyUpdateRoute = React.lazy(() => import('./components/news/WeeklyUpdat const PhoneApp = React.lazy(() => import('./components/phone/PhoneApp')); const AttackPlanner = React.lazy(() => import('./components/missions/AttackPlanner')); const TrapApp = React.lazy(() => import('./components/trap/TrapApp')); +const MostWantedApp = React.lazy(() => import('./components/economy/MostWantedApp')); import './App.css'; @@ -196,6 +198,7 @@ const App: React.FC = () => { case 'news': return }>; case 'phone': return }>; case 'trap': return }>; + case 'most_wanted': return }>; default: return ( { + {/* Get Back shot clock — global HUD. Hides itself when no debt is open. */} + + {/* Payroll modal — renders when Shoebox can't cover weekly wages */} {salarySystem.payrollDue && } diff --git a/frontend/src/components/common/GetBackClock.css b/frontend/src/components/common/GetBackClock.css new file mode 100644 index 0000000..d30bb6b --- /dev/null +++ b/frontend/src/components/common/GetBackClock.css @@ -0,0 +1,281 @@ +/* ============================================================ + GetBackClock — revenge shot clock HUD + Sprint 15-B + ============================================================ */ + +/* ── Badge ── */ +.gbc-badge { + position: fixed; + left: 50%; + bottom: 92px; + transform: translateX(-50%); + z-index: 620; + + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px 10px; + border-radius: 999px; + border: 1px solid rgba(239, 68, 68, 0.45); + background: rgba(20, 8, 8, 0.93); + backdrop-filter: blur(8px); + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.55); + cursor: pointer; + overflow: hidden; +} + +.gbc-badge.urgent { + border-color: rgba(239, 68, 68, 0.85); + animation: gbc-pulse 1.4s ease-in-out infinite; +} + +@keyframes gbc-pulse { + 0%, 100% { box-shadow: 0 4px 18px rgba(0, 0, 0, 0.55); } + 50% { box-shadow: 0 4px 26px rgba(239, 68, 68, 0.55); } +} + +.gbc-badge-label { + font-size: 9px; + font-weight: 800; + letter-spacing: 1.6px; + color: #fca5a5; +} + +.gbc-badge-clock { + font-size: 15px; + font-weight: 900; + letter-spacing: 0.6px; + color: #fff; + /* Tabular figures keep the clock from jittering as digits change. */ + font-variant-numeric: tabular-nums; +} + +.gbc-badge.urgent .gbc-badge-clock { + color: #fca5a5; +} + +.gbc-badge-more { + padding: 1px 6px; + border-radius: 999px; + background: rgba(239, 68, 68, 0.28); + color: #fecaca; + font-size: 9px; + font-weight: 800; +} + +.gbc-badge-bar { + position: absolute; + left: 0; + bottom: 0; + height: 3px; + background: linear-gradient(90deg, #ef4444, #f59e0b); + transition: width 1s linear; +} + +/* ── Overlay panel ── */ +.gbc-overlay { + position: fixed; + inset: 0; + z-index: 940; + display: flex; + align-items: flex-end; + justify-content: center; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); +} + +.gbc-panel { + width: 100%; + max-width: 440px; + max-height: 88vh; + overflow-y: auto; + padding: 18px 16px 22px; + border-radius: 16px 16px 0 0; + background: linear-gradient(180deg, #1a0d0d, #0a0708); + border-top: 1px solid rgba(239, 68, 68, 0.3); +} + +.gbc-panel-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 8px; +} + +.gbc-panel-title { + font-size: 16px; + font-weight: 900; + letter-spacing: 2.4px; + color: #fca5a5; +} + +.gbc-panel-sub { + font-size: 10px; + color: #78716c; +} + +.gbc-explainer { + margin: 0 0 14px; + font-size: 11px; + color: #9ca3af; + line-height: 1.6; +} + +/* ── Items ── */ +.gbc-list { + display: flex; + flex-direction: column; + gap: 11px; +} + +.gbc-item { + padding: 12px; + border-radius: 10px + ; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); +} + +.gbc-item.urgent { + border-color: rgba(239, 68, 68, 0.45); + background: rgba(239, 68, 68, 0.07); +} + +.gbc-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 7px; +} + +.gbc-item-trigger { + font-size: 9px; + font-weight: 800; + letter-spacing: 1.1px; + color: #a8a29e; +} + +.gbc-item-clock { + font-size: 17px; + font-weight: 900; + color: #e7e5e4; + font-variant-numeric: tabular-nums; +} + +.gbc-item-clock.urgent { + color: #f87171; +} + +.gbc-item-track { + height: 4px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; + margin-bottom: 9px; +} + +.gbc-item-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #4ade80, #facc15); + transition: width 1s linear; +} + +.gbc-item-fill.urgent { + background: linear-gradient(90deg, #ef4444, #f97316); +} + +.gbc-item-lost { + font-size: 11px; + color: #a1a1aa; + margin-bottom: 5px; +} + +.gbc-item-lost strong { + color: #f4f4f5; + font-weight: 700; +} + +.gbc-item-copy { + margin: 0 0 9px; + font-size: 11px; + color: #78716c; + line-height: 1.55; +} + +/* ── Wanted list ── */ +.gbc-wanted { + margin-bottom: 9px; +} + +.gbc-wanted-label { + display: block; + font-size: 8px; + font-weight: 800; + letter-spacing: 1.2px; + color: #52525b; + margin-bottom: 5px; +} + +.gbc-wanted-names { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.gbc-wanted-name { + padding: 3px 8px; + border-radius: 999px; + background: rgba(239, 68, 68, 0.14); + border: 1px solid rgba(239, 68, 68, 0.28); + color: #fca5a5; + font-size: 9px; + font-weight: 700; +} + +/* ── Stakes ── */ +.gbc-stakes { + display: flex; + justify-content: space-between; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + font-size: 10px; + font-weight: 700; + color: #4ade80; +} + +.gbc-stakes-loss { + color: #f87171; +} + +/* ── Actions ── */ +.gbc-hunt-btn { + width: 100%; + margin-top: 16px; + padding: 13px; + border: none; + border-radius: 9px; + background: linear-gradient(135deg, #dc2626, #991b1b); + color: #fff; + font-size: 12px; + font-weight: 800; + letter-spacing: 1.1px; + cursor: pointer; +} + +.gbc-hunt-btn:active { + transform: scale(0.98); +} + +.gbc-close { + width: 100%; + margin-top: 8px; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: transparent; + color: #71717a; + font-size: 11px; + cursor: pointer; +} diff --git a/frontend/src/components/common/GetBackClock.tsx b/frontend/src/components/common/GetBackClock.tsx new file mode 100644 index 0000000..1d68f22 --- /dev/null +++ b/frontend/src/components/common/GetBackClock.tsx @@ -0,0 +1,260 @@ +// ============================================================ +// GetBackClock — NBA-style shot clock on revenge +// Sprint 15-B +// +// A persistent HUD badge that sits above the dock and counts down +// the window you have to get back for a lost member. Tapping it +// expands a panel listing who is wanted and what is at stake. +// +// The clock derives its display from wall time on every tick rather +// than decrementing a stored counter, so backgrounding the app or +// reloading does not desync it. A single one-second interval drives +// re-renders; expiry sweeping is handled here too so morale +// penalties land even if the player never opens the panel. +// ============================================================ + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + useGetBackStore, + remainingMs, + remainingFraction, + formatClock, + urgency, + GET_BACK_CONFIG, +} from '../../stores/getBackStore'; +import { + useGangStore, + useMoraleStore, + useNotificationStore, + useNavigationStore, +} from '../../stores/gameStore'; +import type { GetBackWindow, GetBackTrigger } from '../../types/game.types'; +import './GetBackClock.css'; + +const TRIGGER_LABELS: Record = { + slide_casualty: 'LOST ON THE BLOCK', + bounty_fulfilled: 'CONTRACT CASHED ON YOURS', + special_person: 'THEY TOUCHED FAMILY', + block_attack: 'LOST DEFENDING THE BLOCK', +}; + +const TRIGGER_COPY: Record = { + slide_casualty: + 'Somebody slid on your block and took one of yours. Catch anybody who was in that car.', + bounty_fulfilled: + 'Somebody cashed a contract on one of yours. The payout is public — you know who.', + special_person: + 'They went after a member\u2019s people. Handle it or watch that member\u2019s loyalty go.', + block_attack: + 'They came to your block and you lost somebody holding it down. Return the visit.', +}; + +interface GetBackClockProps { + /** Hide the badge on screens that already own the bottom of the viewport. */ + hidden?: boolean; +} + +const GetBackClock: React.FC = ({ hidden = false }) => { + const windows = useGetBackStore((s) => s.windows); + const sweepExpired = useGetBackStore((s) => s.sweepExpired); + const { members } = useGangStore(); + const { applyMoraleChange } = useMoraleStore(); + const { addNotification } = useNotificationStore(); + const { navigateTo } = useNavigationStore(); + + /** + * There is no single gang-wide morale value — morale lives per member. A + * gang-wide swing is applied to every active member so the roster drifts + * together, which is what "the gang lost faith" actually means mechanically. + */ + const applyGangWideMorale = useCallback( + (delta: number) => { + for (const m of members) { + if (m.status !== 'active') continue; + applyMoraleChange(m.id, delta, Math.round(delta / 2)); + } + }, + [members, applyMoraleChange], + ); + + const [now, setNow] = useState(() => Date.now()); + const [expanded, setExpanded] = useState(false); + + // One interval drives every countdown on screen. + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + + const active = useMemo( + () => windows.filter((w) => w.outcome === 'pending' && remainingMs(w, now) > 0), + [windows, now], + ); + + const soonest: GetBackWindow | null = useMemo(() => { + if (active.length === 0) return null; + return active.reduce((best, w) => + remainingMs(w, now) < remainingMs(best, now) ? w : best, + ); + }, [active, now]); + + // Sweep lapsed windows and apply the morale penalty for letting it slide. + // Runs off the same tick so it happens whether or not the panel is open. + useEffect(() => { + const lapsed = windows.filter( + (w) => w.outcome === 'pending' && remainingMs(w, now) === 0, + ); + if (lapsed.length === 0) return; + + const results = sweepExpired(now); + for (const r of results) { + applyGangWideMorale(r.moraleSwing.retaliator); + addNotification({ + type: 'danger', + title: 'CLOCK RAN OUT', + message: + `Nobody answered for ${r.window.lostMemberName}. ` + + `${r.window.offendingGangName} walked. Gang morale down ${Math.abs(r.moraleSwing.retaliator)}.`, + timestamp: Date.now(), + } as never); + } + // `now` is intentionally the only trigger — windows is read fresh each tick. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [now]); + + const handleGoHunt = useCallback(() => { + setExpanded(false); + navigateTo('map'); + }, [navigateTo]); + + if (hidden || !soonest) return null; + + const band = urgency(soonest, now); + const ms = remainingMs(soonest, now); + const fraction = remainingFraction(soonest, now); + + return ( + <> + setExpanded(true)} + initial={{ y: 20, opacity: 0 }} + animate={{ y: 0, opacity: 1 }} + whileTap={{ scale: 0.96 }} + > + GET BACK + {formatClock(ms)} + {active.length > 1 && ( + +{active.length - 1} + )} + + + + + {expanded && ( + setExpanded(false)} + > + e.stopPropagation()} + > +
+ GET BACK + + {active.length} open {active.length === 1 ? 'debt' : 'debts'} + +
+ +

+ Answer inside the clock and your gang takes{' '} + {GET_BACK_CONFIG.SUCCESS_MULTIPLIER}x the morale they gained — and + they lose it plus what they banked. Let it run out and your own crew + drops {GET_BACK_CONFIG.EXPIRY_MORALE_PENALTY}. +

+ +
+ {active + .slice() + .sort((a, b) => remainingMs(a, now) - remainingMs(b, now)) + .map((w) => { + const wBand = urgency(w, now); + const wFraction = remainingFraction(w, now); + return ( +
+
+ + {TRIGGER_LABELS[w.trigger]} + + + {formatClock(remainingMs(w, now))} + +
+ +
+
+
+ +
+ For {w.lostMemberName} · on{' '} + {w.offendingGangName} +
+ +

{TRIGGER_COPY[w.trigger]}

+ + {w.wantedMemberNames.length > 0 && ( +
+ CATCH ANY OF THESE +
+ {w.wantedMemberNames.map((n, i) => ( + + {n} + + ))} +
+
+ )} + +
+ + Win: +{w.offenderMoraleGained * GET_BACK_CONFIG.SUCCESS_MULTIPLIER}{' '} + morale + + + Miss: −{GET_BACK_CONFIG.EXPIRY_MORALE_PENALTY} morale + +
+
+ ); + })} +
+ + + + + + )} + + + ); +}; + +export default GetBackClock; diff --git a/frontend/src/components/economy/Market.tsx b/frontend/src/components/economy/Market.tsx index 3ff5931..004f509 100644 --- a/frontend/src/components/economy/Market.tsx +++ b/frontend/src/components/economy/Market.tsx @@ -6,6 +6,7 @@ import React, { useState, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { useNavigationStore, usePlayerStore, useEconomyStore } from '../../stores/gameStore'; import { soundManager } from '../../utils/SoundManager'; +import MarketMembers from './MarketMembers'; import './Market.css'; // ── Item Catalog (from DB seed data) ── @@ -18,39 +19,50 @@ interface CatalogItem { basePrice: number; description: string; rarity: 'common' | 'uncommon' | 'rare' | 'legendary'; - icon: string; } const ITEM_CATALOG: CatalogItem[] = [ - { id: 'pistol-9mm', name: '9mm Pistol', category: 'weapon', damage: 15, defense: 0, basePrice: 500, description: 'Basic handgun. Reliable and concealable.', rarity: 'common', icon: '🔫' }, - { id: 'revolver-38', name: '.38 Revolver', category: 'weapon', damage: 20, defense: 0, basePrice: 750, description: 'Six-shooter. Never jams.', rarity: 'common', icon: '🔫' }, - { id: 'mac-10', name: 'MAC-10', category: 'weapon', damage: 25, defense: 0, basePrice: 1500, description: 'Compact SMG. Spray and pray.', rarity: 'uncommon', icon: '🔫' }, - { id: 'ak47', name: 'AK-47', category: 'weapon', damage: 35, defense: 0, basePrice: 2500, description: 'Assault rifle. The peoples weapon.', rarity: 'uncommon', icon: '🔫' }, - { id: 'shotgun', name: 'Pump Shotgun', category: 'weapon', damage: 40, defense: 0, basePrice: 1800, description: 'Close-range devastation.', rarity: 'uncommon', icon: '🔫' }, - { id: 'sniper-rifle', name: 'Sniper Rifle', category: 'weapon', damage: 60, defense: 0, basePrice: 5000, description: 'Long-range precision.', rarity: 'rare', icon: '🎯' }, - { id: 'draco', name: 'Draco', category: 'weapon', damage: 45, defense: 0, basePrice: 4000, description: 'Mini AK. Big damage, small package.', rarity: 'rare', icon: '🔫' }, - { id: 'bulletproof-vest', name: 'Bulletproof Vest', category: 'armor', damage: 0, defense: 10, basePrice: 800, description: 'Basic body armor.', rarity: 'common', icon: '🦺' }, - { id: 'tactical-armor', name: 'Tactical Armor', category: 'armor', damage: 0, defense: 25, basePrice: 3000, description: 'Military-grade protection.', rarity: 'rare', icon: '🛡️' }, - { id: 'kevlar-helmet', name: 'Kevlar Helmet', category: 'armor', damage: 0, defense: 8, basePrice: 600, description: 'Head protection from stray shots.', rarity: 'common', icon: '⛑️' }, - { id: 'medkit', name: 'Medical Kit', category: 'consumable', damage: 0, defense: 0, basePrice: 200, description: 'Restores 50 HP.', rarity: 'common', icon: '🩹' }, - { id: 'adrenaline', name: 'Adrenaline Shot', category: 'consumable', damage: 0, defense: 0, basePrice: 400, description: 'Temporary speed boost in combat.', rarity: 'uncommon', icon: '💉' }, - { id: 'burner-phone', name: 'Burner Phone', category: 'consumable', damage: 0, defense: 0, basePrice: 100, description: 'Reduces heat by 10 points.', rarity: 'common', icon: '📱' }, - { id: 'lockpick', name: 'Lockpick Set', category: 'tool', damage: 0, defense: 0, basePrice: 150, description: 'Bypass security.', rarity: 'common', icon: '🔑' }, - { id: 'night-vision', name: 'Night Vision Goggles', category: 'tool', damage: 0, defense: 0, basePrice: 1200, description: '+20% accuracy at night.', rarity: 'uncommon', icon: '🥽' }, - { id: 'scanner', name: 'Police Scanner', category: 'tool', damage: 0, defense: 0, basePrice: 800, description: 'Early warning for raids.', rarity: 'uncommon', icon: '📻' }, - { id: 'sedan', name: 'Sedan', category: 'vehicle', damage: 0, defense: 0, basePrice: 5000, description: 'Basic getaway car.', rarity: 'common', icon: '🚗' }, - { id: 'suv', name: 'Armored SUV', category: 'vehicle', damage: 0, defense: 15, basePrice: 15000, description: 'Bulletproof windows. Seats 5.', rarity: 'rare', icon: '🚙' }, - { id: 'sports-car', name: 'Sports Car', category: 'vehicle', damage: 0, defense: 0, basePrice: 25000, description: 'Fast and flashy.', rarity: 'rare', icon: '🏎️' }, - { id: 'motorcycle', name: 'Motorcycle', category: 'vehicle', damage: 0, defense: 0, basePrice: 3000, description: 'Quick escape through traffic.', rarity: 'common', icon: '🏍️' }, + { id: 'pistol-9mm', name: '9mm Pistol', category: 'weapon', damage: 15, defense: 0, basePrice: 500, description: 'Basic handgun. Reliable and concealable.', rarity: 'common' }, + { id: 'revolver-38', name: '.38 Revolver', category: 'weapon', damage: 20, defense: 0, basePrice: 750, description: 'Six-shooter. Never jams.', rarity: 'common' }, + { id: 'mac-10', name: 'MAC-10', category: 'weapon', damage: 25, defense: 0, basePrice: 1500, description: 'Compact SMG. Spray and pray.', rarity: 'uncommon' }, + { id: 'ak47', name: 'AK-47', category: 'weapon', damage: 35, defense: 0, basePrice: 2500, description: 'Assault rifle. The peoples weapon.', rarity: 'uncommon' }, + { id: 'shotgun', name: 'Pump Shotgun', category: 'weapon', damage: 40, defense: 0, basePrice: 1800, description: 'Close-range devastation.', rarity: 'uncommon' }, + { id: 'sniper-rifle', name: 'Sniper Rifle', category: 'weapon', damage: 60, defense: 0, basePrice: 5000, description: 'Long-range precision.', rarity: 'rare' }, + { id: 'draco', name: 'Draco', category: 'weapon', damage: 45, defense: 0, basePrice: 4000, description: 'Mini AK. Big damage, small package.', rarity: 'rare' }, + { id: 'bulletproof-vest', name: 'Bulletproof Vest', category: 'armor', damage: 0, defense: 10, basePrice: 800, description: 'Basic body armor.', rarity: 'common' }, + { id: 'tactical-armor', name: 'Tactical Armor', category: 'armor', damage: 0, defense: 25, basePrice: 3000, description: 'Military-grade protection.', rarity: 'rare' }, + { id: 'kevlar-helmet', name: 'Kevlar Helmet', category: 'armor', damage: 0, defense: 8, basePrice: 600, description: 'Head protection from stray shots.', rarity: 'common' }, + { id: 'medkit', name: 'Medical Kit', category: 'consumable', damage: 0, defense: 0, basePrice: 200, description: 'Restores 50 HP.', rarity: 'common' }, + { id: 'adrenaline', name: 'Adrenaline Shot', category: 'consumable', damage: 0, defense: 0, basePrice: 400, description: 'Temporary speed boost in combat.', rarity: 'uncommon' }, + { id: 'burner-phone', name: 'Burner Phone', category: 'consumable', damage: 0, defense: 0, basePrice: 100, description: 'Reduces heat by 10 points.', rarity: 'common' }, + { id: 'lockpick', name: 'Lockpick Set', category: 'tool', damage: 0, defense: 0, basePrice: 150, description: 'Bypass security.', rarity: 'common' }, + { id: 'night-vision', name: 'Night Vision Goggles', category: 'tool', damage: 0, defense: 0, basePrice: 1200, description: '+20% accuracy at night.', rarity: 'uncommon' }, + { id: 'scanner', name: 'Police Scanner', category: 'tool', damage: 0, defense: 0, basePrice: 800, description: 'Early warning for raids.', rarity: 'uncommon' }, + { id: 'sedan', name: 'Sedan', category: 'vehicle', damage: 0, defense: 0, basePrice: 5000, description: 'Basic getaway car.', rarity: 'common' }, + { id: 'suv', name: 'Armored SUV', category: 'vehicle', damage: 0, defense: 15, basePrice: 15000, description: 'Bulletproof windows. Seats 5.', rarity: 'rare' }, + { id: 'sports-car', name: 'Sports Car', category: 'vehicle', damage: 0, defense: 0, basePrice: 25000, description: 'Fast and flashy.', rarity: 'rare' }, + { id: 'motorcycle', name: 'Motorcycle', category: 'vehicle', damage: 0, defense: 0, basePrice: 3000, description: 'Quick escape through traffic.', rarity: 'common' }, ]; -const CATEGORIES = ['weapon', 'armor', 'consumable', 'tool', 'vehicle'] as const; +// 'members' is a virtual category — it renders MarketMembers instead of the +// item grid, because hiring has a different flow to buying a vest. +const CATEGORIES = ['weapon', 'armor', 'consumable', 'tool', 'vehicle', 'members'] as const; const CATEGORY_LABELS: Record = { - weapon: '🔫 Weapons', - armor: '🛡️ Armor', - consumable: '💊 Consumables', - tool: '🔧 Tools', - vehicle: '🚗 Vehicles', + weapon: 'WEAPONS', + armor: 'ARMOR', + consumable: 'SUPPLIES', + tool: 'TOOLS', + vehicle: 'WHIPS', + members: 'MEMBERS', +}; + +/** Short text tag standing in for the old emoji on each item card. */ +const CATEGORY_TAGS: Record = { + weapon: 'GUN', + armor: 'VEST', + consumable: 'SUPPLY', + tool: 'TOOL', + vehicle: 'WHIP', }; const RARITY_COLORS: Record = { @@ -124,13 +136,13 @@ const Market: React.FC = () => { ← Back -

🏪 MARKET

+

UNDERWORLD MARKET

setShowInventory(!showInventory)} whileTap={{ scale: 0.9 }} > - {showInventory ? '🛒 Shop' : '🎒 Inv'} + {showInventory ? 'Shop' : 'Stash'}
@@ -151,7 +163,9 @@ const Market: React.FC = () => { const cat = ITEM_CATALOG.find((c) => c.id === inv.itemId); return (
- {cat?.icon || '📦'} + + {cat ? CATEGORY_TAGS[cat.category] : 'ITEM'} + {cat?.name || inv.itemId} x{inv.quantity}
@@ -176,7 +190,10 @@ const Market: React.FC = () => { ))} - {/* Item Grid */} + {activeCategory === 'members' ? ( + + ) : ( + /* Item Grid */
{filteredItems.map((item) => { const owned = getOwnedCount(item.id); @@ -189,7 +206,7 @@ const Market: React.FC = () => { style={{ borderColor: RARITY_COLORS[item.rarity] + '40' }} >
- {item.icon} + {CATEGORY_TAGS[item.category]} {owned > 0 && x{owned}}
{item.name}
@@ -197,14 +214,15 @@ const Market: React.FC = () => { {item.rarity.toUpperCase()}
- {item.damage > 0 && ⚔️ {item.damage}} - {item.defense > 0 && 🛡️ {item.defense}} + {item.damage > 0 && DMG {item.damage}} + {item.defense > 0 && DEF {item.defense}}
{formatMoney(item.basePrice)}
); })} + )} )} @@ -225,12 +243,12 @@ const Market: React.FC = () => { exit={{ scale: 0.8, opacity: 0 }} onClick={(e) => e.stopPropagation()} > -
{selectedItem.icon}
+
{CATEGORY_TAGS[selectedItem.category]}

{selectedItem.name}

{selectedItem.description}

- {selectedItem.damage > 0 && ⚔️ DMG: {selectedItem.damage}} - {selectedItem.defense > 0 && 🛡️ DEF: {selectedItem.defense}} + {selectedItem.damage > 0 && DMG: {selectedItem.damage}} + {selectedItem.defense > 0 && DEF: {selectedItem.defense}} {selectedItem.rarity.toUpperCase()} diff --git a/frontend/src/components/economy/MarketMembers.css b/frontend/src/components/economy/MarketMembers.css new file mode 100644 index 0000000..8052bda --- /dev/null +++ b/frontend/src/components/economy/MarketMembers.css @@ -0,0 +1,475 @@ +/* ============================================================ + MarketMembers — underworld hires board + Sprint 15-B + ============================================================ */ + +.mm-root { + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 4px 24px; +} + +/* ── Pitch strip ── */ +.mm-pitch { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border-radius: 8px; + background: linear-gradient(90deg, rgba(245, 158, 11, 0.12), rgba(0, 0, 0, 0)); + border-left: 3px solid #f59e0b; +} + +.mm-pitch-label { + font-size: 10px; + font-weight: 800; + letter-spacing: 1.4px; + color: #f59e0b; +} + +.mm-pitch-copy { + font-size: 11px; + color: #9ca3af; + line-height: 1.45; +} + +/* ── Filters ── */ +.mm-filters { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 4px; + scrollbar-width: none; +} + +.mm-filters::-webkit-scrollbar { + display: none; +} + +.mm-filter { + flex: 0 0 auto; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: #9ca3af; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.8px; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.mm-filter.active { + background: rgba(239, 68, 68, 0.18); + border-color: rgba(239, 68, 68, 0.45); + color: #fca5a5; +} + +/* ── Board bar ── */ +.mm-board-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.mm-count { + font-size: 11px; + color: #6b7280; + letter-spacing: 0.4px; +} + +.mm-refresh { + padding: 7px 12px; + border-radius: 6px; + border: 1px solid rgba(96, 165, 250, 0.35); + background: rgba(96, 165, 250, 0.12); + color: #93c5fd; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.6px; + cursor: pointer; +} + +.mm-refresh:active { + transform: scale(0.97); +} + +/* ── Toast ── */ +.mm-toast { + padding: 9px 12px; + border-radius: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + text-align: center; +} + +.mm-toast.ok { + background: rgba(74, 222, 128, 0.15); + color: #86efac; + border: 1px solid rgba(74, 222, 128, 0.3); +} + +.mm-toast.fail { + background: rgba(239, 68, 68, 0.15); + color: #fca5a5; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +/* ── Grid ── */ +.mm-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 9px; +} + +.mm-empty { + grid-column: 1 / -1; + padding: 32px 16px; + text-align: center; + font-size: 12px; + color: #6b7280; + line-height: 1.6; +} + +.mm-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 11px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(160deg, rgba(24, 24, 27, 0.95), rgba(9, 9, 11, 0.95)); + cursor: pointer; + transition: transform 0.12s, border-color 0.15s; +} + +.mm-card.unaffordable { + opacity: 0.5; +} + +.mm-card-top { + display: flex; + align-items: center; + justify-content: space-between; +} + +.mm-tier { + font-size: 9px; + font-weight: 800; + letter-spacing: 1.2px; +} + +.mm-level { + font-size: 9px; + font-weight: 700; + color: #6b7280; + letter-spacing: 0.6px; +} + +.mm-name { + font-size: 13px; + font-weight: 700; + color: #f4f4f5; + line-height: 1.2; +} + +.mm-nick { + font-size: 11px; + color: #a1a1aa; + font-style: italic; +} + +.mm-role { + font-size: 9px; + font-weight: 700; + letter-spacing: 1px; + color: #71717a; + margin-top: 1px; +} + +.mm-hook { + font-size: 10px; + color: #8b8b93; + line-height: 1.45; + margin: 4px 0 2px; + min-height: 28px; +} + +.mm-statline { + display: flex; + flex-wrap: wrap; + gap: 6px; + font-size: 9px; + font-weight: 600; + color: #71717a; + letter-spacing: 0.4px; +} + +.mm-baggage-flag { + margin-top: 4px; + font-size: 8px; + font-weight: 800; + letter-spacing: 1px; + color: #fbbf24; +} + +.mm-card-bottom { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-top: 7px; + padding-top: 7px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.mm-price { + font-size: 14px; + font-weight: 800; + color: #4ade80; +} + +.mm-salary { + font-size: 9px; + color: #6b7280; +} + +/* ── Modal ── */ +.mm-overlay { + position: fixed; + inset: 0; + z-index: 900; + display: flex; + align-items: flex-end; + justify-content: center; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(3px); +} + +.mm-modal { + width: 100%; + max-width: 460px; + max-height: 88vh; + overflow-y: auto; + padding: 18px 16px 22px; + border-radius: 16px 16px 0 0; + background: linear-gradient(180deg, #18181b, #0a0a0b); + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.mm-modal-head { + margin-bottom: 12px; +} + +.mm-modal-head h3 { + margin: 4px 0 2px; + font-size: 19px; + font-weight: 800; + color: #fafafa; +} + +.mm-modal-nick { + font-size: 12px; + color: #a1a1aa; +} + +.mm-role-desc { + margin: 0 0 14px; + font-size: 12px; + color: #9ca3af; + line-height: 1.5; +} + +.mm-section { + margin-bottom: 14px; +} + +.mm-section-label { + font-size: 9px; + font-weight: 800; + letter-spacing: 1.4px; + color: #52525b; + margin-bottom: 6px; +} + +.mm-story { + margin: 0; + font-size: 12px; + color: #d4d4d8; + line-height: 1.6; +} + +/* ── Stat bars ── */ +.mm-stat-bars { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mm-stat-row { + display: grid; + grid-template-columns: 62px 1fr 28px; + align-items: center; + gap: 8px; +} + +.mm-stat-label { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.8px; + color: #71717a; +} + +.mm-stat-track { + height: 5px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); + overflow: hidden; +} + +.mm-stat-fill { + height: 100%; + border-radius: 999px; + transition: width 0.3s; +} + +.mm-stat-val { + font-size: 10px; + font-weight: 700; + color: #a1a1aa; + text-align: right; +} + +/* ── Terms ── */ +.mm-terms { + display: flex; + flex-direction: column; + gap: 5px; +} + +.mm-term { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + color: #9ca3af; +} + +.mm-term-val { + font-weight: 700; + color: #e4e4e7; +} + +/* ── Special people ── */ +.mm-people-warn { + margin: 0 0 7px; + font-size: 10px; + color: #78716c; + line-height: 1.5; +} + +.mm-person { + display: flex; + align-items: baseline; + gap: 8px; + padding: 6px 8px; + margin-bottom: 4px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.035); +} + +.mm-person-name { + font-size: 11px; + font-weight: 600; + color: #e4e4e7; +} + +.mm-person-rel { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.6px; + color: #a78bfa; +} + +.mm-person-hood { + margin-left: auto; + font-size: 9px; + color: #52525b; +} + +/* ── Warning ── */ +.mm-warning { + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 12px; + margin-bottom: 14px; + border-radius: 8px; + background: rgba(251, 191, 36, 0.1); + border-left: 3px solid #fbbf24; +} + +.mm-warning-label { + font-size: 9px; + font-weight: 800; + letter-spacing: 1.4px; + color: #fbbf24; +} + +.mm-warning-copy { + font-size: 11px; + color: #d4d4d8; + line-height: 1.5; +} + +/* ── Actions ── */ +.mm-hire-btn { + width: 100%; + padding: 13px; + border: none; + border-radius: 9px; + background: linear-gradient(135deg, #16a34a, #15803d); + color: #fff; + font-size: 13px; + font-weight: 800; + letter-spacing: 1px; + cursor: pointer; +} + +.mm-hire-btn:active { + transform: scale(0.98); +} + +.mm-modal-result { + padding: 12px; + border-radius: 8px; + text-align: center; + font-size: 12px; + font-weight: 800; + letter-spacing: 1px; +} + +.mm-modal-result.ok { + background: rgba(74, 222, 128, 0.15); + color: #86efac; +} + +.mm-modal-result.fail { + background: rgba(239, 68, 68, 0.15); + color: #fca5a5; +} + +.mm-close { + width: 100%; + margin-top: 9px; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: transparent; + color: #71717a; + font-size: 11px; + cursor: pointer; +} diff --git a/frontend/src/components/economy/MarketMembers.tsx b/frontend/src/components/economy/MarketMembers.tsx new file mode 100644 index 0000000..de884a6 --- /dev/null +++ b/frontend/src/components/economy/MarketMembers.tsx @@ -0,0 +1,362 @@ +// ============================================================ +// MarketMembers — the MEMBERS tab of the underworld marketplace +// Sprint 15-B +// +// Members sold here come pre-leveled, which is the whole pitch: you +// pay a premium to skip the grind. The listing states the catch +// honestly — a legend shooter draws police like a magnet, a certified +// dealer might have a habit. The interesting decision is whether the +// ceiling is worth the tax, so the baggage line is never hidden and +// the heat multiplier is always shown. +// ============================================================ + +import React, { useState, useMemo, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + usePlayerStore, + useGangStore, + useEconomyStore, + useNotificationStore, +} from '../../stores/gameStore'; +import { + generateMemberBoard, + hireableToMemberPayload, + TIER_BANDS, + ROLE_PROFILES, + RELATION_LABELS, +} from '../../utils/marketMembersCatalog'; +import type { HireableMember, HireableRole, HireableTier } from '../../types/game.types'; +import { soundManager } from '../../utils/SoundManager'; +import './MarketMembers.css'; + +const formatMoney = (n: number): string => { + if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}K`; + return `$${n.toLocaleString()}`; +}; + +const TIER_COLORS: Record = { + street: '#8b9199', + seasoned: '#4ade80', + certified: '#60a5fa', + legend: '#f59e0b', +}; + +type RoleFilter = 'all' | HireableRole; + +const ROLE_FILTERS: RoleFilter[] = [ + 'all', 'shooter', 'dealer', 'enforcer', 'driver', 'lookout', 'recruit', 'k9', +]; + +const FILTER_LABELS: Record = { + all: 'ALL', + shooter: 'SHOOTERS', + dealer: 'DEALERS', + enforcer: 'ENFORCERS', + driver: 'DRIVERS', + lookout: 'LOOKOUTS', + recruit: 'RECRUITS', + k9: 'K9', +}; + +/** A refresh costs money — you can't reroll the board for free. */ +const REFRESH_COST = 250; + +const MarketMembers: React.FC = () => { + const { player, updateMoney } = usePlayerStore(); + const { members, addMember, maxMembers } = useGangStore(); + const { addTransaction } = useEconomyStore(); + const { addNotification } = useNotificationStore(); + + // Seed is stable across renders so a listing survives the buy flow. + const [boardSeed, setBoardSeed] = useState(() => `board_${Date.now()}`); + const [roleFilter, setRoleFilter] = useState('all'); + const [selected, setSelected] = useState(null); + const [result, setResult] = useState(''); + const [hiredIds, setHiredIds] = useState>(new Set()); + + const board = useMemo(() => generateMemberBoard(boardSeed, 14), [boardSeed]); + + const visible = useMemo( + () => + board.filter( + (h) => !hiredIds.has(h.id) && (roleFilter === 'all' || h.role === roleFilter), + ), + [board, roleFilter, hiredIds], + ); + + const handleRefresh = useCallback(() => { + if (player.money < REFRESH_COST) { + setResult('NOT ENOUGH FOR A REFRESH'); + setTimeout(() => setResult(''), 1600); + return; + } + updateMoney(-REFRESH_COST); + setBoardSeed(`board_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`); + setHiredIds(new Set()); + soundManager.play('ui_tap'); + }, [player.money, updateMoney]); + + const handleHire = useCallback(() => { + if (!selected) return; + + if (player.money < selected.price) { + setResult('NOT ENOUGH CASH'); + setTimeout(() => setResult(''), 1600); + return; + } + + // Roster cap keeps the weekly payroll from becoming unmanageable. + const activeCount = members.filter((m) => m.status === 'active').length; + if (activeCount >= maxMembers) { + setResult('ROSTER FULL — BACKDOOR SOMEBODY FIRST'); + setTimeout(() => setResult(''), 2200); + return; + } + + updateMoney(-selected.price); + addMember(hireableToMemberPayload(selected, player.id) as never); + + addTransaction({ + id: `tx_hire_${Date.now()}`, + type: 'purchase', + userId: player.id, + amount: -selected.price, + details: { + description: `Bought ${selected.name} "${selected.nickname}" — level ${selected.level} ${ROLE_PROFILES[selected.role].label.toLowerCase()}`, + }, + createdAt: new Date().toISOString(), + } as never); + + addNotification({ + type: selected.baggage ? 'warning' : 'success', + title: 'NEW HIRE', + message: selected.baggage + ? `${selected.nickname} is on the payroll. Heads up: ${selected.baggage}` + : `${selected.nickname} is on the payroll at ${formatMoney(selected.salary)}/week.`, + timestamp: Date.now(), + } as never); + + soundManager.play('cash_register'); + setHiredIds((prev) => new Set(prev).add(selected.id)); + setResult(`SIGNED ${selected.nickname.toUpperCase()}`); + setTimeout(() => { + setResult(''); + setSelected(null); + }, 1600); + }, [selected, player, members, updateMoney, addMember, addTransaction, addNotification]); + + return ( +
+ {/* Pitch strip — explains why these cost more than recruiting. */} +
+ UNDERWORLD HIRES + + Already leveled. You are paying to skip the grind — read the warnings. + +
+ + {/* Role filter */} +
+ {ROLE_FILTERS.map((f) => ( + + ))} +
+ +
+ {visible.length} available + +
+ + {result && !selected && ( +
+ {result} +
+ )} + + {/* Listings */} +
+ {visible.length === 0 ? ( +
+ Nobody in this category right now. Refresh the board or check back. +
+ ) : ( + visible.map((h) => { + const affordable = player.money >= h.price; + return ( + { setSelected(h); setResult(''); }} + whileTap={{ scale: 0.98 }} + > +
+ + {TIER_BANDS[h.tier].label} + + LVL {h.level} +
+ +
{h.name}
+
“{h.nickname}”
+
{ROLE_PROFILES[h.role].label}
+ +
{h.originStory.hook}
+ +
+ SHT {h.stats.shooting} + DLR {h.stats.dealing} + NRV {h.stats.nerve} + STL {h.stats.stealth} +
+ + {h.baggage &&
CARRIES BAGGAGE
} + +
+ {formatMoney(h.price)} + {formatMoney(h.salary)}/wk +
+
+ ); + }) + )} +
+ + {/* Detail / hire modal */} + + {selected && ( + { setSelected(null); setResult(''); }} + > + e.stopPropagation()} + > +
+ + {TIER_BANDS[selected.tier].label} · LVL {selected.level} + +

{selected.name}

+
+ “{selected.nickname}” · {ROLE_PROFILES[selected.role].label} +
+
+ +

{ROLE_PROFILES[selected.role].description}

+ +
+
ORIGIN
+

{selected.originStory.body}

+
+ +
+
STATS
+
+ {([ + ['SHOOTING', selected.stats.shooting], + ['DEALING', selected.stats.dealing], + ['NERVE', selected.stats.nerve], + ['STEALTH', selected.stats.stealth], + ] as const).map(([label, val]) => ( +
+ {label} +
+
+
+ {val} +
+ ))} +
+
+ +
+
TERMS
+
+
+ Loyalty on arrival + {selected.startingLoyalty}/100 +
+
+ Heat multiplier + 1.4 ? '#ef4444' : undefined }} + > + {selected.heatFactor.toFixed(2)}x + +
+
+ Weekly + {formatMoney(selected.salary)} +
+
+
+ + {selected.specialPeople.length > 0 && ( +
+
PEOPLE THEY CARE ABOUT
+

+ Opps can post bounties on these two. How you respond changes this + member’s loyalty. +

+ {selected.specialPeople.map((p) => ( +
+ {p.name} + {RELATION_LABELS[p.relation]} + {p.neighborhood} +
+ ))} +
+ )} + + {selected.baggage && ( +
+ THE CATCH + {selected.baggage} +
+ )} + + {result ? ( +
+ {result} +
+ ) : ( + + )} + + + + + )} + +
+ ); +}; + +export default MarketMembers; diff --git a/frontend/src/components/economy/MostWanted.css b/frontend/src/components/economy/MostWanted.css new file mode 100644 index 0000000..dd89e93 --- /dev/null +++ b/frontend/src/components/economy/MostWanted.css @@ -0,0 +1,672 @@ +/* ============================================================ + MostWanted — bounty board + Sprint 15-B + ============================================================ */ + +.mw-root { + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 4px 24px; +} + +/* ── Intro ── */ +.mw-intro { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border-radius: 8px; + background: linear-gradient(90deg, rgba(239, 68, 68, 0.13), rgba(0, 0, 0, 0)); + border-left: 3px solid #ef4444; +} + +.mw-intro-label { + font-size: 10px; + font-weight: 800; + letter-spacing: 1.6px; + color: #ef4444; +} + +.mw-intro-copy { + font-size: 11px; + color: #9ca3af; + line-height: 1.5; +} + +/* ── Tabs ── */ +.mw-tabs { + display: flex; + gap: 5px; + overflow-x: auto; + scrollbar-width: none; +} + +.mw-tabs::-webkit-scrollbar { + display: none; +} + +.mw-tab { + flex: 1 0 auto; + position: relative; + padding: 8px 11px; + border-radius: 7px; + border: 1px solid rgba(255, 255, 255, 0.09); + background: rgba(255, 255, 255, 0.035); + color: #9ca3af; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.9px; + cursor: pointer; + white-space: nowrap; +} + +.mw-tab.active { + background: rgba(239, 68, 68, 0.16); + border-color: rgba(239, 68, 68, 0.42); + color: #fca5a5; +} + +.mw-tab-count { + display: inline-block; + margin-left: 5px; + padding: 1px 5px; + border-radius: 999px; + background: #ef4444; + color: #fff; + font-size: 8px; + font-weight: 800; +} + +/* ── Toast ── */ +.mw-toast { + padding: 9px 12px; + border-radius: 6px; + background: rgba(251, 191, 36, 0.14); + border: 1px solid rgba(251, 191, 36, 0.32); + color: #fcd34d; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.9px; + text-align: center; +} + +.mw-empty { + padding: 34px 18px; + text-align: center; + font-size: 12px; + color: #6b7280; + line-height: 1.65; +} + +.mw-inline-empty { + padding: 10px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.03); + font-size: 11px; + color: #6b7280; + line-height: 1.5; +} + +/* ── Poster wall ── */ +.mw-poster-wall { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(142px, 1fr)); + gap: 10px; +} + +.mw-poster { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + padding: 0 9px 10px; + border-radius: 4px; + /* Aged-paper look so the board reads as a physical wall. */ + background: linear-gradient(175deg, #e8e0cd, #d6cbb2); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.5); + cursor: pointer; + overflow: hidden; +} + +.mw-poster.own { + outline: 2px solid rgba(96, 165, 250, 0.6); +} + +.mw-poster-banner { + width: calc(100% + 18px); + margin: 0 -9px 7px; + padding: 5px 0; + background: #1c1917; + color: #e8e0cd; + font-size: 11px; + font-weight: 900; + letter-spacing: 4px; + text-align: center; +} + +.mw-poster-photo { + width: 100%; + aspect-ratio: 1 / 1; + border: 2px solid #57534e; + background: #a8a29e; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.mw-poster-photo img { + width: 100%; + height: 100%; + object-fit: cover; + /* Slight desaturation keeps uploaded photos in the poster's palette. */ + filter: grayscale(0.55) contrast(1.1); +} + +.mw-poster-nophoto { + font-size: 9px; + font-weight: 800; + letter-spacing: 1px; + color: #57534e; +} + +.mw-poster-name { + margin-top: 6px; + font-size: 12px; + font-weight: 800; + color: #1c1917; + text-align: center; + line-height: 1.25; +} + +.mw-poster-kin { + font-size: 8px; + font-style: italic; + color: #57534e; + text-align: center; +} + +.mw-poster-gang { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.8px; + color: #78716c; + text-transform: uppercase; +} + +.mw-poster-reward { + margin-top: 3px; + font-size: 17px; + font-weight: 900; + color: #7f1d1d; + letter-spacing: -0.4px; +} + +.mw-poster-meta { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + font-size: 8px; + color: #78716c; +} + +.mw-poster-own-flag { + margin-top: 4px; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.9px; + color: #1d4ed8; +} + +/* ── Rows ── */ +.mw-list { + display: flex; + flex-direction: column; + gap: 7px; +} + +.mw-row { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 12px; + border-radius: 9px; + border: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(255, 255, 255, 0.035); +} + +.mw-row.status-paid { + opacity: 0.72; +} + +.mw-row.status-expired, +.mw-row.status-cancelled { + opacity: 0.45; +} + +.mw-row-main { + flex: 1; + min-width: 0; +} + +.mw-row-name { + font-size: 13px; + font-weight: 700; + color: #f4f4f5; +} + +.mw-row-sub { + margin-top: 2px; + font-size: 10px; + color: #71717a; +} + +.mw-row-paid { + margin-top: 3px; + font-size: 10px; + color: #fbbf24; +} + +.mw-row-right { + display: flex; + align-items: center; + gap: 7px; +} + +.mw-status-badge { + padding: 3px 7px; + border-radius: 4px; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.8px; +} + +.mw-status-badge.open { + background: rgba(74, 222, 128, 0.16); + color: #86efac; +} + +.mw-status-badge.paid { + background: rgba(251, 191, 36, 0.16); + color: #fcd34d; +} + +.mw-status-badge.expired, +.mw-status-badge.cancelled { + background: rgba(255, 255, 255, 0.07); + color: #71717a; +} + +.mw-pull, +.mw-view-proof { + padding: 5px 9px; + border-radius: 5px; + border: 1px solid rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.1); + color: #fca5a5; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.7px; + cursor: pointer; +} + +.mw-view-proof { + border-color: rgba(96, 165, 250, 0.35); + background: rgba(96, 165, 250, 0.1); + color: #93c5fd; +} + +/* ── Standing meter ── */ +.mw-standing { + display: flex; + align-items: center; + gap: 6px; + min-width: 82px; +} + +.mw-standing-track { + flex: 1; + height: 5px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.mw-standing-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #60a5fa, #34d399); +} + +.mw-standing-val { + font-size: 10px; + font-weight: 700; + color: #a1a1aa; +} + +/* ── Form ── */ +.mw-form { + display: flex; + flex-direction: column; + gap: 13px; +} + +.mw-form-note { + padding: 10px 12px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.03); + font-size: 11px; + color: #8b8b93; + line-height: 1.6; +} + +.mw-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mw-field label { + font-size: 9px; + font-weight: 800; + letter-spacing: 1.3px; + color: #52525b; +} + +.mw-min-hint { + font-weight: 600; + letter-spacing: 0.4px; + color: #78716c; + text-transform: none; +} + +.mw-field select, +.mw-field input[type='number'], +.mw-field textarea { + width: 100%; + padding: 10px 11px; + border-radius: 7px; + border: 1px solid rgba(255, 255, 255, 0.11); + background: rgba(0, 0, 0, 0.35); + color: #e4e4e7; + font-size: 12px; + font-family: inherit; + resize: vertical; +} + +.mw-field select:focus, +.mw-field input:focus, +.mw-field textarea:focus { + outline: none; + border-color: rgba(239, 68, 68, 0.5); +} + +.mw-kind-toggle { + display: flex; + gap: 6px; +} + +.mw-kind-toggle button { + flex: 1; + padding: 9px; + border-radius: 7px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.035); + color: #9ca3af; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.9px; + cursor: pointer; +} + +.mw-kind-toggle button.active { + background: rgba(239, 68, 68, 0.16); + border-color: rgba(239, 68, 68, 0.42); + color: #fca5a5; +} + +.mw-upload { + padding: 10px; + border-radius: 7px; + border: 1px dashed rgba(255, 255, 255, 0.2); + background: transparent; + color: #9ca3af; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.8px; + cursor: pointer; +} + +.mw-preview { + width: 88px; + height: 88px; + border-radius: 5px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.14); +} + +.mw-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.mw-fee-line { + font-size: 10px; + color: #71717a; +} + +.mw-fee-line strong { + color: #e4e4e7; +} + +.mw-post-btn { + margin-top: 4px; + padding: 14px; + border: none; + border-radius: 9px; + background: linear-gradient(135deg, #dc2626, #991b1b); + color: #fff; + font-size: 12px; + font-weight: 800; + letter-spacing: 1.1px; + cursor: pointer; +} + +.mw-post-btn:active { + transform: scale(0.98); +} + +/* ── Modal ── */ +.mw-overlay { + position: fixed; + inset: 0; + z-index: 900; + display: flex; + align-items: flex-end; + justify-content: center; + background: rgba(0, 0, 0, 0.78); + backdrop-filter: blur(3px); +} + +.mw-modal { + width: 100%; + max-width: 420px; + max-height: 90vh; + overflow-y: auto; + padding: 0 16px 20px; + border-radius: 14px 14px 0 0; + background: linear-gradient(180deg, #e8e0cd 0%, #e8e0cd 55%, #18181b 55%, #0a0a0b 100%); +} + +.mw-modal-banner { + margin: 0 -16px 12px; + padding: 9px 0; + background: #1c1917; + color: #e8e0cd; + font-size: 13px; + font-weight: 900; + letter-spacing: 6px; + text-align: center; + border-radius: 14px 14px 0 0; +} + +.mw-modal-photo { + width: 100%; + aspect-ratio: 4 / 3; + border: 3px solid #57534e; + background: #a8a29e; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.mw-modal-photo img { + width: 100%; + height: 100%; + object-fit: cover; + filter: grayscale(0.5) contrast(1.1); +} + +.mw-modal h3 { + margin: 10px 0 2px; + font-size: 20px; + font-weight: 900; + color: #1c1917; + text-align: center; +} + +.mw-modal-gang { + font-size: 10px; + font-weight: 800; + letter-spacing: 1.2px; + color: #78716c; + text-align: center; + text-transform: uppercase; +} + +.mw-modal-kin { + margin-top: 3px; + font-size: 10px; + font-style: italic; + color: #57534e; + text-align: center; +} + +.mw-modal-reward { + margin: 8px 0 4px; + font-size: 30px; + font-weight: 900; + color: #7f1d1d; + text-align: center; + letter-spacing: -1px; +} + +.mw-modal-note { + margin: 6px 0; + padding: 8px 10px; + border-radius: 5px; + background: rgba(28, 25, 23, 0.09); + font-size: 11px; + color: #44403c; + line-height: 1.55; + text-align: center; +} + +.mw-modal-loc { + font-size: 10px; + color: #57534e; + text-align: center; +} + +.mw-modal-meta { + display: flex; + justify-content: space-between; + padding: 8px 0 14px; + font-size: 9px; + color: #78716c; + border-bottom: 1px solid rgba(28, 25, 23, 0.15); +} + +.mw-fulfil-note, +.mw-modal-own { + margin: 14px 0 10px; + font-size: 11px; + color: #9ca3af; + line-height: 1.6; + text-align: center; +} + +.mw-cash-btn { + width: 100%; + padding: 14px; + border: none; + border-radius: 9px; + background: linear-gradient(135deg, #16a34a, #15803d); + color: #fff; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.9px; + cursor: pointer; +} + +.mw-pull-btn { + width: 100%; + padding: 13px; + border: 1px solid rgba(239, 68, 68, 0.45); + border-radius: 9px; + background: rgba(239, 68, 68, 0.12); + color: #fca5a5; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.9px; + cursor: pointer; +} + +.mw-modal-closed { + padding-top: 14px; +} + +.mw-closed-line { + font-size: 12px; + font-weight: 700; + color: #fcd34d; + text-align: center; + margin-bottom: 10px; +} + +.mw-proof-shot { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mw-proof-shot .mw-section-label, +.mw-section-label { + font-size: 9px; + font-weight: 800; + letter-spacing: 1.3px; + color: #52525b; +} + +.mw-proof-shot img { + width: 100%; + border-radius: 7px; + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.mw-close { + width: 100%; + margin-top: 10px; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 8px; + background: transparent; + color: #71717a; + font-size: 11px; + cursor: pointer; +} diff --git a/frontend/src/components/economy/MostWanted.tsx b/frontend/src/components/economy/MostWanted.tsx new file mode 100644 index 0000000..d737d34 --- /dev/null +++ b/frontend/src/components/economy/MostWanted.tsx @@ -0,0 +1,766 @@ +// ============================================================ +// MostWanted — the bounty board +// Sprint 15-B +// +// Players post contracts on specific members, or on a member's +// special people. Anyone else can take the work, upload proof, and +// cash out. Three things make this interesting: +// +// 1. The payout is public record. Cashing a contract tells the +// target's gang exactly who to come see. +// 2. Fulfilling a contract makes you the poster's ally, which is +// how alliances form without a friend system. +// 3. Contracts on special people are cheaper to survive but uglier +// — they crater the target member's loyalty instead of killing +// them, which is how you make somebody flip. +// +// Verification is trust-first: proof is a screenshot and the payout +// is automatic. A real referee would need a server, so instead the +// receipt is public and reputation does the policing. +// ============================================================ + +import React, { useState, useMemo, useCallback, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + usePlayerStore, + useGangStore, + useEconomyStore, + useNotificationStore, +} from '../../stores/gameStore'; +import { + useMostWantedStore, + BOUNTY_CONFIG, + listingFee, + totalPostingCost, + minimumReward, + formatRemaining, + remainingMs, + canFulfil, +} from '../../stores/mostWantedStore'; +import { useGetBackStore } from '../../stores/getBackStore'; +import { RELATION_LABELS } from '../../utils/marketMembersCatalog'; +import type { MostWantedPoster, SpecialPerson } from '../../types/game.types'; +import { soundManager } from '../../utils/SoundManager'; +import './MostWanted.css'; + +const formatMoney = (n: number): string => { + if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}K`; + return `$${n.toLocaleString()}`; +}; + +type Tab = 'board' | 'mine' | 'post' | 'allies'; + +const TAB_LABELS: Record = { + board: 'THE BOARD', + mine: 'MY CONTRACTS', + post: 'POST ONE', + allies: 'ALLIES', +}; + +/** Read an uploaded file as a data URL so proof survives a reload. */ +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(new Error('Could not read that file.')); + reader.readAsDataURL(file); + }); +} + +const MostWanted: React.FC = () => { + const { player, updateMoney, updateHeat } = usePlayerStore(); + const { members, contacts, addContact } = useGangStore(); + const { addTransaction } = useEconomyStore(); + const { addNotification } = useNotificationStore(); + + const posters = useMostWantedStore((s) => s.posters); + const allies = useMostWantedStore((s) => s.allies); + const postBounty = useMostWantedStore((s) => s.postBounty); + const cancelBounty = useMostWantedStore((s) => s.cancelBounty); + const fulfilBounty = useMostWantedStore((s) => s.fulfilBounty); + + const openWindow = useGetBackStore((s) => s.openWindow); + + const [tab, setTab] = useState('board'); + const [selected, setSelected] = useState(null); + const [toast, setToast] = useState(''); + const proofInputRef = useRef(null); + const posterImageRef = useRef(null); + + // ── Post form state ── + const [formTargetKind, setFormTargetKind] = useState<'member' | 'special_person'>('member'); + const [formTargetId, setFormTargetId] = useState(''); + const [formReward, setFormReward] = useState(BOUNTY_CONFIG.MIN_REWARD); + const [formNote, setFormNote] = useState(''); + const [formImage, setFormImage] = useState(''); + + const flash = useCallback((msg: string) => { + setToast(msg); + setTimeout(() => setToast(''), 2400); + }, []); + + const openBoard = useMemo( + () => posters.filter((p) => p.status === 'open' && remainingMs(p) > 0), + [posters], + ); + + const myPosters = useMemo( + () => posters.filter((p) => p.postedBy === player.id), + [posters, player.id], + ); + + // Opps in the contact book are the valid targets. You cannot post on + // somebody you have never crossed paths with. + const oppContacts = useMemo( + () => contacts.filter((c) => c.status === 'opp'), + [contacts], + ); + + /** + * Special people you know about, flattened across every opp you know. + * Own members are excluded — posting on your own crew's family is not a + * mechanic, it is just self harm. + */ + const knownSpecialPeople = useMemo(() => { + const out: Array<{ person: SpecialPerson; ownerName: string; ownerId: string; gangName: string }> = []; + for (const c of oppContacts) { + const people = (c as unknown as { specialPeople?: SpecialPerson[] }).specialPeople ?? []; + for (const person of people) { + out.push({ + person, + ownerName: c.name, + ownerId: c.memberId ?? c.id, + gangName: (c as unknown as { gangName?: string }).gangName ?? 'Unknown', + }); + } + } + return out; + }, [oppContacts]); + + const selectedTargetLevel = useMemo(() => { + if (formTargetKind === 'member') { + const c = oppContacts.find((x) => (x.memberId ?? x.id) === formTargetId); + return c?.level ?? 1; + } + const entry = knownSpecialPeople.find((x) => x.person.id === formTargetId); + if (!entry) return 1; + const owner = oppContacts.find((x) => (x.memberId ?? x.id) === entry.ownerId); + return owner?.level ?? 1; + }, [formTargetKind, formTargetId, oppContacts, knownSpecialPeople]); + + const minReward = useMemo( + () => minimumReward(selectedTargetLevel, formTargetKind), + [selectedTargetLevel, formTargetKind], + ); + + // ── Post a contract ── + const handlePost = useCallback(() => { + if (!formTargetId) { + flash('PICK A TARGET FIRST'); + return; + } + if (formReward < minReward) { + flash(`MINIMUM FOR THIS TARGET IS ${formatMoney(minReward)}`); + return; + } + + const cost = totalPostingCost(formReward); + if (player.money < cost) { + flash(`NEED ${formatMoney(cost)} INCLUDING THE FEE`); + return; + } + + let targetName = ''; + let targetGangName = 'Unknown'; + let targetOwnerMemberId: string | undefined; + let targetOwnerName: string | undefined; + + if (formTargetKind === 'member') { + const c = oppContacts.find((x) => (x.memberId ?? x.id) === formTargetId); + if (!c) { flash('TARGET NOT FOUND'); return; } + targetName = c.nickname ? `${c.name} "${c.nickname}"` : c.name; + targetGangName = (c as unknown as { gangName?: string }).gangName ?? 'Unknown'; + } else { + const entry = knownSpecialPeople.find((x) => x.person.id === formTargetId); + if (!entry) { flash('TARGET NOT FOUND'); return; } + targetName = entry.person.name; + targetGangName = entry.gangName; + targetOwnerMemberId = entry.ownerId; + targetOwnerName = entry.ownerName; + } + + updateMoney(-cost); + updateHeat(BOUNTY_CONFIG.POSTER_HEAT); + + const poster = postBounty({ + postedBy: player.id, + postedByGangName: player.gangName ?? 'Your Gang', + targetKind: formTargetKind, + targetId: formTargetId, + targetName, + targetGangName, + targetOwnerMemberId, + targetOwnerName, + posterImageUrl: formImage || undefined, + note: formNote.trim() || undefined, + reward: formReward, + }); + + addTransaction({ + id: `tx_bounty_${Date.now()}`, + type: 'purchase', + userId: player.id, + amount: -cost, + details: { + description: `Posted contract on ${targetName} — ${formatMoney(formReward)} reward plus ${formatMoney(listingFee(formReward))} listing fee`, + }, + createdAt: new Date().toISOString(), + } as never); + + addNotification({ + type: 'info', + title: 'CONTRACT POSTED', + message: `${targetName} is on the board for ${formatMoney(formReward)}. Everybody can see it.`, + timestamp: Date.now(), + } as never); + + soundManager.play('cash_register'); + flash(`POSTED ON ${targetName.toUpperCase()}`); + setFormTargetId(''); + setFormReward(BOUNTY_CONFIG.MIN_REWARD); + setFormNote(''); + setFormImage(''); + setTab('mine'); + void poster; + }, [ + formTargetId, formReward, formTargetKind, formNote, formImage, minReward, + player, oppContacts, knownSpecialPeople, updateMoney, updateHeat, + postBounty, addTransaction, addNotification, flash, + ]); + + // ── Fulfil a contract ── + const handleProofUpload = useCallback( + async (file: File) => { + if (!selected) return; + + const check = canFulfil(selected, player.id); + if (!check.allowed) { + flash((check.reason ?? 'CANNOT CASH THIS').toUpperCase()); + return; + } + + let proofUrl: string; + try { + proofUrl = await readFileAsDataUrl(file); + } catch { + flash('COULD NOT READ THAT SCREENSHOT'); + return; + } + + const receipt = fulfilBounty({ + posterId: selected.id, + hunterPlayerId: player.id, + hunterGangName: player.gangName ?? 'Your Gang', + proofImageUrl: proofUrl, + }); + + if (!receipt) { + flash('CONTRACT NO LONGER AVAILABLE'); + return; + } + + // You get paid, and you pick up heat for it. + updateMoney(receipt.reward); + updateHeat(BOUNTY_CONFIG.HUNTER_HEAT); + + addTransaction({ + id: `tx_bounty_paid_${Date.now()}`, + type: 'deal_income', + userId: player.id, + amount: receipt.reward, + details: { + description: `Cashed contract on ${receipt.poster.targetName} — paid by ${receipt.poster.postedByGangName}`, + }, + createdAt: new Date().toISOString(), + } as never); + + // The poster becomes your ally too — business runs both directions. + const alreadyKnown = contacts.some( + (c) => c.id === `ally_${receipt.poster.postedBy}`, + ); + if (!alreadyKnown) { + addContact({ + id: `ally_${receipt.poster.postedBy}`, + name: receipt.poster.postedByGangName, + role: 'ally', + status: 'ally', + notes: [ + `Paid ${formatMoney(receipt.reward)} for the contract on ${receipt.poster.targetName}.`, + ], + } as never); + } + + addNotification({ + type: 'success', + title: 'CONTRACT CASHED', + message: + `${receipt.poster.postedByGangName} paid ${formatMoney(receipt.reward)} for ${receipt.poster.targetName}. ` + + `They are in your book as an ally now — and ${receipt.poster.targetGangName} can see who cashed it.`, + timestamp: Date.now(), + } as never); + + // The target's gang gets a Get Back window. This is the war starter. + openWindow({ + trigger: 'bounty_fulfilled', + lostMemberId: receipt.poster.targetId, + lostMemberName: receipt.poster.targetName, + offendingGangName: player.gangName ?? 'Your Gang', + offendingPlayerId: player.id, + wantedMemberIds: members.filter((m) => m.status === 'active').map((m) => m.id), + wantedMemberNames: members.filter((m) => m.status === 'active').map((m) => m.name), + offenderMoraleGained: 10, + }); + + soundManager.play('cash_register'); + flash(`CASHED ${formatMoney(receipt.reward)}`); + setSelected(null); + }, + [ + selected, player, members, contacts, fulfilBounty, updateMoney, updateHeat, + addTransaction, addContact, addNotification, openWindow, flash, + ], + ); + + const handleCancel = useCallback( + (posterId: string) => { + // Reward is refunded, the listing fee is not. Posting had a cost. + const p = posters.find((x) => x.id === posterId); + if (!p) return; + if (!cancelBounty(posterId)) { + flash('ALREADY CLOSED'); + return; + } + updateMoney(p.reward); + addTransaction({ + id: `tx_bounty_refund_${Date.now()}`, + type: 'deal_income', + userId: player.id, + amount: p.reward, + details: { + description: `Pulled the contract on ${p.targetName} — reward refunded, listing fee kept`, + }, + createdAt: new Date().toISOString(), + } as never); + flash('CONTRACT PULLED — FEE KEPT'); + setSelected(null); + }, + [posters, cancelBounty, updateMoney, addTransaction, player.id, flash], + ); + + // ── Render ── + return ( +
+
+ MOST WANTED + + Post a contract, or cash somebody else’s. Every payout is public — + the target’s people will know exactly who took the work. + +
+ +
+ {(Object.keys(TAB_LABELS) as Tab[]).map((t) => ( + + ))} +
+ + {toast &&
{toast}
} + + {/* ── THE BOARD ── */} + {tab === 'board' && ( +
+ {openBoard.length === 0 ? ( +
+ Board is clear. Nobody has anything out on anybody right now. +
+ ) : ( + openBoard.map((p) => ( + setSelected(p)} + whileTap={{ scale: 0.98 }} + > +
WANTED
+
+ {p.posterImageUrl ? ( + {p.targetName} + ) : ( +
NO PHOTO
+ )} +
+
{p.targetName}
+ {p.targetKind === 'special_person' && p.targetOwnerName && ( +
+ connected to {p.targetOwnerName} +
+ )} +
{p.targetGangName}
+
{formatMoney(p.reward)}
+
+ {formatRemaining(remainingMs(p))} + by {p.postedByGangName} +
+ {p.postedBy === player.id && ( +
YOUR CONTRACT
+ )} +
+ )) + )} +
+ )} + + {/* ── MY CONTRACTS ── */} + {tab === 'mine' && ( +
+ {myPosters.length === 0 ? ( +
+ You have not put anything out. Post one from the tab above. +
+ ) : ( + myPosters.map((p) => ( +
+
+
{p.targetName}
+
+ {p.targetGangName} · {formatMoney(p.reward)} +
+ {p.status === 'paid' && p.claimedByGangName && ( +
+ Cashed by {p.claimedByGangName} +
+ )} +
+
+ + {p.status.toUpperCase()} + + {p.status === 'open' && ( + + )} + {p.status === 'paid' && p.proofImageUrl && ( + + )} +
+
+ )) + )} +
+ )} + + {/* ── POST ONE ── */} + {tab === 'post' && ( +
+
+ You can only post on opps already in your contact book. Contracts on a + member’s people cost more and cost you heat — but they crater that + member’s loyalty instead of killing them, which is how somebody flips. +
+ +
+ +
+ + +
+
+ +
+ + {formTargetKind === 'member' ? ( + oppContacts.length === 0 ? ( +
+ No opps in your book yet. Claim a slide or get slid on first. +
+ ) : ( + + ) + ) : knownSpecialPeople.length === 0 ? ( +
+ You do not know anybody’s people yet. Open an opp’s contact + card to learn who they care about. +
+ ) : ( + + )} +
+ +
+ + { + const f = e.target.files?.[0]; + if (!f) return; + try { + setFormImage(await readFileAsDataUrl(f)); + } catch { + flash('COULD NOT READ THAT IMAGE'); + } + }} + /> + + {formImage && ( +
+ poster preview +
+ )} +
+ +
+ + setFormReward(Number(e.target.value) || 0)} + /> +
+ Listing fee {formatMoney(listingFee(formReward))} · total out of pocket{' '} + {formatMoney(totalPostingCost(formReward))} +
+
+ +
+ +