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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -215,9 +216,9 @@ const App: React.FC = () => {
return <AgeGate onConfirm={() => setAgeAffirmed(true)} />;
}

// Show loading while checking auth
// Show loading while checking auth — cinematic splash (Sprint 16, P0)
if (!authChecked) {
return <LazyFallback />;
return <SplashScreen label="LOADING" />;
}

// Show auth screen if not logged in (skipped in demo mode)
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/assets/assetManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,11 @@ export const environmentAssets: Record<string, EnvironmentAsset> = {
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`,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/alchemy/AlchemyLab.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/casino/Casino.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/compliance/AgeGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}}
>
<section style={panelStyle} aria-labelledby="age-gate-title">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/contacts/Contacts.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
90 changes: 84 additions & 6 deletions frontend/src/components/contacts/Contacts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Seed the canonical experience field during creation

When potential grants a new member an XP head start, only the optional xp alias is populated. The progression panel, Slide rewards, and dealer game loop all read member.experience, so the member initially displays zero XP and the first normal reward is calculated from zero, discarding the promised 13–52 starting XP. Initialize experience consistently with xp here.

Useful? React with 👍 / 👎.

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<string | null>(null);
const [showDeployWarning, setShowDeployWarning] = useState<string | null>(null);
const [deployConsequences, setDeployConsequences] = useState<any[]>([]);
Expand Down Expand Up @@ -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 (
<ContactCard
<RoleContactCard
key={contact.id}
contact={contact}
moraleBadge={moraleInfo}
memberLevel={(member as any)?.level}
onClick={() => setSelectedContact(contact)}
name={contact.nickname || contact.name}
role={contact.role}
level={(member as any)?.level ?? 1}
avatarUrl={contact.customAvatarUrl}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve legacy contact avatars on the new cards

For persisted contacts that use the valid Contact.avatar field rendered by the previous ContactCard, the replacement passes only customAvatarUrl, so their saved portrait is discarded and the card falls back to a letter. Pass contact.customAvatarUrl ?? contact.avatar so upgrading to this card does not regress existing contact images.

Useful? React with 👍 / 👎.

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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enter placement mode before routing to the map

For every active contact, SEND TO BLOCK only posts a “Pick a corner” notification and navigates to the map. It never calls useBlockStore.setPlacementMode with this member, while TopDownBlock only places a member when isPlacementMode and pendingPlacementMemberId are set, so tapping a corner after this action does nothing and the advertised deployment flow is nonfunctional.

Useful? React with 👍 / 👎.

}}
onCallBack={() => setSelectedContact(contact)}
onBackdoor={() => handleBackdoor(contact.memberId ?? contact.id)}
/>
);
Expand Down Expand Up @@ -334,13 +397,28 @@ const Contacts: React.FC = () => {
)}
</div>

{/* Member Creation — full-screen mockup flow (Sprint 16, P1) */}
<AnimatePresence>
{showMemberCreation && (
<MemberCreation
onCreate={handleMemberCreation}
onClose={() => setShowMemberCreation(false)}
/>
)}
</AnimatePresence>

{/* Add Member Modal */}
<AnimatePresence>
{showAddMember && (
<motion.div className="modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setShowAddMember(false)}>
<motion.div className="modal-content" initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.9, opacity: 0 }} onClick={(e) => e.stopPropagation()}>
<h2>Add New Member</h2>
<div className="add-options">
<motion.button className="add-option" onClick={() => { setShowAddMember(false); setShowMemberCreation(true); }} whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<span className="option-icon">🧬</span>
<span className="option-title">Member Creation</span>
<span className="option-desc">Pick a role, roll starter attributes, name your soldier</span>
</motion.button>
<motion.button className="add-option" onClick={() => { setShowAddMember(false); setShowPhotoCreator(true); }} whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<span className="option-icon">📸</span>
<span className="option-title">Create from Photo</span>
Expand Down
Loading
Loading