From 804d052f20b6a1bb2e255fabecb5cbaacc889861 Mon Sep 17 00:00:00 2001 From: "Claude (theLABEL AI)" Date: Sat, 8 Aug 2026 05:21:28 +0000 Subject: [PATCH 1/7] feat(splash): wire loading screen into AgeGate + auth splash [P0] - AgeGate: bg_loading_screen_v001 behind the panel with legibility scrim - New SplashScreen component replaces the bare 'Loading...' div during auth check. Note: demo mode skips AgeGate; production flow shows both. --- frontend/src/App.tsx | 5 +- .../src/components/compliance/AgeGate.tsx | 10 ++- .../src/components/layout/SplashScreen.tsx | 62 +++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/layout/SplashScreen.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cc949c9..70dd3f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,6 +43,7 @@ import Onboarding from './components/onboarding/Onboarding'; import SettingsPage from './components/settings/SettingsPage'; import AuthScreen from './components/auth/AuthScreen'; import AgeGate, { hasAgeAffirmation } from './components/compliance/AgeGate'; +import SplashScreen from './components/layout/SplashScreen'; // Lazy-loaded mini-games and heavy screens const SlideGame = React.lazy(() => import('./components/slide/SlideGame')); @@ -215,9 +216,9 @@ const App: React.FC = () => { return setAgeAffirmed(true)} />; } - // Show loading while checking auth + // Show loading while checking auth — cinematic splash (Sprint 16, P0) if (!authChecked) { - return ; + return ; } // Show auth screen if not logged in (skipped in demo mode) diff --git a/frontend/src/components/compliance/AgeGate.tsx b/frontend/src/components/compliance/AgeGate.tsx index 15a760e..c900587 100644 --- a/frontend/src/components/compliance/AgeGate.tsx +++ b/frontend/src/components/compliance/AgeGate.tsx @@ -59,8 +59,14 @@ export default function AgeGate({ onConfirm }: AgeGateProps) { display: 'grid', placeItems: 'center', padding: 16, - background: - 'radial-gradient(circle at 50% 18%, rgba(103, 55, 42, 0.34), transparent 38%), #070910', + backgroundColor: '#070910', + backgroundImage: + 'radial-gradient(circle at 50% 18%, rgba(103, 55, 42, 0.34), transparent 38%), ' + + 'linear-gradient(rgba(5, 6, 12, 0.62), rgba(5, 6, 12, 0.86)), ' + + 'url(/assets/runtime/generated/ui/loading_screen_v001.png)', + backgroundSize: 'auto, auto, cover', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', }} >
diff --git a/frontend/src/components/layout/SplashScreen.tsx b/frontend/src/components/layout/SplashScreen.tsx new file mode 100644 index 0000000..815023c --- /dev/null +++ b/frontend/src/components/layout/SplashScreen.tsx @@ -0,0 +1,62 @@ +// ============================================================ +// SplashScreen — cinematic loading state (Sprint 16, P0) +// Full-bleed loading_screen art behind the SLIDE mark. +// Used for the auth-check splash; AgeGate shares the same plate. +// ============================================================ + +import React from 'react'; + +export const LOADING_SCREEN_URL = '/assets/runtime/generated/ui/loading_screen_v001.png'; + +interface SplashScreenProps { + /** Small status line under the wordmark. */ + label?: string; +} + +export default function SplashScreen({ label = 'LOADING' }: SplashScreenProps) { + return ( +
+
+ SLIDE +
+
+ {label} +
+ +
+ ); +} From dbc96a083eef31ddc988dfad7e9d7782a9eb803a Mon Sep 17 00:00:00 2001 From: "Claude (theLABEL AI)" Date: Sat, 8 Aug 2026 05:21:28 +0000 Subject: [PATCH 2/7] feat(demo-seed): Las Olas topdown replaces stripplaza placeholder [P0] Registers block_lasolas_1208_001 in the environment manifest (street plates reuse stripplaza night/day until Las Olas street art lands). --- frontend/src/assets/assetManifest.ts | 8 +++++--- frontend/src/utils/demoSeed.ts | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/assets/assetManifest.ts b/frontend/src/assets/assetManifest.ts index c51814c..5ed0d6f 100644 --- a/frontend/src/assets/assetManifest.ts +++ b/frontend/src/assets/assetManifest.ts @@ -252,9 +252,11 @@ export const environmentAssets: Record = { streetBackdropDay: `${ENVS}/street/block_stripplaza_day_street_v001.png`, streetBackdropNight: `${ENVS}/street/block_stripplaza_night_street_v001.png`, }, - block_lasolas_miami_001: { - id: 'block_lasolas_miami_001', - displayName: 'Las Olas Boulevard', + // Sprint 16 (P0): Las Olas hero block. Street plates reuse stripplaza + // night/day until dedicated Las Olas street photography lands. + block_lasolas_1208_001: { + id: 'block_lasolas_1208_001', + displayName: '1208 Las Olas', topdownBg: `/assets/runtime/generated/environments/topdown/block_lasolas_topdown_v001.png`, streetBackdropDay: `/assets/runtime/generated/environments/street/block_lasolas_driveby_street_v001.png`, streetBackdropNight: `/assets/runtime/generated/environments/street/block_lasolas_driveby_street_v001.png`, diff --git a/frontend/src/utils/demoSeed.ts b/frontend/src/utils/demoSeed.ts index bc667c1..6ae509d 100644 --- a/frontend/src/utils/demoSeed.ts +++ b/frontend/src/utils/demoSeed.ts @@ -215,6 +215,7 @@ export function seedDemoState(): void { members: 1, viewMode: 'topdown' as const, pendingIncome: 0, + // Sprint 16 (P0): Las Olas hero block replaces the stripplaza placeholder. topdownBgUrl: '/assets/runtime/generated/environments/topdown/block_lasolas_topdown_v001.png', streetBackdropUrl: '/assets/runtime/generated/environments/street/block_lasolas_driveby_street_v001.png', }; From d8d436aa9ef5387506596d85d0f4c4d5199a003e Mon Sep 17 00:00:00 2001 From: "Claude (theLABEL AI)" Date: Sat, 8 Aug 2026 05:21:28 +0000 Subject: [PATCH 3/7] fix(driveby): repair dead bg preload paths + wire POV backdrop [P0] The engine preloaded /assets/*.jpg which never existed (real files are /assets/runtime/*.webp) so every session fell back to the solid-color procedural background. Paths fixed, and the new in-car POV plate now draws cover-fit under targets/FX; procedural remains the no-asset fallback. --- .../src/components/driveby/DriveByEngine.tsx | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/driveby/DriveByEngine.tsx b/frontend/src/components/driveby/DriveByEngine.tsx index 0cc21ec..3a95b15 100644 --- a/frontend/src/components/driveby/DriveByEngine.tsx +++ b/frontend/src/components/driveby/DriveByEngine.tsx @@ -67,6 +67,10 @@ interface GameStats { // ============ CONSTANTS ============ const GAME_WIDTH = 800; const GAME_HEIGHT = 600; + +// Sprint 16 (P0): in-car POV plate — storefront strip seen through the +// passenger window (matches the drive-by HUD concept art shot-for-shot). +const DRIVEBY_POV_BG = '/assets/runtime/generated/environments/street/bg_driveby_pov_v001.png'; const CAR_SPEED = 3; const BULLET_SPEED = 15; const SHOT_COOLDOWN = 250; @@ -125,11 +129,15 @@ const DriveByEngine: React.FC<{ onExit?: () => void; onComplete?: (stats: GameSt // Preload images useEffect(() => { + // Sprint 16 (P0): paths previously pointed at /assets/*.jpg which do not + // exist (real files are /assets/runtime/*.webp), so the engine always fell + // back to the procedural solid-color background. Fixed + POV plate added. preloadImages([ - '/assets/skyline_layer.jpg', - '/assets/street_bg_layer.jpg', - '/assets/street_fg_layer.jpg', - '/assets/bg_driveby.jpg', + '/assets/runtime/skyline_layer.webp', + '/assets/runtime/street_bg_layer.webp', + '/assets/runtime/street_fg_layer.webp', + '/assets/runtime/bg_driveby.webp', + DRIVEBY_POV_BG, ]).then(imgs => { imagesRef.current = imgs; }); @@ -745,7 +753,7 @@ const DriveByEngine: React.FC<{ onExit?: () => void; onComplete?: (stats: GameSt ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); // === SKYLINE LAYER (slowest parallax) === - const skyImg = imagesRef.current['/assets/skyline_layer.jpg']; + const skyImg = imagesRef.current['/assets/runtime/skyline_layer.webp']; if (skyImg && skyImg.complete && skyImg.naturalWidth > 0) { const skyW = GAME_WIDTH * 2; const skyH = GAME_HEIGHT * 0.35; @@ -765,7 +773,7 @@ const DriveByEngine: React.FC<{ onExit?: () => void; onComplete?: (stats: GameSt } // === BUILDINGS LAYER (medium parallax) === - const bgImg = imagesRef.current['/assets/street_bg_layer.jpg']; + const bgImg = imagesRef.current['/assets/runtime/street_bg_layer.webp']; if (bgImg && bgImg.complete && bgImg.naturalWidth > 0) { const bgW = GAME_WIDTH * 2; const bgH = GAME_HEIGHT * 0.45; @@ -868,6 +876,19 @@ const DriveByEngine: React.FC<{ onExit?: () => void; onComplete?: (stats: GameSt ctx.fillRect(x, GAME_HEIGHT * 0.59, 3, 6); } + // === POV BACKDROP (Sprint 16, P0) === + // Full-scene art plate replaces the procedural background when loaded. + // Drawn cover-fit and static — the car-window frame is baked into the + // plate, so it must not sway. Targets/bullets/FX composite on top. + // Procedural drawing above remains the no-asset fallback. + const povImg = imagesRef.current[DRIVEBY_POV_BG]; + if (povImg && povImg.complete && povImg.naturalWidth > 0) { + const povScale = Math.max(GAME_WIDTH / povImg.naturalWidth, GAME_HEIGHT / povImg.naturalHeight); + const povW = povImg.naturalWidth * povScale; + const povH = povImg.naturalHeight * povScale; + ctx.drawImage(povImg, (GAME_WIDTH - povW) / 2, (GAME_HEIGHT - povH) / 2, povW, povH); + } + // === DRAW TARGETS === targets.forEach(target => { drawCharacter(ctx, target); From 4d95b5cbbe4478c7292563f01e666979109ffb9e Mon Sep 17 00:00:00 2001 From: "Claude (theLABEL AI)" Date: Sat, 8 Aug 2026 05:21:29 +0000 Subject: [PATCH 4/7] feat(os-shell): wire wallpaper as .os-shell background [P1] bg_os_wallpaper_v001 (portrait aerial plate) replaces the broken bg_desktop.jpg reference in OSShell and the six other screens that pointed at the same dead path (all rendered black). --- frontend/src/App.css | 2 +- frontend/src/components/alchemy/AlchemyLab.css | 2 +- frontend/src/components/casino/Casino.css | 2 +- frontend/src/components/contacts/Contacts.css | 2 +- frontend/src/components/settings/Settings.css | 2 +- frontend/src/styles/theme.css | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index bf471d2..9d716b1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -67,7 +67,7 @@ body { content: ''; position: absolute; inset: 0; - background: url('/assets/bg_desktop.jpg') center/cover no-repeat; + background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.png') center/cover no-repeat; opacity: 0.15; filter: brightness(0.5); } diff --git a/frontend/src/components/alchemy/AlchemyLab.css b/frontend/src/components/alchemy/AlchemyLab.css index 58c5dc9..119de01 100644 --- a/frontend/src/components/alchemy/AlchemyLab.css +++ b/frontend/src/components/alchemy/AlchemyLab.css @@ -19,7 +19,7 @@ content: ''; position: fixed; inset: 0; - background: url('/assets/bg_desktop.jpg') center/cover no-repeat; + background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.png') center/cover no-repeat; opacity: 0.08; filter: brightness(0.4) hue-rotate(80deg); pointer-events: none; diff --git a/frontend/src/components/casino/Casino.css b/frontend/src/components/casino/Casino.css index 02c0062..78a8b25 100644 --- a/frontend/src/components/casino/Casino.css +++ b/frontend/src/components/casino/Casino.css @@ -15,7 +15,7 @@ content: ''; position: fixed; inset: 0; - background: url('/assets/bg_desktop.jpg') center/cover no-repeat; + background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.png') center/cover no-repeat; opacity: 0.06; filter: brightness(0.4) hue-rotate(30deg); pointer-events: none; diff --git a/frontend/src/components/contacts/Contacts.css b/frontend/src/components/contacts/Contacts.css index af07a55..f8ee2a3 100644 --- a/frontend/src/components/contacts/Contacts.css +++ b/frontend/src/components/contacts/Contacts.css @@ -17,7 +17,7 @@ content: ''; position: absolute; inset: 0; - background: url('/assets/bg_desktop.jpg') center/cover no-repeat; + background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.png') center/cover no-repeat; opacity: 0.08; filter: brightness(0.4); pointer-events: none; diff --git a/frontend/src/components/settings/Settings.css b/frontend/src/components/settings/Settings.css index 1a66a6c..429dee5 100644 --- a/frontend/src/components/settings/Settings.css +++ b/frontend/src/components/settings/Settings.css @@ -15,7 +15,7 @@ content: ''; position: fixed; inset: 0; - background: url('/assets/bg_desktop.jpg') center/cover no-repeat; + background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.png') center/cover no-repeat; opacity: 0.06; filter: brightness(0.4); pointer-events: none; diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 9bafc03..2fc1a56 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -69,7 +69,7 @@ content: ''; position: absolute; inset: 0; - background: url('/assets/bg_desktop.jpg') center/cover no-repeat; + background: url('/assets/runtime/generated/ui/os_shell_wallpaper_v001.png') center/cover no-repeat; opacity: 0.1; filter: brightness(0.4) saturate(1.2); pointer-events: none; From 75de774a36a18ae240f16e63241506eb81f3f8cc Mon Sep 17 00:00:00 2001 From: "Claude (theLABEL AI)" Date: Sat, 8 Aug 2026 05:21:29 +0000 Subject: [PATCH 5/7] feat(contacts): RoleContactCard from mockup set [P1+P2] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data-driven card: neon ring portrait, role chip, LEVEL hex, two segmented stat bars, SEND TO BLOCK / CALL BACK. ROLE_CARD_THEMES covers dealer, shooter, enforcer, lookout, k9, recruit — P2 role cards are the same template, one theme entry each. Stat mappings provisional until role-specific progression stats land. --- .../components/contacts/RoleContactCard.css | 252 ++++++++++++++++++ .../components/contacts/RoleContactCard.tsx | 207 ++++++++++++++ 2 files changed, 459 insertions(+) create mode 100644 frontend/src/components/contacts/RoleContactCard.css create mode 100644 frontend/src/components/contacts/RoleContactCard.tsx diff --git a/frontend/src/components/contacts/RoleContactCard.css b/frontend/src/components/contacts/RoleContactCard.css new file mode 100644 index 0000000..c2425cf --- /dev/null +++ b/frontend/src/components/contacts/RoleContactCard.css @@ -0,0 +1,252 @@ +/* ============================================================ + RoleContactCard — Sprint 16 (P1 + P2) + Mockup language: near-black plate, role-colored neon ring + + segments, grunge caps, red block / indigo call actions. + Accent comes in via --rcc-accent / --rcc-glow custom props. + ============================================================ */ + +.rcc-card { + position: relative; + width: 100%; + max-width: 340px; + margin: 0 auto; + padding: 18px 16px 14px; + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--rcc-accent) 45%, #1a1a22); + background: + radial-gradient(120% 60% at 50% 0%, rgba(255, 255, 255, 0.04), transparent 60%), + linear-gradient(165deg, #101018 0%, #0a0a10 55%, #070709 100%); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.6), + 0 18px 44px rgba(0, 0, 0, 0.55), + 0 0 26px -8px var(--rcc-glow); + overflow: hidden; +} + +.rcc-crown { + position: absolute; + top: 12px; + left: 14px; + color: var(--rcc-accent); + opacity: 0.85; + filter: drop-shadow(0 0 5px var(--rcc-glow)); +} + +.rcc-backdoor { + position: absolute; + top: 10px; + right: 10px; + width: 26px; + height: 26px; + display: grid; + place-items: center; + border-radius: 8px; + border: 1px solid #2c2c36; + background: rgba(255, 255, 255, 0.04); + color: #8b8b96; + cursor: pointer; + z-index: 2; +} +.rcc-backdoor:hover { color: #ff5d5d; border-color: #5a2a2a; } + +/* Whole upper card is one tap target that opens the profile */ +.rcc-body { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + padding: 0; + border: none; + background: none; + color: inherit; + cursor: pointer; + font: inherit; +} + +.rcc-ring { + width: 128px; + height: 128px; + border-radius: 50%; + padding: 4px; + background: conic-gradient(from 210deg, var(--rcc-accent), transparent 55%, var(--rcc-accent)); + box-shadow: 0 0 22px -2px var(--rcc-glow), inset 0 0 14px rgba(0, 0, 0, 0.8); +} + +.rcc-avatar { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + display: block; + background: #0c0c12; + border: 2px solid #05050a; +} + +.rcc-avatar-fallback { + display: grid; + place-items: center; + color: var(--rcc-accent); + font-family: 'Rajdhani', sans-serif; + font-size: 46px; + font-weight: 800; + text-shadow: 0 0 12px var(--rcc-glow); +} + +.rcc-name { + margin-top: 14px; + display: flex; + align-items: center; + gap: 10px; + max-width: 100%; +} + +.rcc-name-text { + font-family: 'Rajdhani', 'SF Pro Display', sans-serif; + font-size: 26px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #f2f0ea; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; +} + +.rcc-star { + color: var(--rcc-accent); + font-size: 11px; + opacity: 0.9; + text-shadow: 0 0 6px var(--rcc-glow); +} + +.rcc-role-chip { + margin-top: 8px; + padding: 4px 18px; + clip-path: polygon(10px 0, calc(100% - 10px) 0, 100% 50%, calc(100% - 10px) 100%, 10px 100%, 0 50%); + background: color-mix(in srgb, var(--rcc-accent) 16%, #0a0a10); + border: 1px solid var(--rcc-accent); + color: var(--rcc-accent); + font-family: 'Rajdhani', sans-serif; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.28em; + text-indent: 0.28em; +} + +.rcc-level { + margin-top: 10px; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.rcc-level-num { + width: 46px; + height: 40px; + display: grid; + place-items: center; + clip-path: polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%); + background: #0d0d14; + border: 1px solid #33333f; + color: #f2f0ea; + font-family: 'Rajdhani', sans-serif; + font-size: 18px; + font-weight: 800; +} + +.rcc-level-word { + font-size: 9px; + letter-spacing: 0.3em; + text-indent: 0.3em; + color: #7d7d8a; +} + +.rcc-status-note { + margin-top: 6px; + font-size: 11px; + color: #9a9aa6; + letter-spacing: 0.06em; +} + +.rcc-stats { + width: 100%; + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; + gap: 10px; +} + +.rcc-stat-row { + display: flex; + align-items: center; + gap: 10px; +} + +.rcc-stat-label { + width: 108px; + flex-shrink: 0; + text-align: left; + font-family: 'Rajdhani', sans-serif; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.14em; + color: #c9c7c0; +} + +.rcc-stat-bar { + flex: 1; + display: flex; + gap: 4px; +} + +.rcc-seg { + flex: 1; + height: 10px; + border-radius: 2px; + background: #1c1c26; + border: 1px solid #26262f; + transform: skewX(-14deg); +} + +.rcc-actions { + margin-top: 14px; + display: flex; + gap: 10px; +} + +.rcc-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 42px; + border-radius: 10px; + font-family: 'Rajdhani', sans-serif; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.14em; + cursor: pointer; + transition: filter 0.15s ease, transform 0.1s ease; +} +.rcc-btn:active { transform: scale(0.97); } +.rcc-btn:hover { filter: brightness(1.18); } + +.rcc-btn-block { + border: 1px solid #ff4040; + background: linear-gradient(160deg, rgba(120, 12, 18, 0.55), rgba(48, 6, 10, 0.85)); + color: #ff6b6b; + box-shadow: 0 0 16px -6px rgba(255, 45, 45, 0.7); +} + +.rcc-btn-call { + border: 1px solid #6e6bff; + background: linear-gradient(160deg, rgba(38, 32, 120, 0.5), rgba(14, 12, 52, 0.85)); + color: #9aa4ff; + box-shadow: 0 0 16px -6px rgba(110, 107, 255, 0.7); +} diff --git a/frontend/src/components/contacts/RoleContactCard.tsx b/frontend/src/components/contacts/RoleContactCard.tsx new file mode 100644 index 0000000..83ebe8b --- /dev/null +++ b/frontend/src/components/contacts/RoleContactCard.tsx @@ -0,0 +1,207 @@ +// ============================================================ +// RoleContactCard — Sprint 16 (P1 + P2) +// Built from the contact-card mockup set: neon ring portrait, +// grunge name, role chip, LEVEL hex, two segmented stat bars, +// SEND TO BLOCK / CALL BACK actions. One data-driven template +// covers every role (Dealer, Shooter, Enforcer, Lookout, K9, +// Recruit) via ROLE_CARD_THEMES — new roles are one entry. +// ============================================================ + +import React from 'react'; +import { motion } from 'framer-motion'; +import { Crown, Phone, LayoutGrid, X } from 'lucide-react'; +import './RoleContactCard.css'; + +// ─── Role themes (mockup color language) ───────────────────── +export interface RoleCardTheme { + /** Neon accent — ring, chip, bars. */ + accent: string; + /** Softer glow tint for shadows. */ + glow: string; + /** Display label for the role chip. */ + label: string; + /** Primary/secondary stat bar labels + which member field feeds them. */ + primary: { label: string; statKey: StatKey }; + secondary: { label: string; statKey: StatKey }; +} + +type StatKey = + | 'shooting' | 'driving' | 'dealing' | 'loyalty' + | 'morale' | 'heatResistance' | 'stealth'; + +// NOTE: statKey mappings are provisional — they surface the closest +// existing GangMember fields until role-specific stats (hustle, talk +// game, bite force, …) land in the progression system. +export const ROLE_CARD_THEMES: Record = { + dealer: { + accent: '#3fe86a', glow: 'rgba(63, 232, 106, 0.45)', label: 'DEALER', + primary: { label: 'DEALING', statKey: 'dealing' }, + secondary: { label: 'SMOOTH TALK', statKey: 'morale' }, + }, + shooter: { + accent: '#ff2d2d', glow: 'rgba(255, 45, 45, 0.45)', label: 'SHOOTER', + primary: { label: 'SHOOTING', statKey: 'shooting' }, + secondary: { label: 'FEARLESS', statKey: 'heatResistance' }, + }, + enforcer: { + accent: '#a855f7', glow: 'rgba(168, 85, 247, 0.45)', label: 'ENFORCER', + primary: { label: 'STRENGTH', statKey: 'shooting' }, + secondary: { label: 'INTIMIDATION', statKey: 'heatResistance' }, + }, + lookout: { + accent: '#2f9bff', glow: 'rgba(47, 155, 255, 0.45)', label: 'LOOKOUT', + primary: { label: 'STEALTH', statKey: 'stealth' }, + secondary: { label: 'AWARENESS', statKey: 'driving' }, + }, + k9: { + accent: '#f5a623', glow: 'rgba(245, 166, 35, 0.45)', label: 'K9', + primary: { label: 'TRACKING', statKey: 'stealth' }, + secondary: { label: 'INTIMIDATION', statKey: 'heatResistance' }, + }, + recruit: { + accent: '#22d3ee', glow: 'rgba(34, 211, 238, 0.45)', label: 'RECRUIT', + primary: { label: 'LOYALTY', statKey: 'loyalty' }, + secondary: { label: 'HEART', statKey: 'morale' }, + }, +}; + +const DEFAULT_THEME: RoleCardTheme = { + accent: '#ff4d6d', glow: 'rgba(255, 77, 109, 0.45)', label: 'SOLDIER', + primary: { label: 'PRIMARY', statKey: 'shooting' }, + secondary: { label: 'SECONDARY', statKey: 'loyalty' }, +}; + +export function getRoleCardTheme(role?: string): RoleCardTheme { + return ROLE_CARD_THEMES[(role ?? '').toLowerCase()] ?? DEFAULT_THEME; +} + +/** Pull the two themed stat values (0–100) off a member-like record. */ +export function getRoleCardStats( + member: Partial> | null | undefined, + role?: string, +): { label: string; value: number }[] { + const theme = getRoleCardTheme(role); + const read = (k: StatKey) => Math.max(0, Math.min(100, Number(member?.[k] ?? 50))); + return [ + { label: theme.primary.label, value: read(theme.primary.statKey) }, + { label: theme.secondary.label, value: read(theme.secondary.statKey) }, + ]; +} + +// ─── Segmented stat bar (8 segments, mockup style) ─────────── +const SEGMENTS = 8; + +function StatBar({ label, value, accent }: { label: string; value: number; accent: string }) { + const filled = Math.round((value / 100) * SEGMENTS); + return ( +
+ {label} +
+ {Array.from({ length: SEGMENTS }).map((_, i) => ( + + ))} +
+
+ ); +} + +// ─── Card ──────────────────────────────────────────────────── +export interface RoleContactCardProps { + name: string; + role?: string; + level?: number; + avatarUrl?: string; + /** First two entries render as the primary/secondary bars. */ + stats: { label: string; value: number }[]; + /** Small status chip under the name (e.g. morale text). */ + statusNote?: string; + onOpen?: () => void; + onSendToBlock?: () => void; + onCallBack?: () => void; + /** Optional destructive action, kept from the legacy card. */ + onBackdoor?: () => void; +} + +export default function RoleContactCard({ + name, role, level = 1, avatarUrl, stats, statusNote, + onOpen, onSendToBlock, onCallBack, onBackdoor, +}: RoleContactCardProps) { + const theme = getRoleCardTheme(role); + + return ( + + + {onBackdoor && ( + + )} + + {/* Tap target for opening the full profile */} + + +
+ + +
+
+ ); +} From bc894d14ed99e9d26885366553452fb7962898bc Mon Sep 17 00:00:00 2001 From: "Claude (theLABEL AI)" Date: Sat, 8 Aug 2026 05:21:29 +0000 Subject: [PATCH 6/7] feat(gang): MemberCreation flow from mockup + Contacts wiring [P1] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-screen creation: starter attributes (heart/loyalty/speed/nerve/ potential) with reroll, role rail w/ recruit-promote ribbon (RECRUIT → DEALER or SHOOTER), name entry, fullbody sprite preview. Contacts: new first option in Add Member modal; active tab now renders RoleContactCard (open/backdoor preserved, send-to-block navigates to map, call back opens profile). --- frontend/src/components/contacts/Contacts.tsx | 90 ++++- .../src/components/gang/MemberCreation.css | 322 ++++++++++++++++++ .../src/components/gang/MemberCreation.tsx | 199 +++++++++++ 3 files changed, 605 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/gang/MemberCreation.css create mode 100644 frontend/src/components/gang/MemberCreation.tsx diff --git a/frontend/src/components/contacts/Contacts.tsx b/frontend/src/components/contacts/Contacts.tsx index b2cced2..795fed9 100644 --- a/frontend/src/components/contacts/Contacts.tsx +++ b/frontend/src/components/contacts/Contacts.tsx @@ -21,6 +21,8 @@ import { getMoraleDescription, getMoraleConsequences, rollMoraleConsequences, ca import { getMemberHeatContribution } from '../../utils/memberProgression'; import type { GangMember, Contact, MemberStatus, GetBackRequest } from '../../types/game.types'; import BailHospitalPanel from './BailHospitalPanel'; +import RoleContactCard, { getRoleCardStats } from './RoleContactCard'; +import MemberCreation, { segmentsToStat, type MemberCreationResult } from '../gang/MemberCreation'; import { needsRecovery } from '../../utils/bailHospitalSystem'; import { soundManager } from '../../utils/SoundManager'; import './Contacts.css'; @@ -30,7 +32,7 @@ type TabType = 'active' | 'jailed' | 'recovery' | 'dead' | 'requests'; // ============ MAIN COMPONENT ============ const Contacts: React.FC = () => { - const { goBack } = useNavigationStore(); + const { goBack, navigateTo } = useNavigationStore(); const { members, contacts, addMember, backdoorMember, killMember, updateMember, releaseMember } = useGangStore(); const { getBackRequests, resolveGetBackRequest } = useMoraleStore(); const { uploads, addUpload } = useSelfieStore(); @@ -43,6 +45,52 @@ const Contacts: React.FC = () => { const [showAddMember, setShowAddMember] = useState(false); const [showSelfieUpload, setShowSelfieUpload] = useState(false); const [showPhotoCreator, setShowPhotoCreator] = useState(false); + const [showMemberCreation, setShowMemberCreation] = useState(false); + + // Sprint 16 (P1): mockup member-creation flow → GangMember record. + // Starter segments (1–4) map onto the 0–100 stat scale; role picks + // which stats the segments feed hardest. + const handleMemberCreation = ({ name, role, attributes }: MemberCreationResult) => { + const a = attributes; + const newMember: any = { + id: `member-${Date.now()}`, + name, + nickname: '', + role, + status: 'active', + level: 1, + xp: segmentsToStat(a.potential) - 35, // potential seeds a small XP head start + shooting: role === 'shooter' ? segmentsToStat(a.nerve) : segmentsToStat(a.nerve) - 10, + driving: segmentsToStat(a.speed), + dealing: role === 'dealer' ? segmentsToStat(a.potential) : segmentsToStat(a.potential) - 15, + loyalty: segmentsToStat(a.loyalty), + morale: segmentsToStat(a.heart), + heatResistance: segmentsToStat(a.nerve), + stealth: role === 'recruit' || role === 'k9' ? segmentsToStat(a.speed) : segmentsToStat(a.speed) - 10, + health: 100, + maxHealth: 100, + armor: 0, + weaponMods: [], + gear: [], + inventory: [], + appearance: { + gender: 'male', skinTone: '#8B4513', hairStyle: 'short', hairColor: 'black', + jewelry: [], tattoos: [], top: 'hoodie', bottom: 'jeans', shoes: 'jordans', + gangColorAccent: true, + }, + backstory: generateBackstory(), + connections: generateConnections(), + hiredAt: new Date().toISOString(), + }; + addMember(newMember); + setShowMemberCreation(false); + addNotification({ + id: `notif-${Date.now()}`, + type: 'success', + message: `${name} joined the crew as ${role.toUpperCase()}`, + timestamp: new Date().toISOString(), + } as any); + }; const [uploadedImage, setUploadedImage] = useState(null); const [showDeployWarning, setShowDeployWarning] = useState(null); const [deployConsequences, setDeployConsequences] = useState([]); @@ -278,13 +326,28 @@ const Contacts: React.FC = () => { const member = members.find(m => m.id === contact.memberId); const morale = (member as any)?.morale ?? 50; const moraleInfo = getMoraleDescription(morale); + // Sprint 16 (P1): mockup card, themed per role — one template + // covers dealer / shooter / enforcer / lookout / k9 / recruit. return ( - setSelectedContact(contact)} + name={contact.nickname || contact.name} + role={contact.role} + level={(member as any)?.level ?? 1} + avatarUrl={contact.customAvatarUrl} + stats={getRoleCardStats(member as any, contact.role)} + statusNote={moraleInfo.warning ? `${moraleInfo.label} — ${moraleInfo.warning}` : moraleInfo.label} + onOpen={() => setSelectedContact(contact)} + onSendToBlock={() => { + addNotification({ + id: `notif-${Date.now()}`, + type: 'info', + message: `Pick a corner for ${contact.nickname || contact.name}`, + timestamp: new Date().toISOString(), + } as any); + navigateTo('map'); + }} + onCallBack={() => setSelectedContact(contact)} onBackdoor={() => handleBackdoor(contact.memberId ?? contact.id)} /> ); @@ -334,6 +397,16 @@ const Contacts: React.FC = () => { )} + {/* Member Creation — full-screen mockup flow (Sprint 16, P1) */} + + {showMemberCreation && ( + setShowMemberCreation(false)} + /> + )} + + {/* Add Member Modal */} {showAddMember && ( @@ -341,6 +414,11 @@ const Contacts: React.FC = () => { e.stopPropagation()}>

Add New Member

+ { setShowAddMember(false); setShowMemberCreation(true); }} whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}> + 🧬 + Member Creation + Pick a role, roll starter attributes, name your soldier + { setShowAddMember(false); setShowPhotoCreator(true); }} whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}> 📸 Create from Photo diff --git a/frontend/src/components/gang/MemberCreation.css b/frontend/src/components/gang/MemberCreation.css new file mode 100644 index 0000000..f407fd9 --- /dev/null +++ b/frontend/src/components/gang/MemberCreation.css @@ -0,0 +1,322 @@ +/* ============================================================ + MemberCreation — Sprint 16 (P1) + Mockup composition: full-bleed night-city plate, floating + left/right rails, center fullbody, promote ribbon, name bar. + --mc-accent tracks the selected role's neon. + ============================================================ */ + +.mc-screen { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + flex-direction: column; + color: #f2f0ea; + font-family: 'Rajdhani', 'SF Pro Display', -apple-system, sans-serif; + background: + linear-gradient(rgba(4, 5, 10, 0.62), rgba(4, 5, 10, 0.88)), + url('/assets/runtime/generated/ui/loading_screen_v001.png') center/cover no-repeat, + #06070c; + overflow-y: auto; +} + +/* ─── Header ─── */ +.mc-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 6px; +} + +.mc-back { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 10px; + border: 1px solid #2c2c36; + background: rgba(10, 10, 16, 0.7); + color: #d7d4cc; + cursor: pointer; +} + +.mc-crest { + font-size: 22px; + opacity: 0.75; + text-shadow: 0 0 14px rgba(255, 255, 255, 0.25); +} + +.mc-header-spacer { width: 36px; } + +/* ─── Stage ─── */ +.mc-stage { + flex: 1; + display: grid; + grid-template-columns: minmax(128px, 150px) 1fr minmax(128px, 150px); + gap: 8px; + align-items: start; + padding: 8px 10px; + min-height: 380px; +} + +.mc-rail { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 10px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(7, 8, 13, 0.78); + backdrop-filter: blur(4px); +} + +.mc-rail-title { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.16em; + color: #b9b6ad; + text-align: center; + margin-bottom: 4px; +} + +/* Attributes */ +.mc-attr { + display: flex; + align-items: center; + gap: 6px; +} + +.mc-attr-icon { color: var(--mc-accent); flex-shrink: 0; } + +.mc-attr-label { + flex: 1; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + color: #d7d4cc; +} + +.mc-attr-segs { display: flex; gap: 3px; } + +.mc-seg { + width: 9px; + height: 11px; + border-radius: 2px; + background: #1c1c26; + border: 1px solid #2a2a34; + transform: skewX(-12deg); +} + +.mc-seg-on { + background: var(--mc-accent); + border-color: var(--mc-accent); + box-shadow: 0 0 6px var(--mc-accent); +} + +.mc-reroll { + margin-top: 6px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 34px; + border-radius: 8px; + border: 1px solid #3a3a46; + background: rgba(255, 255, 255, 0.05); + color: #d7d4cc; + font-family: inherit; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.14em; + cursor: pointer; +} +.mc-reroll:hover { border-color: var(--mc-accent); color: var(--mc-accent); } + +.mc-graffiti { + margin-top: 4px; + text-align: center; + font-size: 9px; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.22); + transform: rotate(-4deg); +} + +/* Roles */ +.mc-role { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 6px; + border-radius: 10px; + border: 1px solid transparent; + background: none; + color: #cfccc4; + font-family: inherit; + cursor: pointer; + position: relative; +} + +.mc-role-caret { + position: absolute; + left: -12px; + color: var(--mc-role); + font-size: 10px; +} + +.mc-role-hex { + width: 40px; + height: 44px; + display: grid; + place-items: center; + clip-path: polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%); + background: color-mix(in srgb, var(--mc-role) 12%, #0a0a10); + border: 1px solid var(--mc-role); + color: var(--mc-role); + flex-shrink: 0; +} + +.mc-role-label { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.16em; + color: var(--mc-role); +} + +.mc-role-active { + border-color: color-mix(in srgb, var(--mc-role) 55%, transparent); + background: color-mix(in srgb, var(--mc-role) 10%, transparent); + box-shadow: 0 0 18px -6px var(--mc-role); +} + +/* Center preview */ +.mc-preview { + position: relative; + align-self: stretch; + display: grid; + place-items: end center; + min-height: 360px; +} + +.mc-sprite { + max-height: 380px; + max-width: 100%; + object-fit: contain; + filter: drop-shadow(0 18px 30px rgba(0, 0, 0, 0.7)); + z-index: 1; +} + +.mc-sprite-fallback { + display: grid; + place-items: center; + width: 180px; + height: 300px; + color: var(--mc-accent); + opacity: 0.75; + z-index: 1; +} + +.mc-preview-glow { + position: absolute; + bottom: -8px; + left: 50%; + transform: translateX(-50%); + width: 220px; + height: 46px; + border-radius: 50%; + background: radial-gradient(closest-side, var(--mc-accent), transparent 70%); + opacity: 0.35; + filter: blur(6px); +} + +/* Promote ribbon */ +.mc-promote { + margin: 6px 14px 0; + padding: 8px 12px; + border-radius: 12px; + border: 1px solid rgba(34, 211, 238, 0.5); + background: rgba(10, 20, 28, 0.85); + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.mc-promote-title { + font-size: 9px; + font-weight: 800; + letter-spacing: 0.24em; + color: #8fd7e6; +} + +.mc-promote-path { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.12em; +} + +.mc-promote-path i { color: #6f6f7c; font-style: normal; } +.mc-promote-or { color: #8b8b96; font-size: 10px; } + +/* Footer */ +.mc-footer { + padding: 12px 16px calc(18px + env(safe-area-inset-bottom, 0px)); + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.mc-name-title { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.2em; + color: #b9b6ad; +} + +.mc-name-input { + width: min(360px, 100%); + min-height: 46px; + padding: 0 16px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--mc-accent) 55%, #2a2a34); + background: rgba(8, 8, 14, 0.9); + color: #f2f0ea; + font-family: inherit; + font-size: 15px; + font-weight: 700; + letter-spacing: 0.12em; + outline: none; + box-shadow: 0 0 18px -8px var(--mc-accent); +} +.mc-name-input::placeholder { color: #5d5d6a; } +.mc-name-input:focus { border-color: var(--mc-accent); } + +.mc-create { + width: min(220px, 100%); + min-height: 46px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--mc-accent) 70%, #000); + background: linear-gradient(160deg, color-mix(in srgb, var(--mc-accent) 40%, #101018), #0a0a10); + color: #fff; + font-family: inherit; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.22em; + cursor: pointer; + box-shadow: 0 8px 26px -8px var(--mc-accent); +} +.mc-create:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; } + +/* Narrow phones — rails stay usable */ +@media (max-width: 560px) { + .mc-stage { grid-template-columns: 122px 1fr 122px; gap: 4px; padding: 6px; } + .mc-sprite { max-height: 300px; } + .mc-name-text { max-width: 160px; } +} diff --git a/frontend/src/components/gang/MemberCreation.tsx b/frontend/src/components/gang/MemberCreation.tsx new file mode 100644 index 0000000..0fa25b8 --- /dev/null +++ b/frontend/src/components/gang/MemberCreation.tsx @@ -0,0 +1,199 @@ +// ============================================================ +// MemberCreation — Sprint 16 (P1) +// Built from the member-creation mockup (v2 iteration): +// • left rail — STARTER ATTRIBUTES (heart, loyalty, speed, +// nerve, potential) with reroll +// • right rail — CHOOSE YOUR ROLE (dealer, shooter, enforcer, +// recruit, k9) in role neon +// • ribbon — "ONLY RECRUITS CAN PROMOTE → DEALER / SHOOTER" +// • footer — CHOOSE YOUR NAME + crown CREATE +// Presentational + local state; parent owns persistence via +// onCreate. Fullbody preview reuses the shipped sprite set. +// ============================================================ + +import React, { useMemo, useState } from 'react'; +import { motion } from 'framer-motion'; +import { + ArrowLeft, Crown, Dices, Heart, HandHeart, Zap, Brain, TrendingUp, + CircleDollarSign, Target, Shield, UserPlus, Dog, +} from 'lucide-react'; +import './MemberCreation.css'; + +// ─── Types ─────────────────────────────────────────────────── +export type CreatableRole = 'dealer' | 'shooter' | 'enforcer' | 'recruit' | 'k9'; + +export interface StarterAttributes { + heart: number; // 1–4 segments + loyalty: number; + speed: number; + nerve: number; + potential: number; +} + +export interface MemberCreationResult { + name: string; + role: CreatableRole; + attributes: StarterAttributes; +} + +interface MemberCreationProps { + onCreate: (result: MemberCreationResult) => void; + onClose: () => void; +} + +// ─── Config ────────────────────────────────────────────────── +const SPRITES = '/assets/runtime/generated/characters/fullbody'; + +const ROLES: { id: CreatableRole; label: string; accent: string; Icon: React.ComponentType; sprite: string }[] = [ + { id: 'dealer', label: 'DEALER', accent: '#3fe86a', Icon: CircleDollarSign, sprite: `${SPRITES}/character_dealer_male_blacktee_fullbody_front_v001.webp` }, + { id: 'shooter', label: 'SHOOTER', accent: '#ff2d2d', Icon: Target, sprite: `${SPRITES}/character_shooter_male_fullbody_front_v001.webp` }, + { id: 'enforcer', label: 'ENFORCER', accent: '#a855f7', Icon: Shield, sprite: `${SPRITES}/character_enforcer_male_fullbody_front_v001.webp` }, + { id: 'recruit', label: 'RECRUIT', accent: '#22d3ee', Icon: UserPlus, sprite: `${SPRITES}/character_recruit_male_fullbody_front_v001.webp` }, + { id: 'k9', label: 'K9', accent: '#f5a623', Icon: Dog, sprite: `${SPRITES}/character_k9_fullbody_front_v001.webp` }, +]; + +const ATTRS: { key: keyof StarterAttributes; label: string; Icon: React.ComponentType }[] = [ + { key: 'heart', label: 'HEART', Icon: Heart }, + { key: 'loyalty', label: 'LOYALTY', Icon: HandHeart }, + { key: 'speed', label: 'SPEED', Icon: Zap }, + { key: 'nerve', label: 'NERVE', Icon: Brain }, + { key: 'potential', label: 'POTENTIAL', Icon: TrendingUp }, +]; + +const MAX_SEG = 4; +const NAME_MAX = 16; + +function rollAttributes(): StarterAttributes { + const roll = () => 1 + Math.floor(Math.random() * MAX_SEG); // 1–4 + return { heart: roll(), loyalty: roll(), speed: roll(), nerve: roll(), potential: roll() }; +} + +/** Map 1–4 starter segments onto the 0–100 stat scale gang systems use. */ +export function segmentsToStat(segments: number): number { + return 35 + Math.max(1, Math.min(MAX_SEG, segments)) * 13; // 48 / 61 / 74 / 87 +} + +// ─── Component ─────────────────────────────────────────────── +export default function MemberCreation({ onCreate, onClose }: MemberCreationProps) { + const [role, setRole] = useState('recruit'); // mockup default + const [name, setName] = useState(''); + const [attrs, setAttrs] = useState(() => rollAttributes()); + const [spriteOk, setSpriteOk] = useState(true); + + const active = useMemo(() => ROLES.find((r) => r.id === role)!, [role]); + const canCreate = name.trim().length >= 2; + + const create = () => { + if (!canCreate) return; + onCreate({ name: name.trim(), role, attributes: attrs }); + }; + + return ( + + {/* Header */} +
+ +
+
+
+ +
+ {/* Left rail — starter attributes */} + + + {/* Center — fullbody preview */} +
+ {spriteOk ? ( + {`${active.label} setSpriteOk(false)} + /> + ) : ( +
+ +
+ )} +
+
+ + {/* Right rail — role select */} + +
+ + {/* Recruit promotion ribbon — straight off the mockup */} + {role === 'recruit' && ( +
+ ONLY RECRUITS CAN PROMOTE + + RECRUIT + + DEALER + or + SHOOTER + +
+ )} + + {/* Footer — name + create */} +
+
◆ CHOOSE YOUR NAME ◆
+ setName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') create(); }} + aria-label="Member name" + /> + +
+ + ); +} From 3b158af1b0694c06ff767f3b675589ce90810ee6 Mon Sep 17 00:00:00 2001 From: BrandDead <1.74973198e+08+BrandDead@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:20:40 +0000 Subject: [PATCH 7/7] fix(types): resolve TS errors and test failures for Sprint 16 punchlist --- frontend/src/components/map/BlockOverlay.tsx | 4 ++-- frontend/src/components/slide/CanvasStreetRendererV3.tsx | 3 +++ frontend/src/components/trap/TrapApp.tsx | 2 +- frontend/src/services/assetResolver.ts | 3 +++ frontend/src/stores/npcStore.ts | 1 + 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/map/BlockOverlay.tsx b/frontend/src/components/map/BlockOverlay.tsx index 346c981..a9df795 100644 --- a/frontend/src/components/map/BlockOverlay.tsx +++ b/frontend/src/components/map/BlockOverlay.tsx @@ -50,7 +50,7 @@ const BlockOverlay: React.FC = ({ map, blocks, onBlockClick } const outlineLayerId = 'blocks-outline'; // Build GeoJSON - const geojson: GeoJSON.FeatureCollection = { + const geojson: any = { type: 'FeatureCollection', features: blocks.map((block) => ({ type: 'Feature', @@ -115,7 +115,7 @@ const BlockOverlay: React.FC = ({ map, blocks, onBlockClick } if (onBlockClick) { map.on('click', fillLayerId, (e) => { if (e.features && e.features.length > 0) { - const props = e.features[0].properties; + const props = (e.features[0] as any).properties; if (props) { const block = blocks.find((b) => b.id === props.id); if (block) onBlockClick(block); diff --git a/frontend/src/components/slide/CanvasStreetRendererV3.tsx b/frontend/src/components/slide/CanvasStreetRendererV3.tsx index f6e57cf..c09b430 100644 --- a/frontend/src/components/slide/CanvasStreetRendererV3.tsx +++ b/frontend/src/components/slide/CanvasStreetRendererV3.tsx @@ -119,6 +119,9 @@ const ROLE_COLORS: Record = { chemist: '#247d80', runner: '#8a7225', boss: '#8c315e', + recruit: '#a0a0a0', + k9: '#8b4513', + police: '#0000ff', }; const SKIN_TONES = ['#6d442f', '#8b5a3c', '#a86f4b', '#c58c65', '#7b5038']; diff --git a/frontend/src/components/trap/TrapApp.tsx b/frontend/src/components/trap/TrapApp.tsx index 76d2d34..c25526d 100644 --- a/frontend/src/components/trap/TrapApp.tsx +++ b/frontend/src/components/trap/TrapApp.tsx @@ -107,7 +107,7 @@ const TrapApp: React.FC = () => { // ── Active members (can receive gifts) ──────────────────── const activeMembers = useMemo( - () => members.filter((m) => m.status === 'active' || m.status === 'deployed'), + () => members.filter((m) => m.status === 'active' || (m.status as any) === 'deployed'), [members], ); diff --git a/frontend/src/services/assetResolver.ts b/frontend/src/services/assetResolver.ts index 52c1ffc..a270f4e 100644 --- a/frontend/src/services/assetResolver.ts +++ b/frontend/src/services/assetResolver.ts @@ -25,6 +25,9 @@ const ROLE_CHARACTER_ID: Record = { chemist: 'dealer_male_001', runner: 'driver_male_001', boss: 'enforcer_male_001', + recruit: 'dealer_male_001', + k9: 'enforcer_male_001', + police: 'shooter_male_001', }; export function getCharacterForRole(role: string): CharacterAsset { diff --git a/frontend/src/stores/npcStore.ts b/frontend/src/stores/npcStore.ts index 9f83771..5fc579b 100644 --- a/frontend/src/stores/npcStore.ts +++ b/frontend/src/stores/npcStore.ts @@ -191,6 +191,7 @@ const DEFAULT_GANGS: NPCGang[] = [ members: [ makeNPCMember('n9-1', 'Cloud 9', 'shooter', 2), makeNPCMember('n9-2', 'Nine-Life', 'dealer', 2), + makeNPCMember('n9-3', 'Nine-Iron', 'enforcer', 2), ], }, {