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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,6 @@
"tsx": "^4.7.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^4.1.10"
"vitest": "^2.1.8"
}
}
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const Missions = React.lazy(() => import('./components/missions/Mission
const Leaderboard = React.lazy(() => import('./components/hub/Leaderboard'));
const TopDownShooter = React.lazy(() => import('./components/topdown/TopDownShooter'));
const BipNDipGame = React.lazy(() => import('./components/topdown/BipNDipGame'));
const PoliceRaidGame = React.lazy(() => import('./components/raid/PoliceRaidGame'));
const GangManagement = React.lazy(() => import('./components/gang/GangManagement'));
const DealtModeSelector = React.lazy(() => import('./components/dealt-v2/DealtModeSelector'));
const CocaineCrush = React.lazy(() => import('./components/cocaine-crush/CocaineCrush'));
Expand Down Expand Up @@ -165,6 +166,7 @@ const App: React.FC = () => {
case 'driveby': return <Suspense fallback={<LazyFallback />}><DriveByGame key="driveby" /></Suspense>;
case 'topdown': return <Suspense fallback={<LazyFallback />}><TopDownShooter key="topdown" /></Suspense>;
case 'bipndip': return <Suspense fallback={<LazyFallback />}><BipNDipGame key="bipndip" /></Suspense>;
case 'raid': return <Suspense fallback={<LazyFallback />}><PoliceRaidGame key="raid" /></Suspense>;
case 'gang_hq': return <Suspense fallback={<LazyFallback />}><GangManagement key="gang_hq" /></Suspense>;
case 'alchemy': return <Suspense fallback={<LazyFallback />}><AlchemyLab key="alchemy" /></Suspense>;
case 'shoebox': return <Suspense fallback={<LazyFallback />}><Shoebox key="shoebox" /></Suspense>;
Expand Down
129 changes: 129 additions & 0 deletions frontend/src/components/contacts/BailHospitalPanel.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/* ============================================================
SLIDE — Bail & Hospital panel (Sprint 14-B)
============================================================ */

.bhp-root {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 4px;
}

.bhp-summary {
display: flex;
gap: 10px;
}

.bhp-summary > div {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
}

.bhp-summary-label {
font-size: 0.62rem;
letter-spacing: 0.12em;
color: #8b8f98;
text-transform: uppercase;
}

.bhp-summary-value {
font-size: 1.05rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
}

.bhp-summary-value.short { color: #ff6b6b; }

.bhp-warning {
margin: 0;
font-size: 0.72rem;
color: #f4a04c;
text-align: center;
line-height: 1.4;
}

.bhp-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}

.bhp-row {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
}

.bhp-row.bleeding {
box-shadow: inset 3px 0 0 #ff6b6b;
background: rgba(239, 68, 68, 0.1);
}

.bhp-icon { font-size: 1.15rem; }

.bhp-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}

.bhp-name {
font-weight: 700;
font-size: 0.88rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.bhp-status {
font-size: 0.66rem;
letter-spacing: 0.05em;
color: #8b8f98;
}

.bhp-row.bleeding .bhp-status { color: #ff8f8f; }

.bhp-pay {
border: none;
border-radius: 10px;
padding: 9px 14px;
background: #6ee7a8;
color: #06210f;
font-weight: 800;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
cursor: pointer;
white-space: nowrap;
}

.bhp-pay.disabled {
background: rgba(255, 255, 255, 0.1);
color: #6b7280;
cursor: default;
}

.bhp-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 40px 20px;
text-align: center;
}

.bhp-empty-icon { font-size: 2rem; }
.bhp-empty p { margin: 0; font-size: 0.9rem; }
.bhp-empty .bhp-sub { color: #8b8f98; font-size: 0.78rem; }
140 changes: 140 additions & 0 deletions frontend/src/components/contacts/BailHospitalPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// ============================================================
// SLIDE — Bail & Hospital panel (Sprint 14-B, Task 2)
// frontend/src/components/contacts/BailHospitalPanel.tsx
//
// The persistent recovery screen. BailModal handles the moment right
// after an incident; this handles every moment after that, so a member
// dismissed at the modal is not stranded for the rest of the run.
// ============================================================

import React, { useCallback, useMemo } from 'react';
import {
usePlayerStore,
useGangStore,
useNotificationStore,
} from '../../stores/gameStore';
import {
quoteRecovery,
needsRecovery,
RECOVERY_CONFIG,
type RecoveryQuote,
} from '../../utils/bailHospitalSystem';
import './BailHospitalPanel.css';

interface BailHospitalPanelProps {
/** memberId -> ticks down, when the caller is tracking it. */
ticksHeld?: Record<string, number>;
}

export const BailHospitalPanel: React.FC<BailHospitalPanelProps> = ({ ticksHeld = {} }) => {
const player = usePlayerStore((s) => s.player);
const updateMoney = usePlayerStore((s) => s.updateMoney);
const members = useGangStore((s) => s.members);
const releaseMember = useGangStore((s) => s.releaseMember);
const addNotification = useNotificationStore((s) => s.addNotification);

const quotes = useMemo(
() =>
members
.filter(needsRecovery)
.map((m) => quoteRecovery(m, player.money, ticksHeld[m.id] ?? 0))
.filter((q): q is RecoveryQuote => q !== null),
[members, player.money, ticksHeld],
);

const totalOwed = quotes.reduce((sum, q) => sum + q.cost, 0);

const handleRecover = useCallback(
(quote: RecoveryQuote) => {
if (player.money < quote.cost) {
addNotification({
type: 'warning',
title: 'Not Enough Cash',
message: `${quote.kind === 'bail' ? 'Bail' : 'The hospital'} wants $${quote.cost.toLocaleString()}. You have $${player.money.toLocaleString()}.`,
priority: 'normal',
});
return;
}

updateMoney(-quote.cost);
// releaseMember restores 'active' on both the member and the
// contact record, which is what the deploy screens read.
releaseMember(quote.memberId);

addNotification({
type: 'success',
title: quote.kind === 'bail' ? 'Bailed Out' : 'Discharged',
message:
quote.kind === 'bail'
? `${quote.memberName} walked. Paid $${quote.cost.toLocaleString()}.`
: `${quote.memberName} is patched up and back on rotation. Paid $${quote.cost.toLocaleString()}.`,
priority: 'normal',
});
},
[player.money, updateMoney, releaseMember, addNotification],
);

if (quotes.length === 0) {
return (
<div className="bhp-empty">
<span className="bhp-empty-icon">✅</span>
<p>Everybody's on the street.</p>
<p className="bhp-sub">No bail or hospital bills outstanding.</p>
</div>
);
}

return (
<div className="bhp-root">
<div className="bhp-summary">
<div>
<span className="bhp-summary-label">Owed</span>
<span className="bhp-summary-value">${totalOwed.toLocaleString()}</span>
</div>
<div>
<span className="bhp-summary-label">On hand</span>
<span className={`bhp-summary-value ${player.money < totalOwed ? 'short' : ''}`}>
${player.money.toLocaleString()}
</span>
</div>
</div>

<p className="bhp-warning">
Anyone down more than {RECOVERY_CONFIG.ABANDON_GRACE_TICKS} ticks costs the crew{' '}
{RECOVERY_CONFIG.ABANDON_MORALE_PENALTY_PCT}% morale every check.
</p>

<ul className="bhp-list">
{quotes.map((quote) => (
<li
key={quote.memberId}
className={`bhp-row ${quote.costingMorale ? 'bleeding' : ''}`}
>
<span className={`bhp-icon ${quote.kind}`}>
{quote.kind === 'bail' ? '⛓️' : '🏥'}
</span>

<div className="bhp-info">
<span className="bhp-name">{quote.memberName}</span>
<span className="bhp-status">
{quote.kind === 'bail' ? 'Locked up' : 'Laid up'}
{quote.ticksHeld > 0 && ` · ${quote.ticksHeld} ticks`}
{quote.costingMorale && ' · BLEEDING MORALE'}
</span>
</div>

<button
className={`bhp-pay ${quote.affordable ? '' : 'disabled'}`}
onClick={() => handleRecover(quote)}
disabled={!quote.affordable}
>
${quote.cost.toLocaleString()}
</button>
</li>
))}
</ul>
</div>
);
};

export default BailHospitalPanel;
12 changes: 11 additions & 1 deletion frontend/src/components/contacts/Contacts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import {
import { getMoraleDescription, getMoraleConsequences, rollMoraleConsequences, calculateBailImpact, calculateHospitalImpact } from '../../utils/moraleSystem';
import { getMemberHeatContribution } from '../../utils/memberProgression';
import type { GangMember, Contact, MemberStatus, GetBackRequest } from '../../types/game.types';
import BailHospitalPanel from './BailHospitalPanel';
import { needsRecovery } from '../../utils/bailHospitalSystem';
import { soundManager } from '../../utils/SoundManager';
import './Contacts.css';

type TabType = 'active' | 'jailed' | 'dead' | 'requests';
type TabType = 'active' | 'jailed' | 'recovery' | 'dead' | 'requests';

// ============ MAIN COMPONENT ============

Expand Down Expand Up @@ -51,6 +53,9 @@ const Contacts: React.FC = () => {
const activeMembers = contacts.filter(c => c.status === 'active');
const jailedMembers = contacts.filter(c => c.status === 'jailed');
const deadMembers = contacts.filter(c => c.status === 'dead' || c.status === 'backdoored');
// Counted off gang members, not contacts: injured statuses live on the
// member record and never make it onto a contact card.
const recoverableCount = members.filter(needsRecovery).length;
const pendingRequests = getBackRequests.filter(r => r.status === 'pending');

// Drug inventory for equipping
Expand Down Expand Up @@ -256,6 +261,9 @@ const Contacts: React.FC = () => {
<button className={`tab ${activeTab === 'jailed' ? 'active' : ''}`} onClick={() => setActiveTab('jailed')}>
⛓️ Jailed ({jailedMembers.length})
</button>
<button className={`tab ${activeTab === 'recovery' ? 'active' : ''}`} onClick={() => setActiveTab('recovery')}>
🏥 Recover ({recoverableCount})
</button>
<button className={`tab ${activeTab === 'dead' ? 'active' : ''}`} onClick={() => setActiveTab('dead')}>
💀 Dead ({deadMembers.length})
</button>
Expand Down Expand Up @@ -292,6 +300,8 @@ const Contacts: React.FC = () => {
/>
))}

{activeTab === 'recovery' && <BailHospitalPanel />}

{activeTab === 'dead' && deadMembers.map(contact => (
<ContactCard key={contact.id} contact={contact} onClick={() => setSelectedContact(contact)} showDeathInfo />
))}
Expand Down
Loading
Loading