- {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..9aeaf8f
--- /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 } = 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 >= 24) {
+ 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) => (
+ setRoleFilter(f)}
+ >
+ {FILTER_LABELS[f]}
+
+ ))}
+
+
+
+ {visible.length} available
+
+ REFRESH BOARD · {formatMoney(REFRESH_COST)}
+
+
+
+ {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]) => (
+
+ ))}
+
+
+
+
+
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}
+
+ ) : (
+
+ SIGN FOR {formatMoney(selected.price)}
+
+ )}
+
+ { setSelected(null); setResult(''); }}
+ >
+ Close
+
+
+
+ )}
+
+
+ );
+};
+
+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) => (
+ setTab(t)}
+ >
+ {TAB_LABELS[t]}
+ {t === 'board' && openBoard.length > 0 && (
+ {openBoard.length}
+ )}
+ {t === 'mine' && myPosters.filter((p) => p.status === 'open').length > 0 && (
+
+ {myPosters.filter((p) => p.status === 'open').length}
+
+ )}
+
+ ))}
+
+
+ {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 ? (
+
+ ) : (
+
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' && (
+ handleCancel(p.id)}>
+ PULL
+
+ )}
+ {p.status === 'paid' && p.proofImageUrl && (
+ setSelected(p)}>
+ PROOF
+
+ )}
+
+
+ ))
+ )}
+
+ )}
+
+ {/* ── 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.
+
+
+
+
TARGET TYPE
+
+ { setFormTargetKind('member'); setFormTargetId(''); }}
+ >
+ A MEMBER
+
+ { setFormTargetKind('special_person'); setFormTargetId(''); }}
+ >
+ THEIR PEOPLE
+
+
+
+
+
+
WHO
+ {formTargetKind === 'member' ? (
+ oppContacts.length === 0 ? (
+
+ No opps in your book yet. Claim a slide or get slid on first.
+
+ ) : (
+
setFormTargetId(e.target.value)}
+ >
+ Select an opp
+ {oppContacts.map((c) => (
+
+ {c.nickname ? `${c.name} "${c.nickname}"` : c.name}
+ {c.level ? ` — lvl ${c.level}` : ''}
+
+ ))}
+
+ )
+ ) : knownSpecialPeople.length === 0 ? (
+
+ You do not know anybody’s people yet. Open an opp’s contact
+ card to learn who they care about.
+
+ ) : (
+
setFormTargetId(e.target.value)}
+ >
+ Select a person
+ {knownSpecialPeople.map(({ person, ownerName }) => (
+
+ {person.name} — {RELATION_LABELS[person.relation]} of {ownerName}
+
+ ))}
+
+ )}
+
+
+
+
PHOTO ON THE POSTER
+
{
+ const f = e.target.files?.[0];
+ if (!f) return;
+ try {
+ setFormImage(await readFileAsDataUrl(f));
+ } catch {
+ flash('COULD NOT READ THAT IMAGE');
+ }
+ }}
+ />
+
posterImageRef.current?.click()}>
+ {formImage ? 'CHANGE PHOTO' : 'UPLOAD PHOTO'}
+
+ {formImage && (
+
+
+
+ )}
+
+
+
+
+ REWARD
+ {formTargetId && (
+ · min {formatMoney(minReward)}
+ )}
+
+
setFormReward(Number(e.target.value) || 0)}
+ />
+
+ Listing fee {formatMoney(listingFee(formReward))} · total out of pocket{' '}
+ {formatMoney(totalPostingCost(formReward))}
+
+
+
+
+ NOTE (OPTIONAL)
+
+
+
+ POST CONTRACT · {formatMoney(totalPostingCost(formReward))}
+
+
+ )}
+
+ {/* ── ALLIES ── */}
+ {tab === 'allies' && (
+
+
+ Allies come out of business, not friendship. Anyone who cashes your
+ contract lands here. Standing rises with every job you do together.
+
+ {allies.length === 0 ? (
+
+ No allies yet. Somebody has to cash one of your contracts first.
+
+ ) : (
+ allies.map((a) => (
+
+
+
{a.gangName}
+
+ {a.jobsCompleted} job{a.jobsCompleted === 1 ? '' : 's'} ·{' '}
+ {formatMoney(a.moneyExchanged)} exchanged
+
+
+
+
+ ))
+ )}
+
+ )}
+
+ {/* ── Detail modal ── */}
+
+ {selected && (
+ setSelected(null)}
+ >
+ e.stopPropagation()}
+ >
+
+ {selected.status === 'paid' ? 'CLOSED' : 'WANTED'}
+
+
+
+ {selected.posterImageUrl ? (
+
+ ) : (
+
NO PHOTO ON FILE
+ )}
+
+
+ {selected.targetName}
+ {selected.targetGangName}
+
+ {selected.targetKind === 'special_person' && selected.targetOwnerName && (
+
+ {RELATION_LABELS[
+ (knownSpecialPeople.find((x) => x.person.id === selected.targetId)
+ ?.person.relation ?? 'cousin')
+ ]}{' '}
+ of {selected.targetOwnerName}
+
+ )}
+
+ {formatMoney(selected.reward)}
+
+ {selected.note && {selected.note}
}
+
+ {selected.lastKnownAddress && (
+
+ Last seen around {selected.lastKnownAddress}
+
+ )}
+
+
+ Posted by {selected.postedByGangName}
+ {formatRemaining(remainingMs(selected))}
+
+
+ {selected.status === 'paid' ? (
+
+
+ Cashed by {selected.claimedByGangName}
+
+ {selected.proofImageUrl && (
+
+
PROOF SUBMITTED
+
+
+ )}
+
+ ) : selected.postedBy === player.id ? (
+ <>
+
+ This is your contract. Pull it and the reward comes back — the
+ listing fee does not.
+
+ handleCancel(selected.id)}>
+ PULL CONTRACT
+
+ >
+ ) : (
+ <>
+
+ Handle it, screenshot the confirmed win, then upload it here.
+ Payment is automatic — and public.
+
+ {
+ const f = e.target.files?.[0];
+ if (f) void handleProofUpload(f);
+ }}
+ />
+ proofInputRef.current?.click()}
+ >
+ UPLOAD PROOF & CASH {formatMoney(selected.reward)}
+
+ >
+ )}
+
+ setSelected(null)}>
+ Close
+
+
+
+ )}
+
+
+ );
+};
+
+export default MostWanted;
diff --git a/frontend/src/components/economy/MostWantedApp.css b/frontend/src/components/economy/MostWantedApp.css
new file mode 100644
index 0000000..aa25daa
--- /dev/null
+++ b/frontend/src/components/economy/MostWantedApp.css
@@ -0,0 +1,56 @@
+/* ============================================================
+ MostWantedApp — route shell for the bounty board
+ Sprint 15-B
+ ============================================================ */
+
+.mwa-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: #0a0a0b;
+ color: #e4e4e7;
+}
+
+.mwa-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 14px 14px 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.07);
+ background: linear-gradient(180deg, rgba(239, 68, 68, 0.08), transparent);
+}
+
+.mwa-header h1 {
+ flex: 1;
+ margin: 0;
+ font-size: 15px;
+ font-weight: 900;
+ letter-spacing: 2.6px;
+ text-align: center;
+ color: #fca5a5;
+}
+
+.mwa-back {
+ padding: 7px 12px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 7px;
+ background: rgba(255, 255, 255, 0.04);
+ color: #a1a1aa;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.mwa-cash {
+ min-width: 58px;
+ font-size: 12px;
+ font-weight: 800;
+ color: #4ade80;
+ text-align: right;
+}
+
+.mwa-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px 10px 90px;
+}
diff --git a/frontend/src/components/economy/MostWantedApp.tsx b/frontend/src/components/economy/MostWantedApp.tsx
new file mode 100644
index 0000000..5cf7be4
--- /dev/null
+++ b/frontend/src/components/economy/MostWantedApp.tsx
@@ -0,0 +1,43 @@
+// ============================================================
+// MostWantedApp — route wrapper for the bounty board
+// Sprint 15-B
+//
+// The board is its own app icon rather than a Market tab because it
+// is not shopping — it is a public ledger of who wants who dead, and
+// players will check it far more often than they buy a vest.
+// ============================================================
+
+import React from 'react';
+import { motion } from 'framer-motion';
+import { useNavigationStore, usePlayerStore } from '../../stores/gameStore';
+import MostWanted from './MostWanted';
+import './MostWantedApp.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 MostWantedApp: React.FC = () => {
+ const { goBack } = useNavigationStore();
+ const { player } = usePlayerStore();
+
+ return (
+
+
+
+ Back
+
+
MOST WANTED
+ {formatMoney(player.money)}
+
+
+
+
+
+
+ );
+};
+
+export default MostWantedApp;
diff --git a/frontend/src/components/layout/OSShell.tsx b/frontend/src/components/layout/OSShell.tsx
index b5fa6ea..a5abdfa 100644
--- a/frontend/src/components/layout/OSShell.tsx
+++ b/frontend/src/components/layout/OSShell.tsx
@@ -12,6 +12,7 @@ import {
useGangStore,
useNotificationStore,
} from '../../stores/gameStore';
+import { useMostWantedStore } from '../../stores/mostWantedStore';
import { getRaidProbability } from '../../utils/heatSystem';
import { getMoraleDescription } from '../../utils/moraleSystem';
import { GameSprite } from '../common/GameSprite';
@@ -57,6 +58,14 @@ const OSShell: React.FC = ({ gangMorale = 75, incomePerMinute = 0
const raidProb = useMemo(() => getRaidProbability(player.heat), [player.heat]);
const moraleDesc = useMemo(() => getMoraleDescription(gangMorale), [gangMorale]);
+ // Badge the bounty board with how many contracts are live, including any
+ // posted against this player's own crew.
+ const bountyPosters = useMostWantedStore((s) => s.posters);
+ const openBountyCount = useMemo(
+ () => bountyPosters.filter((p) => p.status === 'open').length,
+ [bountyPosters],
+ );
+
const appIcons: AppIcon[] = [
{
id: 'map',
@@ -140,6 +149,16 @@ const OSShell: React.FC = ({ gangMorale = 75, incomePerMinute = 0
available: true,
description: 'Underworld Market',
},
+ {
+ id: 'most_wanted',
+ label: 'WANTED',
+ icon: 'WANTED',
+ spriteIcon: 'market',
+ colorClass: 'app-red',
+ available: true,
+ badge: openBountyCount > 0 ? openBountyCount : undefined,
+ description: 'Bounty Board',
+ },
{
id: 'contacts',
label: 'CONTACTS',
@@ -214,12 +233,12 @@ const OSShell: React.FC = ({ gangMorale = 75, incomePerMinute = 0
},
{
id: 'leaderboard',
- label: 'SHOT CALLER',
- icon: 'SHOT CALLER',
- spriteIcon: 'leaderboard',
+ label: 'CLOUT',
+ icon: 'CLOUT',
+ spriteIcon: 'leaderboard', // uses shot-caller artwork from #96
colorClass: 'app-gold',
available: true,
- description: 'Block Attack Alerts',
+ description: 'Who Runs What',
},
{
id: 'news',
diff --git a/frontend/src/stores/__tests__/getBackStore.test.ts b/frontend/src/stores/__tests__/getBackStore.test.ts
new file mode 100644
index 0000000..27a17f6
--- /dev/null
+++ b/frontend/src/stores/__tests__/getBackStore.test.ts
@@ -0,0 +1,365 @@
+// ============================================================
+// getBackStore tests — Sprint 15-B
+//
+// The clock is wall-clock derived, so most of these tests inject an
+// explicit `now` rather than faking timers. The cases that matter are
+// the ones where a player backgrounds the app: a window must expire
+// correctly on the next read, and morale must only ever be applied
+// once per window.
+// ============================================================
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ useGetBackStore,
+ GET_BACK_CONFIG,
+ remainingMs,
+ remainingFraction,
+ isLapsed,
+ urgency,
+ formatClock,
+ moraleSwing,
+ isValidRevengeTarget,
+ type OpenWindowInput,
+} from '../getBackStore';
+import type { GetBackWindow } from '../../types/game.types';
+
+const baseInput: OpenWindowInput = {
+ trigger: 'slide_casualty',
+ lostMemberId: 'm_lost',
+ lostMemberName: 'Cedric',
+ offendingGangName: 'Northbound',
+ offendingPlayerId: 'p_opp',
+ wantedMemberIds: ['opp_1', 'opp_2'],
+ wantedMemberNames: ['Smoke', 'Ghost'],
+ offenderMoraleGained: 10,
+};
+
+/** Build a window object directly for testing pure helpers. */
+function makeWindow(overrides: Partial = {}): GetBackWindow {
+ return {
+ id: 'w1',
+ trigger: 'slide_casualty',
+ openedAt: 1_000_000,
+ durationMs: 60_000,
+ lostMemberId: 'm_lost',
+ lostMemberName: 'Cedric',
+ offendingGangName: 'Northbound',
+ wantedMemberIds: ['opp_1'],
+ wantedMemberNames: ['Smoke'],
+ offenderMoraleGained: 10,
+ outcome: 'pending',
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ useGetBackStore.getState().clearAll();
+});
+
+describe('remainingMs', () => {
+ it('returns the full duration at the moment of opening', () => {
+ const w = makeWindow();
+ expect(remainingMs(w, w.openedAt)).toBe(60_000);
+ });
+
+ it('counts down with wall time', () => {
+ const w = makeWindow();
+ expect(remainingMs(w, w.openedAt + 20_000)).toBe(40_000);
+ });
+
+ it('clamps to zero rather than going negative', () => {
+ const w = makeWindow();
+ expect(remainingMs(w, w.openedAt + 999_999)).toBe(0);
+ });
+
+ it('reports zero for any resolved window regardless of clock', () => {
+ const w = makeWindow({ outcome: 'success' });
+ expect(remainingMs(w, w.openedAt)).toBe(0);
+ });
+});
+
+describe('remainingFraction', () => {
+ it('is 1 at open and 0.5 at the halfway point', () => {
+ const w = makeWindow();
+ expect(remainingFraction(w, w.openedAt)).toBe(1);
+ expect(remainingFraction(w, w.openedAt + 30_000)).toBe(0.5);
+ });
+
+ it('does not divide by zero on a zero-length window', () => {
+ const w = makeWindow({ durationMs: 0 });
+ expect(remainingFraction(w, w.openedAt)).toBe(0);
+ });
+});
+
+describe('isLapsed', () => {
+ it('is false while time remains', () => {
+ const w = makeWindow();
+ expect(isLapsed(w, w.openedAt + 59_999)).toBe(false);
+ });
+
+ it('is true the instant the clock hits zero', () => {
+ const w = makeWindow();
+ expect(isLapsed(w, w.openedAt + 60_000)).toBe(true);
+ });
+
+ it('is false for an already-resolved window so morale is not re-applied', () => {
+ const w = makeWindow({ outcome: 'expired' });
+ expect(isLapsed(w, w.openedAt + 999_999)).toBe(false);
+ });
+});
+
+describe('urgency', () => {
+ it('is normal above the urgent threshold', () => {
+ const w = makeWindow();
+ expect(urgency(w, w.openedAt + 10_000)).toBe('normal');
+ });
+
+ it('flips to urgent at the threshold', () => {
+ const w = makeWindow();
+ // 25% of 60s remaining => 45s elapsed.
+ expect(urgency(w, w.openedAt + 45_000)).toBe('urgent');
+ });
+
+ it('reports expired once the clock is out', () => {
+ const w = makeWindow();
+ expect(urgency(w, w.openedAt + 60_000)).toBe('expired');
+ });
+});
+
+describe('formatClock', () => {
+ it('shows minutes and seconds under an hour', () => {
+ expect(formatClock(65_000)).toBe('01:05');
+ });
+
+ it('shows hours and minutes at or above an hour', () => {
+ expect(formatClock(3 * 3_600_000 + 25 * 60_000)).toBe('03:25');
+ });
+
+ it('shows zeros for a dead clock', () => {
+ expect(formatClock(0)).toBe('00:00');
+ expect(formatClock(-500)).toBe('00:00');
+ });
+});
+
+describe('moraleSwing', () => {
+ it('doubles the retaliator gain on success', () => {
+ const w = makeWindow({ offenderMoraleGained: 10 });
+ expect(moraleSwing(w, 'success').retaliator).toBe(
+ 10 * GET_BACK_CONFIG.SUCCESS_MULTIPLIER,
+ );
+ });
+
+ it('makes the offender lose the doubled amount plus what they banked', () => {
+ const w = makeWindow({ offenderMoraleGained: 10 });
+ // Doubled 20 plus the original 10 they gained.
+ expect(moraleSwing(w, 'success').offender).toBe(-30);
+ });
+
+ it('penalises the retaliator and spares the offender on expiry', () => {
+ const w = makeWindow({ offenderMoraleGained: 10 });
+ const swing = moraleSwing(w, 'expired');
+ expect(swing.retaliator).toBe(-GET_BACK_CONFIG.EXPIRY_MORALE_PENALTY);
+ expect(swing.offender).toBe(0);
+ });
+
+ it('treats a negative banked morale as zero rather than rewarding the offender', () => {
+ const w = makeWindow({ offenderMoraleGained: -5 });
+ const swing = moraleSwing(w, 'success');
+ // Compared numerically because -0 and 0 are distinct under deep equality
+ // but identical as game state.
+ expect(swing.retaliator).toBe(0);
+ expect(swing.offender === 0).toBe(true);
+ });
+});
+
+describe('isValidRevengeTarget', () => {
+ it('accepts a member who was present', () => {
+ const w = makeWindow({ wantedMemberIds: ['opp_1', 'opp_2'] });
+ expect(isValidRevengeTarget(w, 'opp_2')).toBe(true);
+ });
+
+ it('rejects a member who was not', () => {
+ const w = makeWindow({ wantedMemberIds: ['opp_1'] });
+ expect(isValidRevengeTarget(w, 'someone_else')).toBe(false);
+ });
+
+ it('rejects everyone once the window is resolved', () => {
+ const w = makeWindow({ wantedMemberIds: ['opp_1'], outcome: 'success' });
+ expect(isValidRevengeTarget(w, 'opp_1')).toBe(false);
+ });
+});
+
+describe('openWindow', () => {
+ it('stores a pending window with the trigger default duration', () => {
+ const w = useGetBackStore.getState().openWindow(baseInput);
+ expect(w.outcome).toBe('pending');
+ expect(w.durationMs).toBe(GET_BACK_CONFIG.DURATION_MS.slide_casualty);
+ expect(useGetBackStore.getState().windows).toHaveLength(1);
+ });
+
+ it('honours an explicit duration override', () => {
+ const w = useGetBackStore
+ .getState()
+ .openWindow({ ...baseInput, durationMs: 5_000 });
+ expect(w.durationMs).toBe(5_000);
+ });
+
+ it('gives family contracts a longer leash than slides', () => {
+ expect(GET_BACK_CONFIG.DURATION_MS.special_person).toBeGreaterThan(
+ GET_BACK_CONFIG.DURATION_MS.slide_casualty,
+ );
+ });
+
+ it('copies the wanted arrays so later mutation of the input is not reflected', () => {
+ const ids = ['opp_1'];
+ const w = useGetBackStore
+ .getState()
+ .openWindow({ ...baseInput, wantedMemberIds: ids });
+ ids.push('opp_9');
+ expect(w.wantedMemberIds).toEqual(['opp_1']);
+ });
+});
+
+describe('resolveWindow', () => {
+ it('resolves a pending window and returns the morale swing', () => {
+ const w = useGetBackStore.getState().openWindow(baseInput);
+ const res = useGetBackStore.getState().resolveWindow(w.id, 'success', 'opp_1');
+ expect(res).not.toBeNull();
+ expect(res!.window.outcome).toBe('success');
+ expect(res!.window.resolvedAgainstMemberId).toBe('opp_1');
+ expect(res!.moraleSwing.retaliator).toBe(20);
+ });
+
+ it('returns null on an unknown id', () => {
+ expect(useGetBackStore.getState().resolveWindow('nope', 'success')).toBeNull();
+ });
+
+ it('refuses to resolve twice so morale cannot be double-applied', () => {
+ const w = useGetBackStore.getState().openWindow(baseInput);
+ expect(useGetBackStore.getState().resolveWindow(w.id, 'success')).not.toBeNull();
+ expect(useGetBackStore.getState().resolveWindow(w.id, 'success')).toBeNull();
+ });
+});
+
+describe('registerCatch', () => {
+ it('resolves the window listing the caught member', () => {
+ useGetBackStore.getState().openWindow(baseInput);
+ const res = useGetBackStore.getState().registerCatch('opp_2');
+ expect(res).not.toBeNull();
+ expect(res!.window.outcome).toBe('success');
+ expect(res!.window.resolvedAgainstMemberId).toBe('opp_2');
+ });
+
+ it('returns null when the member is not wanted anywhere', () => {
+ useGetBackStore.getState().openWindow(baseInput);
+ expect(useGetBackStore.getState().registerCatch('stranger')).toBeNull();
+ });
+
+ it('clears the oldest debt first when two windows list the same member', () => {
+ const store = useGetBackStore.getState();
+ const older = store.openWindow({ ...baseInput, wantedMemberIds: ['shared'] });
+ // Force a later openedAt so ordering is unambiguous.
+ useGetBackStore.setState((s) => ({
+ windows: s.windows.map((w) =>
+ w.id === older.id ? { ...w, openedAt: w.openedAt - 10_000 } : w,
+ ),
+ }));
+ const newer = useGetBackStore
+ .getState()
+ .openWindow({ ...baseInput, wantedMemberIds: ['shared'] });
+
+ const res = useGetBackStore.getState().registerCatch('shared');
+ expect(res!.window.id).toBe(older.id);
+ expect(
+ useGetBackStore.getState().windows.find((w) => w.id === newer.id)!.outcome,
+ ).toBe('pending');
+ });
+
+ it('ignores windows whose clock already ran out', () => {
+ useGetBackStore.getState().openWindow({ ...baseInput, durationMs: 1 });
+ // Push openedAt into the past so the window is lapsed.
+ useGetBackStore.setState((s) => ({
+ windows: s.windows.map((w) => ({ ...w, openedAt: w.openedAt - 10_000 })),
+ }));
+ expect(useGetBackStore.getState().registerCatch('opp_1')).toBeNull();
+ });
+});
+
+describe('sweepExpired', () => {
+ it('does nothing when every window still has time', () => {
+ useGetBackStore.getState().openWindow(baseInput);
+ expect(useGetBackStore.getState().sweepExpired()).toEqual([]);
+ });
+
+ it('flips lapsed windows to expired and reports the penalty', () => {
+ useGetBackStore.getState().openWindow({ ...baseInput, durationMs: 1_000 });
+ const later = Date.now() + 5_000;
+ const results = useGetBackStore.getState().sweepExpired(later);
+ expect(results).toHaveLength(1);
+ expect(results[0].window.outcome).toBe('expired');
+ expect(results[0].moraleSwing.retaliator).toBe(
+ -GET_BACK_CONFIG.EXPIRY_MORALE_PENALTY,
+ );
+ expect(useGetBackStore.getState().windows[0].outcome).toBe('expired');
+ });
+
+ it('is idempotent so a repeated sweep does not re-penalise', () => {
+ useGetBackStore.getState().openWindow({ ...baseInput, durationMs: 1_000 });
+ const later = Date.now() + 5_000;
+ expect(useGetBackStore.getState().sweepExpired(later)).toHaveLength(1);
+ expect(useGetBackStore.getState().sweepExpired(later)).toHaveLength(0);
+ });
+});
+
+describe('selectors', () => {
+ it('activeWindows excludes resolved and lapsed windows', () => {
+ const store = useGetBackStore.getState();
+ const keep = store.openWindow(baseInput);
+ const resolved = store.openWindow(baseInput);
+ useGetBackStore.getState().resolveWindow(resolved.id, 'success');
+ const active = useGetBackStore.getState().activeWindows();
+ expect(active.map((w) => w.id)).toEqual([keep.id]);
+ });
+
+ it('mostUrgentWindow picks the one closest to expiry', () => {
+ const store = useGetBackStore.getState();
+ store.openWindow({ ...baseInput, durationMs: 100_000 });
+ const soon = store.openWindow({ ...baseInput, durationMs: 10_000 });
+ expect(useGetBackStore.getState().mostUrgentWindow()!.id).toBe(soon.id);
+ });
+
+ it('mostUrgentWindow is null with nothing open', () => {
+ expect(useGetBackStore.getState().mostUrgentWindow()).toBeNull();
+ });
+
+ it('isMemberWanted reflects open windows only', () => {
+ const w = useGetBackStore.getState().openWindow(baseInput);
+ expect(useGetBackStore.getState().isMemberWanted('opp_1')).toBe(true);
+ useGetBackStore.getState().resolveWindow(w.id, 'success');
+ expect(useGetBackStore.getState().isMemberWanted('opp_1')).toBe(false);
+ });
+
+ it('allWantedMemberIds dedupes across windows', () => {
+ const store = useGetBackStore.getState();
+ store.openWindow({ ...baseInput, wantedMemberIds: ['a', 'b'] });
+ store.openWindow({ ...baseInput, wantedMemberIds: ['b', 'c'] });
+ expect(useGetBackStore.getState().allWantedMemberIds().sort()).toEqual(['a', 'b', 'c']);
+ });
+});
+
+describe('pruneHistory', () => {
+ it('keeps pending windows no matter how old', () => {
+ useGetBackStore.getState().openWindow(baseInput);
+ useGetBackStore.getState().pruneHistory(Date.now() + 10 * 365 * 24 * 3_600_000);
+ expect(useGetBackStore.getState().windows).toHaveLength(1);
+ });
+
+ it('drops resolved windows past the retention horizon', () => {
+ const w = useGetBackStore.getState().openWindow(baseInput);
+ useGetBackStore.getState().resolveWindow(w.id, 'success');
+ useGetBackStore
+ .getState()
+ .pruneHistory(Date.now() + GET_BACK_CONFIG.HISTORY_RETENTION_MS + 1_000);
+ expect(useGetBackStore.getState().windows).toHaveLength(0);
+ });
+});
diff --git a/frontend/src/stores/__tests__/mostWantedStore.test.ts b/frontend/src/stores/__tests__/mostWantedStore.test.ts
new file mode 100644
index 0000000..d13c72c
--- /dev/null
+++ b/frontend/src/stores/__tests__/mostWantedStore.test.ts
@@ -0,0 +1,451 @@
+// ============================================================
+// mostWantedStore tests — Sprint 15-B
+//
+// The rules worth guarding are the ones that stop the board from being
+// exploited: you cannot cash your own contract, a paid contract cannot
+// be paid twice, pulling a contract does not refund the listing fee,
+// and the ally ledger accumulates rather than duplicating.
+// ============================================================
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ useMostWantedStore,
+ BOUNTY_CONFIG,
+ listingFee,
+ totalPostingCost,
+ minimumReward,
+ isExpired,
+ remainingMs,
+ formatRemaining,
+ canFulfil,
+ bumpStanding,
+ type PostBountyInput,
+} from '../mostWantedStore';
+import type { MostWantedPoster } from '../../types/game.types';
+
+const basePost: PostBountyInput = {
+ postedBy: 'p_me',
+ postedByGangName: 'Eastside',
+ targetKind: 'member',
+ targetId: 'opp_1',
+ targetName: 'Smoke',
+ targetGangName: 'Northbound',
+ reward: 5_000,
+};
+
+function makePoster(overrides: Partial = {}): MostWantedPoster {
+ const now = Date.now();
+ return {
+ id: 'mw1',
+ postedBy: 'p_me',
+ postedByGangName: 'Eastside',
+ postedAt: new Date(now).toISOString(),
+ expiresAt: new Date(now + 86_400_000).toISOString(),
+ targetKind: 'member',
+ targetId: 'opp_1',
+ targetName: 'Smoke',
+ targetGangName: 'Northbound',
+ reward: 5_000,
+ status: 'open',
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ useMostWantedStore.getState().clearAll();
+});
+
+describe('listingFee and totalPostingCost', () => {
+ it('charges the configured rate, rounded up', () => {
+ expect(listingFee(10_000)).toBe(500);
+ // 501 * 0.05 = 25.05 -> rounds up so the board never loses a cent.
+ expect(listingFee(501)).toBe(26);
+ });
+
+ it('total is reward plus fee', () => {
+ expect(totalPostingCost(10_000)).toBe(10_500);
+ });
+});
+
+describe('minimumReward', () => {
+ it('scales with target level', () => {
+ expect(minimumReward(1, 'member')).toBe(BOUNTY_CONFIG.MIN_REWARD);
+ expect(minimumReward(4, 'member')).toBe(BOUNTY_CONFIG.MIN_REWARD * 4);
+ });
+
+ it('charges a premium for contracts on a member\u2019s people', () => {
+ expect(minimumReward(2, 'special_person')).toBeGreaterThan(
+ minimumReward(2, 'member'),
+ );
+ });
+
+ it('treats level zero or negative as level one', () => {
+ expect(minimumReward(0, 'member')).toBe(BOUNTY_CONFIG.MIN_REWARD);
+ expect(minimumReward(-3, 'member')).toBe(BOUNTY_CONFIG.MIN_REWARD);
+ });
+
+ it('never exceeds the board maximum', () => {
+ expect(minimumReward(9_999, 'special_person')).toBe(BOUNTY_CONFIG.MAX_REWARD);
+ });
+});
+
+describe('isExpired and remainingMs', () => {
+ it('is not expired while time remains', () => {
+ const p = makePoster();
+ expect(isExpired(p)).toBe(false);
+ expect(remainingMs(p)).toBeGreaterThan(0);
+ });
+
+ it('is expired once the deadline passes', () => {
+ const p = makePoster({ expiresAt: new Date(Date.now() - 1_000).toISOString() });
+ expect(isExpired(p)).toBe(true);
+ expect(remainingMs(p)).toBe(0);
+ });
+
+ it('reports non-open posters as not expired so they are not swept twice', () => {
+ const p = makePoster({
+ status: 'paid',
+ expiresAt: new Date(Date.now() - 1_000).toISOString(),
+ });
+ expect(isExpired(p)).toBe(false);
+ });
+});
+
+describe('formatRemaining', () => {
+ it('shows days and hours for long windows', () => {
+ expect(formatRemaining(2 * 86_400_000 + 3 * 3_600_000)).toBe('2d 3h left');
+ });
+
+ it('shows hours under a day', () => {
+ expect(formatRemaining(5 * 3_600_000)).toBe('5h left');
+ });
+
+ it('shows minutes under an hour', () => {
+ expect(formatRemaining(20 * 60_000)).toBe('20m left');
+ });
+
+ it('never shows zero minutes on a live poster', () => {
+ expect(formatRemaining(5_000)).toBe('1m left');
+ });
+
+ it('says expired at or below zero', () => {
+ expect(formatRemaining(0)).toBe('EXPIRED');
+ });
+});
+
+describe('canFulfil', () => {
+ it('allows a third party on an open contract', () => {
+ expect(canFulfil(makePoster(), 'p_other').allowed).toBe(true);
+ });
+
+ it('blocks the poster from cashing their own contract', () => {
+ const res = canFulfil(makePoster({ postedBy: 'p_me' }), 'p_me');
+ expect(res.allowed).toBe(false);
+ expect(res.reason).toMatch(/your own/i);
+ });
+
+ it('blocks an already-paid contract', () => {
+ expect(canFulfil(makePoster({ status: 'paid' }), 'p_other').allowed).toBe(false);
+ });
+
+ it('blocks an expired contract', () => {
+ const p = makePoster({ expiresAt: new Date(Date.now() - 1).toISOString() });
+ expect(canFulfil(p, 'p_other').allowed).toBe(false);
+ });
+});
+
+describe('bumpStanding', () => {
+ it('adds the per-job increment', () => {
+ expect(bumpStanding(50)).toBe(50 + BOUNTY_CONFIG.ALLY_STANDING_PER_JOB);
+ });
+
+ it('caps at the maximum', () => {
+ expect(bumpStanding(99)).toBe(BOUNTY_CONFIG.MAX_ALLY_STANDING);
+ });
+});
+
+describe('postBounty', () => {
+ it('creates an open poster at the top of the board', () => {
+ const p = useMostWantedStore.getState().postBounty(basePost);
+ expect(p.status).toBe('open');
+ expect(useMostWantedStore.getState().posters[0].id).toBe(p.id);
+ });
+
+ it('clamps a reward below the floor up to the minimum', () => {
+ const p = useMostWantedStore.getState().postBounty({ ...basePost, reward: 1 });
+ expect(p.reward).toBe(BOUNTY_CONFIG.MIN_REWARD);
+ });
+
+ it('clamps a reward above the ceiling down to the maximum', () => {
+ const p = useMostWantedStore
+ .getState()
+ .postBounty({ ...basePost, reward: 99_000_000 });
+ expect(p.reward).toBe(BOUNTY_CONFIG.MAX_REWARD);
+ });
+
+ it('sets expiry from the default duration', () => {
+ const p = useMostWantedStore.getState().postBounty(basePost);
+ const life = new Date(p.expiresAt).getTime() - new Date(p.postedAt).getTime();
+ expect(life).toBe(BOUNTY_CONFIG.DEFAULT_DURATION_MS);
+ });
+
+ it('carries special-person context through', () => {
+ const p = useMostWantedStore.getState().postBounty({
+ ...basePost,
+ targetKind: 'special_person',
+ targetId: 'sp_1',
+ targetName: "Smoke's cousin",
+ targetOwnerMemberId: 'opp_1',
+ targetOwnerName: 'Smoke',
+ });
+ expect(p.targetKind).toBe('special_person');
+ expect(p.targetOwnerName).toBe('Smoke');
+ });
+});
+
+describe('cancelBounty', () => {
+ it('marks an open poster cancelled', () => {
+ const p = useMostWantedStore.getState().postBounty(basePost);
+ expect(useMostWantedStore.getState().cancelBounty(p.id)).toBe(true);
+ expect(useMostWantedStore.getState().posters[0].status).toBe('cancelled');
+ });
+
+ it('refuses to cancel a paid poster', () => {
+ const p = useMostWantedStore.getState().postBounty(basePost);
+ useMostWantedStore.getState().fulfilBounty({
+ posterId: p.id,
+ hunterPlayerId: 'p_other',
+ hunterGangName: 'Westend',
+ proofImageUrl: 'data:image/png;base64,x',
+ });
+ expect(useMostWantedStore.getState().cancelBounty(p.id)).toBe(false);
+ });
+
+ it('returns false for an unknown id', () => {
+ expect(useMostWantedStore.getState().cancelBounty('nope')).toBe(false);
+ });
+});
+
+describe('fulfilBounty', () => {
+ it('pays out and returns a receipt naming both sides', () => {
+ const p = useMostWantedStore.getState().postBounty(basePost);
+ const receipt = useMostWantedStore.getState().fulfilBounty({
+ posterId: p.id,
+ hunterPlayerId: 'p_other',
+ hunterGangName: 'Westend',
+ proofImageUrl: 'data:image/png;base64,x',
+ });
+ expect(receipt).not.toBeNull();
+ expect(receipt!.reward).toBe(5_000);
+ expect(receipt!.paidTo).toBe('p_other');
+ expect(receipt!.paidBy).toBe('p_me');
+ expect(receipt!.poster.status).toBe('paid');
+ });
+
+ it('stores the proof screenshot on the poster as public record', () => {
+ const p = useMostWantedStore.getState().postBounty(basePost);
+ useMostWantedStore.getState().fulfilBounty({
+ posterId: p.id,
+ hunterPlayerId: 'p_other',
+ hunterGangName: 'Westend',
+ proofImageUrl: 'data:image/png;base64,PROOF',
+ });
+ expect(useMostWantedStore.getState().posters[0].proofImageUrl).toContain('PROOF');
+ });
+
+ it('refuses a second payout on the same contract', () => {
+ const p = useMostWantedStore.getState().postBounty(basePost);
+ const input = {
+ posterId: p.id,
+ hunterPlayerId: 'p_other',
+ hunterGangName: 'Westend',
+ proofImageUrl: 'data:image/png;base64,x',
+ };
+ expect(useMostWantedStore.getState().fulfilBounty(input)).not.toBeNull();
+ expect(useMostWantedStore.getState().fulfilBounty(input)).toBeNull();
+ });
+
+ it('refuses the poster cashing their own contract', () => {
+ const p = useMostWantedStore.getState().postBounty(basePost);
+ const res = useMostWantedStore.getState().fulfilBounty({
+ posterId: p.id,
+ hunterPlayerId: 'p_me',
+ hunterGangName: 'Eastside',
+ proofImageUrl: 'data:image/png;base64,x',
+ });
+ expect(res).toBeNull();
+ });
+
+ it('returns null for an unknown poster', () => {
+ const res = useMostWantedStore.getState().fulfilBounty({
+ posterId: 'nope',
+ hunterPlayerId: 'p_other',
+ hunterGangName: 'Westend',
+ proofImageUrl: 'x',
+ });
+ expect(res).toBeNull();
+ });
+
+ it('creates a fresh ally on the first job together', () => {
+ const p = useMostWantedStore.getState().postBounty(basePost);
+ const receipt = useMostWantedStore.getState().fulfilBounty({
+ posterId: p.id,
+ hunterPlayerId: 'p_other',
+ hunterGangName: 'Westend',
+ proofImageUrl: 'x',
+ });
+ expect(receipt!.ally.standing).toBe(BOUNTY_CONFIG.ALLY_STARTING_STANDING);
+ expect(receipt!.ally.jobsCompleted).toBe(1);
+ expect(useMostWantedStore.getState().allies).toHaveLength(1);
+ });
+
+ it('accumulates onto an existing ally rather than duplicating them', () => {
+ const store = useMostWantedStore.getState();
+ const first = store.postBounty(basePost);
+ const second = store.postBounty({ ...basePost, targetId: 'opp_2', reward: 2_000 });
+
+ store.fulfilBounty({
+ posterId: first.id,
+ hunterPlayerId: 'p_other',
+ hunterGangName: 'Westend',
+ proofImageUrl: 'x',
+ });
+ const receipt = useMostWantedStore.getState().fulfilBounty({
+ posterId: second.id,
+ hunterPlayerId: 'p_other',
+ hunterGangName: 'Westend',
+ proofImageUrl: 'x',
+ });
+
+ expect(useMostWantedStore.getState().allies).toHaveLength(1);
+ expect(receipt!.ally.jobsCompleted).toBe(2);
+ expect(receipt!.ally.moneyExchanged).toBe(7_000);
+ expect(receipt!.ally.standing).toBe(
+ BOUNTY_CONFIG.ALLY_STARTING_STANDING + BOUNTY_CONFIG.ALLY_STANDING_PER_JOB,
+ );
+ });
+});
+
+describe('sweepExpired', () => {
+ it('does nothing when nothing has lapsed', () => {
+ useMostWantedStore.getState().postBounty(basePost);
+ expect(useMostWantedStore.getState().sweepExpired()).toEqual([]);
+ });
+
+ it('flips lapsed posters to expired', () => {
+ useMostWantedStore.getState().postBounty({ ...basePost, durationMs: 1_000 });
+ const later = Date.now() + 5_000;
+ const swept = useMostWantedStore.getState().sweepExpired(later);
+ expect(swept).toHaveLength(1);
+ expect(useMostWantedStore.getState().posters[0].status).toBe('expired');
+ });
+
+ it('is idempotent across repeated sweeps', () => {
+ useMostWantedStore.getState().postBounty({ ...basePost, durationMs: 1_000 });
+ const later = Date.now() + 5_000;
+ expect(useMostWantedStore.getState().sweepExpired(later)).toHaveLength(1);
+ expect(useMostWantedStore.getState().sweepExpired(later)).toHaveLength(0);
+ });
+});
+
+describe('selectors', () => {
+ it('openPosters excludes paid, cancelled, and lapsed posters', () => {
+ const store = useMostWantedStore.getState();
+ const keep = store.postBounty(basePost);
+ const cancelled = store.postBounty({ ...basePost, targetId: 'opp_2' });
+ useMostWantedStore.getState().cancelBounty(cancelled.id);
+ const open = useMostWantedStore.getState().openPosters();
+ expect(open.map((p) => p.id)).toEqual([keep.id]);
+ });
+
+ it('postersBy filters to one poster author', () => {
+ const store = useMostWantedStore.getState();
+ store.postBounty(basePost);
+ store.postBounty({ ...basePost, postedBy: 'p_someone_else' });
+ expect(useMostWantedStore.getState().postersBy('p_me')).toHaveLength(1);
+ });
+
+ it('postersAgainstGang surfaces open threats to a gang', () => {
+ const store = useMostWantedStore.getState();
+ store.postBounty({ ...basePost, targetGangName: 'Eastside' });
+ store.postBounty({ ...basePost, targetGangName: 'Northbound' });
+ expect(useMostWantedStore.getState().postersAgainstGang('Eastside')).toHaveLength(1);
+ });
+
+ it('bountyOnTarget and isTargetWanted agree', () => {
+ useMostWantedStore.getState().postBounty(basePost);
+ expect(useMostWantedStore.getState().isTargetWanted('opp_1')).toBe(true);
+ expect(useMostWantedStore.getState().bountyOnTarget('opp_1')).not.toBeNull();
+ expect(useMostWantedStore.getState().isTargetWanted('ghost')).toBe(false);
+ });
+
+ it('isTargetWanted goes false once the contract is paid', () => {
+ const p = useMostWantedStore.getState().postBounty(basePost);
+ useMostWantedStore.getState().fulfilBounty({
+ posterId: p.id,
+ hunterPlayerId: 'p_other',
+ hunterGangName: 'Westend',
+ proofImageUrl: 'x',
+ });
+ expect(useMostWantedStore.getState().isTargetWanted('opp_1')).toBe(false);
+ });
+});
+
+describe('ally ledger', () => {
+ it('addAlly assigns an id and prepends', () => {
+ const a = useMostWantedStore.getState().addAlly({
+ playerId: 'p_x',
+ gangName: 'Southgate',
+ origin: 'bounty_fulfilled',
+ since: new Date().toISOString(),
+ standing: 55,
+ jobsCompleted: 0,
+ moneyExchanged: 0,
+ });
+ expect(a.id).toMatch(/^ally_/);
+ expect(useMostWantedStore.getState().getAlly('p_x')).not.toBeNull();
+ });
+
+ it('recordAllyJob bumps standing, jobs, and money', () => {
+ useMostWantedStore.getState().addAlly({
+ playerId: 'p_x',
+ gangName: 'Southgate',
+ origin: 'bounty_fulfilled',
+ since: new Date().toISOString(),
+ standing: 55,
+ jobsCompleted: 1,
+ moneyExchanged: 1_000,
+ });
+ useMostWantedStore.getState().recordAllyJob('p_x', 500);
+ const a = useMostWantedStore.getState().getAlly('p_x')!;
+ expect(a.jobsCompleted).toBe(2);
+ expect(a.moneyExchanged).toBe(1_500);
+ expect(a.standing).toBe(55 + BOUNTY_CONFIG.ALLY_STANDING_PER_JOB);
+ });
+
+ it('getAlly returns null for a stranger', () => {
+ expect(useMostWantedStore.getState().getAlly('nobody')).toBeNull();
+ });
+});
+
+describe('pruneHistory', () => {
+ it('keeps open posters regardless of age', () => {
+ useMostWantedStore.getState().postBounty(basePost);
+ useMostWantedStore.getState().pruneHistory(Date.now() + 10 * 365 * 86_400_000);
+ expect(useMostWantedStore.getState().posters).toHaveLength(1);
+ });
+
+ it('drops settled posters past the retention horizon', () => {
+ const p = useMostWantedStore.getState().postBounty(basePost);
+ useMostWantedStore.getState().fulfilBounty({
+ posterId: p.id,
+ hunterPlayerId: 'p_other',
+ hunterGangName: 'Westend',
+ proofImageUrl: 'x',
+ });
+ useMostWantedStore
+ .getState()
+ .pruneHistory(Date.now() + BOUNTY_CONFIG.HISTORY_RETENTION_MS + 10_000);
+ expect(useMostWantedStore.getState().posters).toHaveLength(0);
+ });
+});
diff --git a/frontend/src/stores/getBackStore.ts b/frontend/src/stores/getBackStore.ts
new file mode 100644
index 0000000..5510dda
--- /dev/null
+++ b/frontend/src/stores/getBackStore.ts
@@ -0,0 +1,294 @@
+// ============================================================
+// getBackStore — the Get Back clock
+// Sprint 15-B
+//
+// Revenge on a shot clock. When you lose a member, a window opens
+// against the gang that did it. Catch one of the members who was
+// present before the clock runs out and you take double morale off
+// them. Let it expire and your own crew loses faith in you.
+//
+// The clock is wall-clock based, not tick based. A window opened
+// before the app was backgrounded must still be expired correctly
+// when the player comes back, so every read derives remaining time
+// from `openedAt + durationMs` rather than decrementing a counter.
+// ============================================================
+
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import type {
+ GetBackWindow,
+ GetBackTrigger,
+ GetBackOutcome,
+} from '../types/game.types';
+
+// ─── Tuning ──────────────────────────────────────────────────
+
+export const GET_BACK_CONFIG = {
+ /** Default window length by trigger. Slides get the shortest leash. */
+ DURATION_MS: {
+ slide_casualty: 24 * 60 * 60 * 1000, // 24h
+ bounty_fulfilled: 48 * 60 * 60 * 1000, // 48h — you were blindsided
+ special_person: 72 * 60 * 60 * 1000, // 72h — family, more rope
+ block_attack: 24 * 60 * 60 * 1000, // 24h
+ } as Record,
+
+ /** Multiplier applied to the offender's gained morale when you get back. */
+ SUCCESS_MULTIPLIER: 2,
+
+ /** Flat morale hit to your own gang when a window expires unanswered. */
+ EXPIRY_MORALE_PENALTY: 12,
+
+ /** Below this fraction remaining, the UI should go red and urgent. */
+ URGENT_THRESHOLD: 0.25,
+
+ /** Windows are dropped from history after this long. */
+ HISTORY_RETENTION_MS: 14 * 24 * 60 * 60 * 1000, // 14 days
+} as const;
+
+// ─── Pure helpers (exported for testing) ─────────────────────
+
+/** Remaining ms on a window. Zero once elapsed. Never negative. */
+export function remainingMs(w: GetBackWindow, now: number = Date.now()): number {
+ if (w.outcome !== 'pending') return 0;
+ return Math.max(0, w.openedAt + w.durationMs - now);
+}
+
+/** Fraction of the window still on the clock, 0..1. */
+export function remainingFraction(w: GetBackWindow, now: number = Date.now()): number {
+ if (w.durationMs <= 0) return 0;
+ return remainingMs(w, now) / w.durationMs;
+}
+
+/** True when the clock has run out but the window has not been resolved yet. */
+export function isLapsed(w: GetBackWindow, now: number = Date.now()): boolean {
+ return w.outcome === 'pending' && remainingMs(w, now) === 0;
+}
+
+/** Urgency band for UI treatment. */
+export function urgency(
+ w: GetBackWindow,
+ now: number = Date.now(),
+): 'expired' | 'urgent' | 'normal' {
+ if (isLapsed(w, now)) return 'expired';
+ return remainingFraction(w, now) <= GET_BACK_CONFIG.URGENT_THRESHOLD
+ ? 'urgent'
+ : 'normal';
+}
+
+/** Shot-clock display string. Hours:minutes above an hour, else minutes:seconds. */
+export function formatClock(ms: number): string {
+ if (ms <= 0) return '00:00';
+ const totalSeconds = Math.floor(ms / 1000);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return hours > 0 ? `${pad(hours)}:${pad(minutes)}` : `${pad(minutes)}:${pad(seconds)}`;
+}
+
+/**
+ * Morale swing for a resolved window.
+ *
+ * Success is deliberately lopsided: the retaliating gang gains double what
+ * the offender originally took, and the offender loses that same doubled
+ * amount plus the morale they banked from the original hit. Getting back
+ * should feel like it erases the whole exchange and then some.
+ */
+export function moraleSwing(
+ w: GetBackWindow,
+ outcome: Exclude,
+): { retaliator: number; offender: number } {
+ const gained = Math.max(0, w.offenderMoraleGained);
+ if (outcome === 'success') {
+ const doubled = gained * GET_BACK_CONFIG.SUCCESS_MULTIPLIER;
+ return { retaliator: doubled, offender: -(doubled + gained) };
+ }
+ // Expired or forfeited — the offender keeps what they took, you eat a penalty.
+ return { retaliator: -GET_BACK_CONFIG.EXPIRY_MORALE_PENALTY, offender: 0 };
+}
+
+/** True when catching this member satisfies the window. */
+export function isValidRevengeTarget(w: GetBackWindow, memberId: string): boolean {
+ return w.outcome === 'pending' && w.wantedMemberIds.includes(memberId);
+}
+
+// ─── Store ───────────────────────────────────────────────────
+
+export interface OpenWindowInput {
+ trigger: GetBackTrigger;
+ lostMemberId: string;
+ lostMemberName: string;
+ offendingGangName: string;
+ offendingPlayerId?: string;
+ wantedMemberIds: string[];
+ wantedMemberNames: string[];
+ offenderMoraleGained: number;
+ /** Override the trigger's default duration. */
+ durationMs?: number;
+}
+
+export interface ResolveResult {
+ window: GetBackWindow;
+ moraleSwing: { retaliator: number; offender: number };
+}
+
+interface GetBackState {
+ windows: GetBackWindow[];
+
+ openWindow: (input: OpenWindowInput) => GetBackWindow;
+
+ /**
+ * Resolve a window explicitly. Returns null when the id is unknown or the
+ * window was already resolved, so callers can't double-apply morale.
+ */
+ resolveWindow: (
+ windowId: string,
+ outcome: Exclude,
+ resolvedAgainstMemberId?: string,
+ ) => ResolveResult | null;
+
+ /**
+ * Called when a member is caught. Resolves the first pending window that
+ * lists them as a wanted target. Returns null when they aren't wanted.
+ */
+ registerCatch: (memberId: string) => ResolveResult | null;
+
+ /**
+ * Sweep lapsed windows to `expired`. Safe to call on every render or on a
+ * timer. Returns the windows that flipped so the caller can apply morale.
+ */
+ sweepExpired: (now?: number) => ResolveResult[];
+
+ activeWindows: (now?: number) => GetBackWindow[];
+ /** The window closest to expiry — what the HUD clock should show. */
+ mostUrgentWindow: (now?: number) => GetBackWindow | null;
+ isMemberWanted: (memberId: string) => boolean;
+ /** All member ids currently hunted across every open window. */
+ allWantedMemberIds: () => string[];
+ pruneHistory: (now?: number) => void;
+ clearAll: () => void;
+}
+
+function makeId(): string {
+ return `gbw_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+}
+
+export const useGetBackStore = create()(
+ persist(
+ (set, get) => ({
+ windows: [],
+
+ openWindow: (input) => {
+ const now = Date.now();
+ const window: GetBackWindow = {
+ id: makeId(),
+ trigger: input.trigger,
+ openedAt: now,
+ durationMs:
+ input.durationMs ?? GET_BACK_CONFIG.DURATION_MS[input.trigger],
+ lostMemberId: input.lostMemberId,
+ lostMemberName: input.lostMemberName,
+ offendingGangName: input.offendingGangName,
+ offendingPlayerId: input.offendingPlayerId,
+ wantedMemberIds: [...input.wantedMemberIds],
+ wantedMemberNames: [...input.wantedMemberNames],
+ offenderMoraleGained: input.offenderMoraleGained,
+ outcome: 'pending',
+ };
+ set((state) => ({ windows: [window, ...state.windows] }));
+ return window;
+ },
+
+ resolveWindow: (windowId, outcome, resolvedAgainstMemberId) => {
+ const existing = get().windows.find((w) => w.id === windowId);
+ if (!existing || existing.outcome !== 'pending') return null;
+
+ const resolved: GetBackWindow = {
+ ...existing,
+ outcome,
+ resolvedAt: Date.now(),
+ resolvedAgainstMemberId,
+ };
+
+ set((state) => ({
+ windows: state.windows.map((w) => (w.id === windowId ? resolved : w)),
+ }));
+
+ return { window: resolved, moraleSwing: moraleSwing(resolved, outcome) };
+ },
+
+ registerCatch: (memberId) => {
+ const now = Date.now();
+ // Oldest pending window first — the debt you've owed longest clears first.
+ const target = [...get().windows]
+ .filter((w) => w.outcome === 'pending' && !isLapsed(w, now))
+ .sort((a, b) => a.openedAt - b.openedAt)
+ .find((w) => w.wantedMemberIds.includes(memberId));
+
+ if (!target) return null;
+ return get().resolveWindow(target.id, 'success', memberId);
+ },
+
+ sweepExpired: (now = Date.now()) => {
+ const lapsed = get().windows.filter((w) => isLapsed(w, now));
+ if (lapsed.length === 0) return [];
+
+ const results: ResolveResult[] = lapsed.map((w) => {
+ const resolved: GetBackWindow = {
+ ...w,
+ outcome: 'expired' as const,
+ resolvedAt: now,
+ };
+ return { window: resolved, moraleSwing: moraleSwing(resolved, 'expired') };
+ });
+
+ const lapsedIds = new Set(lapsed.map((w) => w.id));
+ set((state) => ({
+ windows: state.windows.map((w) =>
+ lapsedIds.has(w.id)
+ ? { ...w, outcome: 'expired' as const, resolvedAt: now }
+ : w,
+ ),
+ }));
+
+ return results;
+ },
+
+ activeWindows: (now = Date.now()) =>
+ get().windows.filter((w) => w.outcome === 'pending' && !isLapsed(w, now)),
+
+ mostUrgentWindow: (now = Date.now()) => {
+ const active = get().activeWindows(now);
+ if (active.length === 0) return null;
+ return active.reduce((soonest, w) =>
+ remainingMs(w, now) < remainingMs(soonest, now) ? w : soonest,
+ );
+ },
+
+ isMemberWanted: (memberId) =>
+ get()
+ .activeWindows()
+ .some((w) => w.wantedMemberIds.includes(memberId)),
+
+ allWantedMemberIds: () => {
+ const ids = new Set();
+ for (const w of get().activeWindows()) {
+ for (const id of w.wantedMemberIds) ids.add(id);
+ }
+ return [...ids];
+ },
+
+ pruneHistory: (now = Date.now()) =>
+ set((state) => ({
+ windows: state.windows.filter(
+ (w) =>
+ w.outcome === 'pending' ||
+ (w.resolvedAt ?? 0) > now - GET_BACK_CONFIG.HISTORY_RETENTION_MS,
+ ),
+ })),
+
+ clearAll: () => set({ windows: [] }),
+ }),
+ { name: 'slide-getback-store' },
+ ),
+);
diff --git a/frontend/src/stores/mostWantedStore.ts b/frontend/src/stores/mostWantedStore.ts
new file mode 100644
index 0000000..5df6efb
--- /dev/null
+++ b/frontend/src/stores/mostWantedStore.ts
@@ -0,0 +1,368 @@
+// ============================================================
+// mostWantedStore — the Most Wanted board and ally ledger
+// Sprint 15-B
+//
+// Players post bounties on members or on a member's special people.
+// Any other player can take the contract, upload proof, and cash out.
+// The payer's balance is debited, the hunter is saved as an ally, and
+// the payout is public record — which is exactly how the next war
+// starts.
+//
+// Verification is intentionally trust-first: proof is a screenshot and
+// the payout is automatic. A dispute path would need a server-side
+// referee, so instead the receipt is public and reputation does the
+// policing.
+// ============================================================
+
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import type { MostWantedPoster, BountyStatus, Ally } from '../types/game.types';
+
+// ─── Tuning ──────────────────────────────────────────────────
+
+export const BOUNTY_CONFIG = {
+ MIN_REWARD: 500,
+ MAX_REWARD: 500_000,
+
+ /** How long a poster stays up before it expires unfulfilled. */
+ DEFAULT_DURATION_MS: 7 * 24 * 60 * 60 * 1000, // 7 days
+
+ /** Board takes a cut when a bounty is posted. Non-refundable on expiry. */
+ LISTING_FEE_RATE: 0.05,
+
+ /** Hits on a member's people cost more — it's uglier work. */
+ SPECIAL_PERSON_PREMIUM: 1.5,
+
+ /** Heat the hunter picks up for cashing a bounty. */
+ HUNTER_HEAT: 8,
+
+ /** Heat the poster picks up. Ordering it is a paper trail too. */
+ POSTER_HEAT: 4,
+
+ /** Standing a fresh ally starts at. */
+ ALLY_STARTING_STANDING: 55,
+
+ /** Standing gained per completed job together. */
+ ALLY_STANDING_PER_JOB: 8,
+
+ MAX_ALLY_STANDING: 100,
+
+ /** Paid/expired posters drop off the board after this long. */
+ HISTORY_RETENTION_MS: 30 * 24 * 60 * 60 * 1000,
+} as const;
+
+// ─── Pure helpers (exported for testing) ─────────────────────
+
+/** Listing fee charged up front when posting. */
+export function listingFee(reward: number): number {
+ return Math.ceil(reward * BOUNTY_CONFIG.LISTING_FEE_RATE);
+}
+
+/** Total debited from the poster at the moment of posting. */
+export function totalPostingCost(reward: number): number {
+ return reward + listingFee(reward);
+}
+
+/**
+ * Minimum reward the board will accept for a target.
+ *
+ * Scales with the target's level so nobody posts a $500 bounty on a level 10
+ * shooter and expects takers. Special-person contracts carry a premium.
+ */
+export function minimumReward(
+ targetLevel: number,
+ targetKind: 'member' | 'special_person',
+): number {
+ const base = BOUNTY_CONFIG.MIN_REWARD * Math.max(1, targetLevel);
+ const scaled =
+ targetKind === 'special_person'
+ ? base * BOUNTY_CONFIG.SPECIAL_PERSON_PREMIUM
+ : base;
+ return Math.min(Math.ceil(scaled), BOUNTY_CONFIG.MAX_REWARD);
+}
+
+export function isExpired(p: MostWantedPoster, now: number = Date.now()): boolean {
+ if (p.status !== 'open') return false;
+ return new Date(p.expiresAt).getTime() <= now;
+}
+
+export function remainingMs(p: MostWantedPoster, now: number = Date.now()): number {
+ return Math.max(0, new Date(p.expiresAt).getTime() - now);
+}
+
+/** Days-and-hours label for a poster's remaining life. */
+export function formatRemaining(ms: number): string {
+ if (ms <= 0) return 'EXPIRED';
+ const hours = Math.floor(ms / 3_600_000);
+ if (hours >= 24) {
+ const days = Math.floor(hours / 24);
+ return `${days}d ${hours % 24}h left`;
+ }
+ if (hours >= 1) return `${hours}h left`;
+ return `${Math.max(1, Math.floor(ms / 60_000))}m left`;
+}
+
+export function canFulfil(
+ p: MostWantedPoster,
+ hunterPlayerId: string,
+ now: number = Date.now(),
+): { allowed: boolean; reason?: string } {
+ if (p.status !== 'open') return { allowed: false, reason: 'This contract is closed.' };
+ if (isExpired(p, now)) return { allowed: false, reason: 'This contract expired.' };
+ if (p.postedBy === hunterPlayerId) {
+ return { allowed: false, reason: "You can't cash your own contract." };
+ }
+ return { allowed: true };
+}
+
+/** Standing after crediting one more completed job. */
+export function bumpStanding(current: number): number {
+ return Math.min(
+ BOUNTY_CONFIG.MAX_ALLY_STANDING,
+ current + BOUNTY_CONFIG.ALLY_STANDING_PER_JOB,
+ );
+}
+
+// ─── Store ───────────────────────────────────────────────────
+
+export interface PostBountyInput {
+ postedBy: string;
+ postedByGangName: string;
+ targetKind: 'member' | 'special_person';
+ targetId: string;
+ targetName: string;
+ targetGangName: string;
+ targetOwnerMemberId?: string;
+ targetOwnerName?: string;
+ lastKnownBlockId?: string;
+ lastKnownAddress?: string;
+ posterImageUrl?: string;
+ note?: string;
+ reward: number;
+ durationMs?: number;
+}
+
+export interface FulfilInput {
+ posterId: string;
+ hunterPlayerId: string;
+ hunterGangName: string;
+ proofImageUrl: string;
+}
+
+export interface PayoutReceipt {
+ poster: MostWantedPoster;
+ reward: number;
+ /** Player who gets paid. */
+ paidTo: string;
+ paidToGangName: string;
+ /** Player who pays. */
+ paidBy: string;
+ /** Ally record created or updated on the poster's side. */
+ ally: Ally;
+}
+
+interface MostWantedState {
+ posters: MostWantedPoster[];
+ allies: Ally[];
+
+ postBounty: (input: PostBountyInput) => MostWantedPoster;
+ cancelBounty: (posterId: string) => boolean;
+
+ /**
+ * Submit proof and cash out. Returns the receipt so the caller can debit the
+ * poster's balance, credit the hunter, log the shoebox transaction, and fire
+ * the inbox message. Returns null when the contract can't be fulfilled.
+ */
+ fulfilBounty: (input: FulfilInput) => PayoutReceipt | null;
+
+ sweepExpired: (now?: number) => MostWantedPoster[];
+
+ openPosters: (now?: number) => MostWantedPoster[];
+ postersBy: (playerId: string) => MostWantedPoster[];
+ postersAgainstGang: (gangName: string) => MostWantedPoster[];
+ bountyOnTarget: (targetId: string) => MostWantedPoster | null;
+ isTargetWanted: (targetId: string) => boolean;
+
+ addAlly: (ally: Omit) => Ally;
+ recordAllyJob: (playerId: string, money: number) => void;
+ getAlly: (playerId: string) => Ally | null;
+
+ pruneHistory: (now?: number) => void;
+ clearAll: () => void;
+}
+
+function makeId(prefix: string): string {
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+}
+
+export const useMostWantedStore = create()(
+ persist(
+ (set, get) => ({
+ posters: [],
+ allies: [],
+
+ postBounty: (input) => {
+ const now = Date.now();
+ const duration = input.durationMs ?? BOUNTY_CONFIG.DEFAULT_DURATION_MS;
+ const poster: MostWantedPoster = {
+ id: makeId('mw'),
+ postedBy: input.postedBy,
+ postedByGangName: input.postedByGangName,
+ postedAt: new Date(now).toISOString(),
+ expiresAt: new Date(now + duration).toISOString(),
+ targetKind: input.targetKind,
+ targetId: input.targetId,
+ targetName: input.targetName,
+ targetGangName: input.targetGangName,
+ targetOwnerMemberId: input.targetOwnerMemberId,
+ targetOwnerName: input.targetOwnerName,
+ lastKnownBlockId: input.lastKnownBlockId,
+ lastKnownAddress: input.lastKnownAddress,
+ posterImageUrl: input.posterImageUrl,
+ note: input.note,
+ reward: Math.min(
+ BOUNTY_CONFIG.MAX_REWARD,
+ Math.max(BOUNTY_CONFIG.MIN_REWARD, Math.floor(input.reward)),
+ ),
+ status: 'open',
+ };
+ set((state) => ({ posters: [poster, ...state.posters] }));
+ return poster;
+ },
+
+ cancelBounty: (posterId) => {
+ const p = get().posters.find((x) => x.id === posterId);
+ if (!p || p.status !== 'open') return false;
+ set((state) => ({
+ posters: state.posters.map((x) =>
+ x.id === posterId ? { ...x, status: 'cancelled' as BountyStatus } : x,
+ ),
+ }));
+ return true;
+ },
+
+ fulfilBounty: ({ posterId, hunterPlayerId, hunterGangName, proofImageUrl }) => {
+ const poster = get().posters.find((p) => p.id === posterId);
+ if (!poster) return null;
+
+ const check = canFulfil(poster, hunterPlayerId);
+ if (!check.allowed) return null;
+
+ const now = Date.now();
+ const paid: MostWantedPoster = {
+ ...poster,
+ status: 'paid',
+ claimedBy: hunterPlayerId,
+ claimedByGangName: hunterGangName,
+ claimedAt: new Date(now).toISOString(),
+ proofImageUrl,
+ paidAt: new Date(now).toISOString(),
+ };
+
+ set((state) => ({
+ posters: state.posters.map((p) => (p.id === posterId ? paid : p)),
+ }));
+
+ // The hunter becomes an ally of the poster. Business, not friendship.
+ const existing = get().allies.find((a) => a.playerId === hunterPlayerId);
+ let ally: Ally;
+ if (existing) {
+ ally = {
+ ...existing,
+ standing: bumpStanding(existing.standing),
+ jobsCompleted: existing.jobsCompleted + 1,
+ moneyExchanged: existing.moneyExchanged + poster.reward,
+ };
+ set((state) => ({
+ allies: state.allies.map((a) => (a.playerId === hunterPlayerId ? ally : a)),
+ }));
+ } else {
+ ally = {
+ id: makeId('ally'),
+ playerId: hunterPlayerId,
+ gangName: hunterGangName,
+ origin: 'bounty_fulfilled',
+ since: new Date(now).toISOString(),
+ standing: BOUNTY_CONFIG.ALLY_STARTING_STANDING,
+ jobsCompleted: 1,
+ moneyExchanged: poster.reward,
+ };
+ set((state) => ({ allies: [ally, ...state.allies] }));
+ }
+
+ return {
+ poster: paid,
+ reward: poster.reward,
+ paidTo: hunterPlayerId,
+ paidToGangName: hunterGangName,
+ paidBy: poster.postedBy,
+ ally,
+ };
+ },
+
+ sweepExpired: (now = Date.now()) => {
+ const lapsed = get().posters.filter((p) => isExpired(p, now));
+ if (lapsed.length === 0) return [];
+ const ids = new Set(lapsed.map((p) => p.id));
+ set((state) => ({
+ posters: state.posters.map((p) =>
+ ids.has(p.id) ? { ...p, status: 'expired' as BountyStatus } : p,
+ ),
+ }));
+ return lapsed.map((p) => ({ ...p, status: 'expired' as BountyStatus }));
+ },
+
+ openPosters: (now = Date.now()) =>
+ get().posters.filter((p) => p.status === 'open' && !isExpired(p, now)),
+
+ postersBy: (playerId) => get().posters.filter((p) => p.postedBy === playerId),
+
+ postersAgainstGang: (gangName) =>
+ get().posters.filter(
+ (p) => p.targetGangName === gangName && p.status === 'open',
+ ),
+
+ bountyOnTarget: (targetId) =>
+ get()
+ .openPosters()
+ .find((p) => p.targetId === targetId) ?? null,
+
+ isTargetWanted: (targetId) => get().bountyOnTarget(targetId) !== null,
+
+ addAlly: (input) => {
+ const ally: Ally = { ...input, id: makeId('ally') };
+ set((state) => ({ allies: [ally, ...state.allies] }));
+ return ally;
+ },
+
+ recordAllyJob: (playerId, money) =>
+ set((state) => ({
+ allies: state.allies.map((a) =>
+ a.playerId === playerId
+ ? {
+ ...a,
+ standing: bumpStanding(a.standing),
+ jobsCompleted: a.jobsCompleted + 1,
+ moneyExchanged: a.moneyExchanged + money,
+ }
+ : a,
+ ),
+ })),
+
+ getAlly: (playerId) =>
+ get().allies.find((a) => a.playerId === playerId) ?? null,
+
+ pruneHistory: (now = Date.now()) =>
+ set((state) => ({
+ posters: state.posters.filter((p) => {
+ if (p.status === 'open') return true;
+ const settled = new Date(p.paidAt ?? p.expiresAt).getTime();
+ return settled > now - BOUNTY_CONFIG.HISTORY_RETENTION_MS;
+ }),
+ })),
+
+ clearAll: () => set({ posters: [], allies: [] }),
+ }),
+ { name: 'slide-mostwanted-store' },
+ ),
+);
diff --git a/frontend/src/types/game.types.ts b/frontend/src/types/game.types.ts
index 2f9bb2e..c37fa13 100644
--- a/frontend/src/types/game.types.ts
+++ b/frontend/src/types/game.types.ts
@@ -984,3 +984,226 @@ export interface SelfieUpload {
status: 'pending' | 'processing' | 'done' | 'failed';
uploadedAt: string;
}
+
+
+// ─────────────────────────────────────────────────────────────────────────────
+// SPECIAL PEOPLE & ORIGIN STORIES (Sprint 15-B)
+//
+// Every member has two people they care about. Opposing players can buy hits
+// on them through the Most Wanted board. How the player responds when a
+// member's people get touched drives that member's loyalty and the gang's
+// morale — ignoring it is how a shooter ends up flipping.
+// ─────────────────────────────────────────────────────────────────────────────
+
+export type SpecialPersonRelation =
+ | 'mother'
+ | 'father'
+ | 'brother'
+ | 'sister'
+ | 'son'
+ | 'daughter'
+ | 'girlfriend'
+ | 'boyfriend'
+ | 'cousin'
+ | 'best_friend'
+ | 'grandmother'
+ | 'auntie';
+
+export type SpecialPersonStatus =
+ | 'safe'
+ | 'threatened' // A hit has been posted, not yet fulfilled
+ | 'hurt' // Hit fulfilled, non-fatal
+ | 'killed'; // Hit fulfilled, fatal
+
+export interface SpecialPerson {
+ id: string;
+ name: string;
+ relation: SpecialPersonRelation;
+ status: SpecialPersonStatus;
+ /** Where they can be found — used when an opp goes looking. */
+ neighborhood?: string;
+ /** Set when a hit resolves against this person. */
+ touchedAt?: string;
+ touchedBy?: string;
+ /** True once the player has retaliated for this person. */
+ avenged?: boolean;
+}
+
+export interface MemberOriginStory {
+ /** One-line hook shown on the contact card. */
+ hook: string;
+ /** Two to four sentences of backstory. */
+ body: string;
+ /** Where they came up. */
+ cameUpOn: string;
+ /** Why they took the work. */
+ reason: string;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// UNDERWORLD MARKETPLACE — MEMBERS FOR SALE (Sprint 15-B)
+//
+// Members bought off the underworld come pre-leveled. You pay a premium to
+// skip the grind, and the higher tiers carry baggage: more heat, lower
+// starting loyalty, or a habit of catching cases.
+// ─────────────────────────────────────────────────────────────────────────────
+
+export type HireableRole =
+ | 'recruit'
+ | 'dealer'
+ | 'shooter'
+ | 'enforcer'
+ | 'driver'
+ | 'lookout'
+ | 'k9';
+
+export type HireableTier = 'street' | 'seasoned' | 'certified' | 'legend';
+
+export interface HireableMember {
+ id: string;
+ name: string;
+ nickname: string;
+ role: HireableRole;
+ tier: HireableTier;
+ level: number;
+ price: number;
+ /** Weekly wage once hired. */
+ salary: number;
+ /** 0-100. Bought members start lower than recruited ones. */
+ startingLoyalty: number;
+ /** Multiplier on heat this member generates while deployed. */
+ heatFactor: number;
+ stats: {
+ shooting: number;
+ dealing: number;
+ nerve: number;
+ stealth: number;
+ };
+ originStory: MemberOriginStory;
+ specialPeople: SpecialPerson[];
+ /** Honest warning shown on the listing. Empty for clean hires. */
+ baggage: string | null;
+ avatarSpriteKey?: string;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// MOST WANTED BOARD (Sprint 15-B)
+//
+// Players post bounties on specific members. Any other player can fulfil one,
+// upload proof, and cash out. The payer's shoebox is debited, the fulfiller is
+// saved as an ally, and the whole transaction is public — which is how wars
+// start.
+// ─────────────────────────────────────────────────────────────────────────────
+
+export type BountyStatus =
+ | 'open'
+ | 'claimed' // Someone submitted proof, awaiting verification
+ | 'paid'
+ | 'expired'
+ | 'cancelled';
+
+export type BountyTargetKind = 'member' | 'special_person';
+
+export interface MostWantedPoster {
+ id: string;
+ /** Player who posted and is paying. */
+ postedBy: string;
+ postedByGangName: string;
+ postedAt: string;
+ expiresAt: string;
+
+ targetKind: BountyTargetKind;
+ /** Member id, or special-person id when targetKind is special_person. */
+ targetId: string;
+ targetName: string;
+ /** For special_person bounties: the member they belong to. */
+ targetOwnerMemberId?: string;
+ targetOwnerName?: string;
+ /** Gang the target runs with. */
+ targetGangName: string;
+ /** Last known block, so hunters know where to post up. */
+ lastKnownBlockId?: string;
+ lastKnownAddress?: string;
+
+ /** Uploaded photo of the target. */
+ posterImageUrl?: string;
+ /** Optional note from the poster. Never required. */
+ note?: string;
+
+ reward: number;
+ status: BountyStatus;
+
+ /** Set when a hunter submits. */
+ claimedBy?: string;
+ claimedByGangName?: string;
+ claimedAt?: string;
+ /** Screenshot the hunter uploaded as proof. */
+ proofImageUrl?: string;
+ paidAt?: string;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// GET BACK CLOCK (Sprint 15-B)
+//
+// An NBA-style shot clock on revenge. When a member is lost — to a slide, a
+// bounty, or a raid gone wrong — a window opens. Get back inside it and your
+// gang takes double morale off the other side. Let it run out and your own
+// crew loses faith.
+// ─────────────────────────────────────────────────────────────────────────────
+
+export type GetBackOutcome = 'pending' | 'success' | 'expired' | 'forfeited';
+
+export type GetBackTrigger =
+ | 'slide_casualty' // Lost a member in a drive-by
+ | 'bounty_fulfilled' // A bounty was cashed on one of yours
+ | 'special_person' // Someone touched a member's people
+ | 'block_attack'; // Lost a member defending the block
+
+export interface GetBackWindow {
+ id: string;
+ trigger: GetBackTrigger;
+ openedAt: number;
+ /** Wall-clock ms the player has to respond. */
+ durationMs: number;
+
+ /** Who we lost. */
+ lostMemberId: string;
+ lostMemberName: string;
+
+ /** Who did it. Gang name is always known; member ids only when claimed. */
+ offendingGangName: string;
+ offendingPlayerId?: string;
+ /** Members known to have been present. These are the valid revenge targets. */
+ wantedMemberIds: string[];
+ wantedMemberNames: string[];
+
+ /** Morale the offending gang gained from the original hit. */
+ offenderMoraleGained: number;
+
+ outcome: GetBackOutcome;
+ resolvedAt?: number;
+ /** Which wanted member was caught, when successful. */
+ resolvedAgainstMemberId?: string;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// ALLIES (Sprint 15-B)
+//
+// Allies come out of business, not friendship. Someone fulfils your bounty,
+// they go in your book. Allies can be called on, and can turn.
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface Ally {
+ id: string;
+ playerId: string;
+ gangName: string;
+ /** How this relationship started. */
+ origin: 'bounty_fulfilled' | 'bounty_paid' | 'mutual_opp' | 'manual';
+ since: string;
+ /** 0-100. Drops if you stiff them or take their contracts. */
+ standing: number;
+ /** Running total of business done together. */
+ jobsCompleted: number;
+ moneyExchanged: number;
+ notes?: string[];
+}
diff --git a/frontend/src/utils/__tests__/marketMembersCatalog.test.ts b/frontend/src/utils/__tests__/marketMembersCatalog.test.ts
new file mode 100644
index 0000000..bd91f66
--- /dev/null
+++ b/frontend/src/utils/__tests__/marketMembersCatalog.test.ts
@@ -0,0 +1,341 @@
+// ============================================================
+// marketMembersCatalog tests — Sprint 15-B
+//
+// The catalog is procedural, so the tests assert invariants rather
+// than exact values: tiers must stay legibly priced relative to each
+// other, stats must respect their band, K9s must not be given family,
+// and a given listing id must always regenerate identically so the
+// buy flow does not lose the listing mid-purchase.
+// ============================================================
+
+import { describe, it, expect } from 'vitest';
+import {
+ TIER_BANDS,
+ ROLE_PROFILES,
+ RELATION_LABELS,
+ generateHireable,
+ generateMemberBoard,
+ generateSpecialPeople,
+ hireableToMemberPayload,
+} from '../marketMembersCatalog';
+import type { HireableRole, HireableTier } from '../../types/game.types';
+
+const ALL_TIERS: HireableTier[] = ['street', 'seasoned', 'certified', 'legend'];
+const ALL_ROLES: HireableRole[] = [
+ 'recruit', 'dealer', 'shooter', 'enforcer', 'driver', 'lookout', 'k9',
+];
+
+/** Small deterministic RNG for exercising helpers directly. */
+function seededRng(seed: number): () => number {
+ let s = seed >>> 0;
+ return () => {
+ s = (s * 1664525 + 1013904223) >>> 0;
+ return s / 0x100000000;
+ };
+}
+
+describe('TIER_BANDS', () => {
+ it('covers every tier', () => {
+ for (const t of ALL_TIERS) expect(TIER_BANDS[t]).toBeDefined();
+ });
+
+ it('prices rise monotonically with tier', () => {
+ const mults = ALL_TIERS.map((t) => TIER_BANDS[t].priceMultiplier);
+ for (let i = 1; i < mults.length; i++) {
+ expect(mults[i]).toBeGreaterThan(mults[i - 1]);
+ }
+ });
+
+ it('level ranges rise and do not overlap', () => {
+ for (let i = 1; i < ALL_TIERS.length; i++) {
+ const prev = TIER_BANDS[ALL_TIERS[i - 1]].levelRange;
+ const cur = TIER_BANDS[ALL_TIERS[i]].levelRange;
+ expect(cur[0]).toBeGreaterThan(prev[1]);
+ }
+ });
+
+ it('loyalty falls as tier rises — the good ones are harder to hold', () => {
+ const floors = ALL_TIERS.map((t) => TIER_BANDS[t].loyaltyRange[0]);
+ for (let i = 1; i < floors.length; i++) {
+ expect(floors[i]).toBeLessThan(floors[i - 1]);
+ }
+ });
+
+ it('heat factor rises as tier rises — reputation attracts police', () => {
+ const heat = ALL_TIERS.map((t) => TIER_BANDS[t].heatFactorRange[0]);
+ for (let i = 1; i < heat.length; i++) {
+ expect(heat[i]).toBeGreaterThanOrEqual(heat[i - 1]);
+ }
+ });
+
+ it('baggage odds rise with tier', () => {
+ const odds = ALL_TIERS.map((t) => TIER_BANDS[t].baggageChance);
+ for (let i = 1; i < odds.length; i++) {
+ expect(odds[i]).toBeGreaterThan(odds[i - 1]);
+ }
+ });
+
+ it('stat bands are ordered and valid', () => {
+ for (const t of ALL_TIERS) {
+ const b = TIER_BANDS[t];
+ expect(b.statFloor).toBeLessThan(b.statCeiling);
+ expect(b.statCeiling).toBeLessThanOrEqual(100);
+ }
+ });
+});
+
+describe('ROLE_PROFILES', () => {
+ it('covers every role', () => {
+ for (const r of ALL_ROLES) expect(ROLE_PROFILES[r]).toBeDefined();
+ });
+
+ it('gives each role a primary stat and positive pricing', () => {
+ for (const r of ALL_ROLES) {
+ const p = ROLE_PROFILES[r];
+ expect(['shooting', 'dealing', 'nerve', 'stealth']).toContain(p.primary);
+ expect(p.basePrice).toBeGreaterThan(0);
+ expect(p.baseSalary).toBeGreaterThan(0);
+ }
+ });
+
+ it('keeps a K9 cheap to feed relative to a person of similar cost', () => {
+ expect(ROLE_PROFILES.k9.baseSalary).toBeLessThan(ROLE_PROFILES.shooter.baseSalary);
+ });
+
+ it('prices a recruit lowest of all roles', () => {
+ const cheapest = Math.min(...ALL_ROLES.map((r) => ROLE_PROFILES[r].basePrice));
+ expect(ROLE_PROFILES.recruit.basePrice).toBe(cheapest);
+ });
+});
+
+describe('RELATION_LABELS', () => {
+ it('has a readable label for every relation used by the generator', () => {
+ const people = generateSpecialPeople(seededRng(7), 'm_test');
+ for (const p of people) {
+ expect(RELATION_LABELS[p.relation]).toBeTruthy();
+ }
+ });
+});
+
+describe('generateSpecialPeople', () => {
+ it('always returns exactly two people', () => {
+ for (let seed = 1; seed < 30; seed++) {
+ expect(generateSpecialPeople(seededRng(seed), `m_${seed}`)).toHaveLength(2);
+ }
+ });
+
+ it('never assigns the same relation twice', () => {
+ for (let seed = 1; seed < 30; seed++) {
+ const people = generateSpecialPeople(seededRng(seed), `m_${seed}`);
+ expect(new Set(people.map((p) => p.relation)).size).toBe(2);
+ }
+ });
+
+ it('gives each person a stable id derived from the member', () => {
+ const people = generateSpecialPeople(seededRng(3), 'm_abc');
+ expect(people[0].id).toBe('m_abc_sp1');
+ expect(people[1].id).toBe('m_abc_sp2');
+ });
+
+ it('starts everybody safe', () => {
+ const people = generateSpecialPeople(seededRng(11), 'm_x');
+ for (const p of people) expect(p.status).toBe('safe');
+ });
+});
+
+describe('generateHireable', () => {
+ it('is deterministic for a given id, role, and tier', () => {
+ const a = generateHireable('listing_1', 'shooter', 'certified');
+ const b = generateHireable('listing_1', 'shooter', 'certified');
+ expect(a).toEqual(b);
+ });
+
+ it('produces different listings for different ids', () => {
+ const a = generateHireable('listing_1', 'shooter', 'certified');
+ const b = generateHireable('listing_2', 'shooter', 'certified');
+ // Same tier and role, but the rolled details should differ.
+ expect(a).not.toEqual(b);
+ });
+
+ it('keeps level inside the tier band', () => {
+ for (const tier of ALL_TIERS) {
+ for (let i = 0; i < 12; i++) {
+ const h = generateHireable(`l_${tier}_${i}`, 'dealer', tier);
+ const [lo, hi] = TIER_BANDS[tier].levelRange;
+ expect(h.level).toBeGreaterThanOrEqual(lo);
+ expect(h.level).toBeLessThanOrEqual(hi);
+ }
+ }
+ });
+
+ it('keeps loyalty inside the tier band', () => {
+ for (const tier of ALL_TIERS) {
+ for (let i = 0; i < 12; i++) {
+ const h = generateHireable(`y_${tier}_${i}`, 'dealer', tier);
+ const [lo, hi] = TIER_BANDS[tier].loyaltyRange;
+ expect(h.startingLoyalty).toBeGreaterThanOrEqual(lo);
+ expect(h.startingLoyalty).toBeLessThanOrEqual(hi);
+ }
+ }
+ });
+
+ it('keeps heat factor inside the tier band', () => {
+ for (const tier of ALL_TIERS) {
+ for (let i = 0; i < 12; i++) {
+ const h = generateHireable(`h_${tier}_${i}`, 'shooter', tier);
+ const [lo, hi] = TIER_BANDS[tier].heatFactorRange;
+ // Rounded to 2dp, so allow a hair of slack at the edges.
+ expect(h.heatFactor).toBeGreaterThanOrEqual(lo - 0.01);
+ expect(h.heatFactor).toBeLessThanOrEqual(hi + 0.01);
+ }
+ }
+ });
+
+ it('never produces a stat above 100', () => {
+ for (const tier of ALL_TIERS) {
+ for (const role of ALL_ROLES) {
+ const h = generateHireable(`s_${tier}_${role}`, role, tier);
+ for (const v of Object.values(h.stats)) {
+ expect(v).toBeLessThanOrEqual(100);
+ expect(v).toBeGreaterThan(0);
+ }
+ }
+ }
+ });
+
+ it('bumps the role primary stat above the band floor', () => {
+ const h = generateHireable('primary_test', 'shooter', 'legend');
+ expect(h.stats.shooting).toBeGreaterThanOrEqual(TIER_BANDS.legend.statFloor);
+ });
+
+ it('prices a higher tier above a lower tier for the same role', () => {
+ const street = generateHireable('cmp_a', 'shooter', 'street');
+ const legend = generateHireable('cmp_b', 'shooter', 'legend');
+ expect(legend.price).toBeGreaterThan(street.price);
+ });
+
+ it('rounds price to a clean increment', () => {
+ const h = generateHireable('round_test', 'enforcer', 'seasoned');
+ expect(h.price % 50).toBe(0);
+ });
+
+ it('rounds salary to a clean increment', () => {
+ const h = generateHireable('sal_test', 'enforcer', 'seasoned');
+ expect(h.salary % 10).toBe(0);
+ });
+
+ it('gives a K9 a breed as its nickname and no special people', () => {
+ const dog = generateHireable('dog_1', 'k9', 'certified');
+ expect(dog.specialPeople).toEqual([]);
+ expect(dog.nickname.length).toBeGreaterThan(0);
+ });
+
+ it('gives every human listing exactly two special people', () => {
+ for (const role of ALL_ROLES.filter((r) => r !== 'k9')) {
+ const h = generateHireable(`sp_${role}`, role, 'seasoned');
+ expect(h.specialPeople).toHaveLength(2);
+ }
+ });
+
+ it('always writes an origin story with a hook and a body', () => {
+ const h = generateHireable('story_test', 'dealer', 'street');
+ expect(h.originStory.hook.length).toBeGreaterThan(0);
+ expect(h.originStory.body.length).toBeGreaterThan(0);
+ expect(h.originStory.cameUpOn.length).toBeGreaterThan(0);
+ });
+
+ it('sets baggage to either a string or null, never undefined', () => {
+ for (let i = 0; i < 20; i++) {
+ const h = generateHireable(`bag_${i}`, 'shooter', 'legend');
+ expect(h.baggage === null || typeof h.baggage === 'string').toBe(true);
+ }
+ });
+});
+
+describe('generateMemberBoard', () => {
+ it('returns the requested number of listings', () => {
+ expect(generateMemberBoard('seed_a', 14)).toHaveLength(14);
+ });
+
+ it('is deterministic for a given seed', () => {
+ expect(generateMemberBoard('seed_a', 8)).toEqual(generateMemberBoard('seed_a', 8));
+ });
+
+ it('produces a different board for a different seed', () => {
+ const a = generateMemberBoard('seed_a', 8);
+ const b = generateMemberBoard('seed_b', 8);
+ expect(a.map((h) => h.id)).not.toEqual(b.map((h) => h.id));
+ });
+
+ it('sorts cheapest first so players browse by affordability', () => {
+ const board = generateMemberBoard('sorted_seed', 14);
+ for (let i = 1; i < board.length; i++) {
+ expect(board[i].price).toBeGreaterThanOrEqual(board[i - 1].price);
+ }
+ });
+
+ it('gives every listing a unique id', () => {
+ const board = generateMemberBoard('unique_seed', 14);
+ expect(new Set(board.map((h) => h.id)).size).toBe(14);
+ });
+
+ it('returns an empty board for a zero count rather than throwing', () => {
+ expect(generateMemberBoard('empty_seed', 0)).toEqual([]);
+ });
+});
+
+describe('hireableToMemberPayload', () => {
+ it('carries level, loyalty, salary, and heat factor across', () => {
+ const h = generateHireable('conv_1', 'shooter', 'certified');
+ const p = hireableToMemberPayload(h, 'gang_1');
+ expect(p.level).toBe(h.level);
+ expect(p.loyalty).toBe(h.startingLoyalty);
+ expect(p.salary).toBe(h.salary);
+ expect(p.heatFactor).toBe(h.heatFactor);
+ });
+
+ it('namespaces the member id off the listing id', () => {
+ const h = generateHireable('conv_2', 'dealer', 'street');
+ expect(hireableToMemberPayload(h, 'gang_1').id).toBe(`m_${h.id}`);
+ });
+
+ it('starts a bought member active with a clean record', () => {
+ const h = generateHireable('conv_3', 'enforcer', 'seasoned');
+ const p = hireableToMemberPayload(h, 'gang_1');
+ expect(p.status).toBe('active');
+ expect(p.arrests).toBe(0);
+ expect(p.kills).toBe(0);
+ expect(p.health).toBe(100);
+ expect(p.inventory).toEqual([]);
+ });
+
+ it('starts morale at the listing loyalty so a disloyal hire is not also happy', () => {
+ const h = generateHireable('conv_4', 'shooter', 'legend');
+ expect(hireableToMemberPayload(h, 'gang_1').morale).toBe(h.startingLoyalty);
+ });
+
+ it('preserves the origin story and special people for bounty targeting', () => {
+ const h = generateHireable('conv_5', 'dealer', 'certified');
+ const p = hireableToMemberPayload(h, 'gang_1');
+ expect(p.originStory).toEqual(h.originStory);
+ expect(p.specialPeople).toEqual(h.specialPeople);
+ });
+
+ it('ages a K9 like a dog, not a person', () => {
+ const dog = generateHireable('conv_dog', 'k9', 'seasoned');
+ expect(hireableToMemberPayload(dog, 'gang_1').age).toBe(3);
+ });
+
+ it('maps listing stats onto the gang member stat block', () => {
+ const h = generateHireable('conv_6', 'driver', 'seasoned');
+ const p = hireableToMemberPayload(h, 'gang_1');
+ expect(p.stats.agility).toBe(h.stats.stealth);
+ expect(p.stats.strength).toBe(h.stats.nerve);
+ expect(p.stats.intelligence).toBe(h.stats.dealing);
+ });
+
+ it('attaches the member to the given gang', () => {
+ const h = generateHireable('conv_7', 'lookout', 'street');
+ expect(hireableToMemberPayload(h, 'gang_xyz').gangId).toBe('gang_xyz');
+ });
+});
diff --git a/frontend/src/utils/marketMembersCatalog.ts b/frontend/src/utils/marketMembersCatalog.ts
new file mode 100644
index 0000000..b9ef968
--- /dev/null
+++ b/frontend/src/utils/marketMembersCatalog.ts
@@ -0,0 +1,502 @@
+// ============================================================
+// marketMembersCatalog — hireable members on the underworld
+// Sprint 15-B
+//
+// Members bought here come pre-leveled. You're paying to skip the
+// grind, and the price reflects it. The higher tiers carry baggage:
+// a legend shooter draws police like a magnet, a certified dealer
+// with a habit skims. Every listing states the catch honestly —
+// the player's choice is whether the ceiling is worth the tax.
+//
+// Listings are generated rather than hardcoded so the board is
+// different every refresh, but the tier bands are fixed so pricing
+// stays legible.
+// ============================================================
+
+import type {
+ HireableMember,
+ HireableRole,
+ HireableTier,
+ MemberOriginStory,
+ SpecialPerson,
+ SpecialPersonRelation,
+} from '../types/game.types';
+
+// ─── Tier bands ──────────────────────────────────────────────
+
+interface TierBand {
+ levelRange: [number, number];
+ priceMultiplier: number;
+ loyaltyRange: [number, number];
+ heatFactorRange: [number, number];
+ statFloor: number;
+ statCeiling: number;
+ /** Odds this tier's listing carries a warning. */
+ baggageChance: number;
+ label: string;
+ blurb: string;
+}
+
+export const TIER_BANDS: Record = {
+ street: {
+ levelRange: [1, 2],
+ priceMultiplier: 1,
+ loyaltyRange: [55, 75],
+ heatFactorRange: [0.8, 1.0],
+ statFloor: 20,
+ statCeiling: 40,
+ baggageChance: 0.1,
+ label: 'STREET',
+ blurb: 'Fresh off the corner. Cheap, loyal enough, needs work.',
+ },
+ seasoned: {
+ levelRange: [3, 5],
+ priceMultiplier: 3.2,
+ loyaltyRange: [45, 65],
+ heatFactorRange: [1.0, 1.3],
+ statFloor: 40,
+ statCeiling: 62,
+ baggageChance: 0.3,
+ label: 'SEASONED',
+ blurb: 'Been around. Knows the work, knows their worth.',
+ },
+ certified: {
+ levelRange: [6, 8],
+ priceMultiplier: 8.5,
+ loyaltyRange: [35, 55],
+ heatFactorRange: [1.3, 1.7],
+ statFloor: 60,
+ statCeiling: 82,
+ baggageChance: 0.55,
+ label: 'CERTIFIED',
+ blurb: 'Proven. Expensive. Comes with a file at the precinct.',
+ },
+ legend: {
+ levelRange: [9, 12],
+ priceMultiplier: 22,
+ loyaltyRange: [25, 45],
+ heatFactorRange: [1.7, 2.4],
+ statFloor: 78,
+ statCeiling: 97,
+ baggageChance: 0.85,
+ label: 'LEGEND',
+ blurb: 'Everybody knows the name. Including the task force.',
+ },
+};
+
+// ─── Role base pricing & specialization ──────────────────────
+
+interface RoleProfile {
+ basePrice: number;
+ baseSalary: number;
+ label: string;
+ /** Which stat this role is strongest in. */
+ primary: 'shooting' | 'dealing' | 'nerve' | 'stealth';
+ description: string;
+}
+
+export const ROLE_PROFILES: Record = {
+ recruit: {
+ basePrice: 800,
+ baseSalary: 120,
+ label: 'RECRUIT',
+ primary: 'nerve',
+ description: 'Runs errands, watches blocks, levels into anything.',
+ },
+ dealer: {
+ basePrice: 2_200,
+ baseSalary: 260,
+ label: 'DEALER',
+ primary: 'dealing',
+ description: 'Moves product. Higher level means bigger deals.',
+ },
+ shooter: {
+ basePrice: 3_400,
+ baseSalary: 340,
+ label: 'SHOOTER',
+ primary: 'shooting',
+ description: 'Holds the block down. Fewer missed shots at level.',
+ },
+ enforcer: {
+ basePrice: 4_100,
+ baseSalary: 420,
+ label: 'ENFORCER',
+ primary: 'nerve',
+ description: 'Collects, intimidates, handles spies on the block.',
+ },
+ driver: {
+ basePrice: 2_600,
+ baseSalary: 240,
+ label: 'DRIVER',
+ primary: 'stealth',
+ description: 'Wheels for slides and getaways. Keeps the car clean.',
+ },
+ lookout: {
+ basePrice: 1_400,
+ baseSalary: 160,
+ label: 'LOOKOUT',
+ primary: 'stealth',
+ description: 'Calls out cops and opps before they turn the corner.',
+ },
+ k9: {
+ basePrice: 5_200,
+ baseSalary: 90,
+ label: 'K9',
+ primary: 'nerve',
+ description: 'Handles spies the way a person cannot. Cheap to keep.',
+ },
+};
+
+// ─── Name pools ──────────────────────────────────────────────
+
+const FIRST_NAMES = [
+ 'Marcus', 'Dre', 'Terrell', 'Jamal', 'Quan', 'Rico', 'Deshawn', 'Malik',
+ 'Tyrone', 'Cedric', 'Andre', 'Darnell', 'Kendrick', 'Rashad', 'Jerome',
+ 'Xavier', 'Trevon', 'Lamar', 'Devante', 'Corey', 'Damon', 'Isaiah',
+ 'Nia', 'Keisha', 'Shanice', 'Tamika', 'Brianna', 'Latoya', 'Jasmine',
+];
+
+const NICKNAMES = [
+ 'Smoke', 'Ghost', 'Trigger', 'Slim', 'Ace', 'Blue', 'Reaper', 'Cash',
+ 'Wolf', 'Zip', 'Cobra', 'Havoc', 'Pistol', 'Rook', 'Static', 'Grave',
+ 'Nickel', 'Deuce', 'Flip', 'Iron', 'Shadow', 'Bishop', 'Rebel', 'Vex',
+];
+
+const K9_NAMES = [
+ 'Zeus', 'Diesel', 'Killa', 'Tank', 'Brutus', 'Onyx', 'Rex', 'Bane',
+ 'Titan', 'Nitro', 'Rocco', 'Blitz', 'Havoc', 'Cain',
+];
+
+const K9_BREEDS = [
+ 'Pit Bull', 'Cane Corso', 'Rottweiler', 'Presa Canario', 'Dogo Argentino',
+ 'Belgian Malinois', 'American Bully',
+];
+
+const LAST_NAMES = [
+ 'Boyd', 'Waller', 'Crews', 'Sampson', 'Vance', 'Kearse', 'Dupree',
+ 'Malloy', 'Ridley', 'Stovall', 'Fenner', 'Broussard', 'Hines', 'Prather',
+];
+
+const NEIGHBORHOODS = [
+ 'Eastside', 'Northbound', 'The Bottom', 'Sixth Ward', 'Lakeview Courts',
+ 'Hillside', 'Southgate', 'The Flats', 'Riverbend', 'Kingsway',
+];
+
+// ─── Origin story fragments ──────────────────────────────────
+
+const CAME_UP_REASONS = [
+ 'Rent came due and nobody was hiring.',
+ 'Older brother did fifteen and left a spot open.',
+ 'Got tired of watching everybody else eat.',
+ 'Momma got sick and the bills did not care why.',
+ 'Took the fall for somebody once and figured out that loyalty pays.',
+ 'Never had a plan B, so plan A had to work.',
+ 'Came home from a bid and the block was different.',
+ 'Wanted the respect more than the money.',
+ 'Grew up two doors down from the trap and learned by watching.',
+ 'Lost somebody young and stopped being scared of much.',
+];
+
+const HOOKS = [
+ 'Does not talk much. Does not have to.',
+ 'Been on this block since before you claimed it.',
+ 'Knows every alley within ten blocks.',
+ 'Has never once been late to a job.',
+ 'Everybody on the strip owes them a favor.',
+ 'Reliable right up until they are not.',
+ 'Was somebody else problem last month.',
+ 'Came recommended, which should worry you.',
+ 'Not the fastest, but never panics.',
+ 'Been shot at enough to stop flinching.',
+];
+
+const K9_HOOKS = [
+ 'Raised on the block. Does not like strangers.',
+ 'Was a fighting dog. Retired into security work.',
+ 'Sleeps by the door and wakes up mean.',
+ 'Handled two spies last month without a sound.',
+ 'Trained on scent. Finds people who do not want finding.',
+];
+
+// ─── Baggage pools ───────────────────────────────────────────
+
+const BAGGAGE_BY_TIER: Record = {
+ street: [
+ 'Green. Will freeze the first time shots come back.',
+ 'Talks too much when nervous.',
+ ],
+ seasoned: [
+ 'Two prior arrests. Third one sticks longer.',
+ 'Skims a little. Nothing you would notice at first.',
+ 'Has beef on the eastside that will follow them here.',
+ ],
+ certified: [
+ 'Known to the task force. Draws heat faster than most.',
+ 'Owes money to people who are not patient.',
+ 'Been a suspect twice and walked both times. Somebody helped.',
+ 'Habit. Functional, but it costs.',
+ ],
+ legend: [
+ 'Named in an open federal file. Deploying them lights the block up.',
+ 'Four arrests already. One more and they are gone for good.',
+ 'Everybody wants them dead, which means everybody watches your block.',
+ 'Flipped on a crew once. Says it was different circumstances.',
+ 'Cannot be told anything. Will do it their way regardless.',
+ ],
+};
+
+// ─── Special people ──────────────────────────────────────────
+
+const RELATIONS: SpecialPersonRelation[] = [
+ 'mother', 'father', 'brother', 'sister', 'son', 'daughter',
+ 'girlfriend', 'boyfriend', 'cousin', 'best_friend', 'grandmother', 'auntie',
+];
+
+export const RELATION_LABELS: Record = {
+ mother: 'Mother',
+ father: 'Father',
+ brother: 'Brother',
+ sister: 'Sister',
+ son: 'Son',
+ daughter: 'Daughter',
+ girlfriend: 'Girlfriend',
+ boyfriend: 'Boyfriend',
+ cousin: 'Cousin',
+ best_friend: 'Best Friend',
+ grandmother: 'Grandmother',
+ auntie: 'Auntie',
+};
+
+// ─── Deterministic RNG ───────────────────────────────────────
+//
+// Seeded so a given listing id always regenerates identically. The board
+// can be rebuilt from ids alone without persisting every field.
+
+function makeRng(seed: number): () => number {
+ let s = seed >>> 0;
+ return () => {
+ s = (s * 1664525 + 1013904223) >>> 0;
+ return s / 0x100000000;
+ };
+}
+
+function hashString(str: string): number {
+ let h = 2166136261;
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ return h >>> 0;
+}
+
+function pick(rng: () => number, arr: readonly T[]): T {
+ return arr[Math.floor(rng() * arr.length)];
+}
+
+function intBetween(rng: () => number, min: number, max: number): number {
+ return Math.floor(rng() * (max - min + 1)) + min;
+}
+
+function floatBetween(rng: () => number, min: number, max: number): number {
+ return rng() * (max - min) + min;
+}
+
+// ─── Generators ──────────────────────────────────────────────
+
+/** Two special people per member. Never duplicate relations. */
+export function generateSpecialPeople(rng: () => number, memberId: string): SpecialPerson[] {
+ const used = new Set();
+ const people: SpecialPerson[] = [];
+ while (people.length < 2) {
+ const relation = pick(rng, RELATIONS);
+ if (used.has(relation)) continue;
+ used.add(relation);
+ people.push({
+ id: `${memberId}_sp${people.length + 1}`,
+ name: `${pick(rng, FIRST_NAMES)} ${pick(rng, LAST_NAMES)}`,
+ relation,
+ status: 'safe',
+ neighborhood: pick(rng, NEIGHBORHOODS),
+ });
+ }
+ return people;
+}
+
+function generateOriginStory(
+ rng: () => number,
+ role: HireableRole,
+ tier: HireableTier,
+ cameUpOn: string,
+): MemberOriginStory {
+ const reason = pick(rng, CAME_UP_REASONS);
+ const hook = role === 'k9' ? pick(rng, K9_HOOKS) : pick(rng, HOOKS);
+ const roleWord = ROLE_PROFILES[role].label.toLowerCase();
+
+ const body =
+ role === 'k9'
+ ? `Came off ${cameUpOn}. ${hook} Handled by whoever feeds them, loyal to whoever does it consistently.`
+ : `Came up on ${cameUpOn}. ${reason} Worked their way into ${roleWord} work and stuck with it. ` +
+ `${TIER_BANDS[tier].blurb}`;
+
+ return { hook, body, cameUpOn, reason };
+}
+
+function generateStats(
+ rng: () => number,
+ role: HireableRole,
+ tier: HireableTier,
+): HireableMember['stats'] {
+ const band = TIER_BANDS[tier];
+ const profile = ROLE_PROFILES[role];
+ const base = () => intBetween(rng, band.statFloor, band.statCeiling);
+ const stats = {
+ shooting: base(),
+ dealing: base(),
+ nerve: base(),
+ stealth: base(),
+ };
+ // Primary stat gets a bump, clamped to 100.
+ stats[profile.primary] = Math.min(100, stats[profile.primary] + intBetween(rng, 8, 18));
+ return stats;
+}
+
+/**
+ * Build one listing from a stable id.
+ *
+ * Price scales on tier multiplier and level within the band, so a level 8
+ * certified shooter costs meaningfully more than a level 6 one.
+ */
+export function generateHireable(
+ id: string,
+ role: HireableRole,
+ tier: HireableTier,
+): HireableMember {
+ const rng = makeRng(hashString(id));
+ const band = TIER_BANDS[tier];
+ const profile = ROLE_PROFILES[role];
+
+ const level = intBetween(rng, band.levelRange[0], band.levelRange[1]);
+ const levelPremium = 1 + (level - band.levelRange[0]) * 0.22;
+ const price = Math.round(
+ (profile.basePrice * band.priceMultiplier * levelPremium) / 50,
+ ) * 50;
+
+ const salary = Math.round((profile.baseSalary * (1 + (level - 1) * 0.15)) / 10) * 10;
+ const cameUpOn = pick(rng, NEIGHBORHOODS);
+
+ const isK9 = role === 'k9';
+ const name = isK9
+ ? pick(rng, K9_NAMES)
+ : `${pick(rng, FIRST_NAMES)} ${pick(rng, LAST_NAMES)}`;
+ const nickname = isK9 ? pick(rng, K9_BREEDS) : pick(rng, NICKNAMES);
+
+ const hasBaggage = rng() < band.baggageChance;
+ const baggage = hasBaggage ? pick(rng, BAGGAGE_BY_TIER[tier]) : null;
+
+ return {
+ id,
+ name,
+ nickname,
+ role,
+ tier,
+ level,
+ price,
+ salary,
+ startingLoyalty: intBetween(rng, band.loyaltyRange[0], band.loyaltyRange[1]),
+ heatFactor: Number(
+ floatBetween(rng, band.heatFactorRange[0], band.heatFactorRange[1]).toFixed(2),
+ ),
+ stats: generateStats(rng, role, tier),
+ originStory: generateOriginStory(rng, role, tier, cameUpOn),
+ // Dogs do not have special people the way a person does.
+ specialPeople: isK9 ? [] : generateSpecialPeople(rng, id),
+ baggage,
+ };
+}
+
+/** Roles that appear on the board, weighted toward what players actually need. */
+const BOARD_ROLES: HireableRole[] = [
+ 'shooter', 'shooter', 'dealer', 'dealer', 'enforcer',
+ 'driver', 'lookout', 'recruit', 'k9',
+];
+
+const BOARD_TIERS: HireableTier[] = [
+ 'street', 'street', 'seasoned', 'seasoned', 'seasoned',
+ 'certified', 'certified', 'legend',
+];
+
+/**
+ * Generate a full board.
+ *
+ * `refreshSeed` should change whenever the player refreshes the market, so the
+ * same seed always rebuilds the same board — important for the buy flow, which
+ * needs the listing to still exist after a re-render.
+ */
+export function generateMemberBoard(
+ refreshSeed: string,
+ count: number = 12,
+): HireableMember[] {
+ const rng = makeRng(hashString(refreshSeed));
+ const listings: HireableMember[] = [];
+ for (let i = 0; i < count; i++) {
+ const role = pick(rng, BOARD_ROLES);
+ const tier = pick(rng, BOARD_TIERS);
+ listings.push(generateHireable(`${refreshSeed}_${i}`, role, tier));
+ }
+ // Cheapest first — players browse by what they can afford.
+ return listings.sort((a, b) => a.price - b.price);
+}
+
+// ─── Conversion to a real gang member ────────────────────────
+
+/**
+ * Shape a listing into the payload `useGangStore().addMember` expects.
+ *
+ * Bought members keep their level and stats but start with the listing's
+ * loyalty rather than the default, and carry `heatFactor` forward so the
+ * heat system can tax them appropriately.
+ */
+export function hireableToMemberPayload(h: HireableMember, gangId: string) {
+ return {
+ id: `m_${h.id}`,
+ gangId,
+ name: h.name,
+ nickname: h.nickname,
+ avatarUrl: '',
+ backstory: h.originStory.body,
+ age: h.role === 'k9' ? 3 : 19 + h.level,
+ region: 'east' as const,
+ stats: {
+ strength: h.stats.nerve,
+ agility: h.stats.stealth,
+ intelligence: h.stats.dealing,
+ charisma: h.stats.dealing,
+ luck: 50,
+ intimidation: h.stats.nerve,
+ },
+ level: h.level,
+ experience: 0,
+ xp: 0,
+ skillPoints: 0,
+ skills: [],
+ loyalty: h.startingLoyalty,
+ morale: h.startingLoyalty,
+ respect: h.level * 8,
+ kills: 0,
+ arrests: 0,
+ dealsCompleted: 0,
+ moneyEarned: 0,
+ status: 'active' as const,
+ currentAssignment: null,
+ joinedAt: new Date().toISOString(),
+ hiredAt: new Date().toISOString(),
+ role: h.role,
+ health: 100,
+ maxHealth: 100,
+ inventory: [],
+ salary: h.salary,
+ heatFactor: h.heatFactor,
+ specialPeople: h.specialPeople,
+ originStory: h.originStory,
+ };
+}
diff --git a/scripts/strip_market_icons.py b/scripts/strip_market_icons.py
new file mode 100644
index 0000000..7ea20dd
--- /dev/null
+++ b/scripts/strip_market_icons.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+"""Strip the now-unused emoji `icon` field from Market.tsx's ITEM_CATALOG.
+
+The UI no longer renders `item.icon` — it uses CATEGORY_TAGS text labels — so
+the field is dead weight that also trips the repo's no-emoji rule. This removes
+the field from every catalog entry and from the CatalogItem interface.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+TARGET = Path("frontend/src/components/economy/Market.tsx")
+
+
+def main() -> int:
+ path = Path(__file__).resolve().parents[1] / TARGET
+ src = path.read_text(encoding="utf-8")
+
+ # Drop `, icon: ''` from each catalog literal.
+ cleaned, n_items = re.subn(r",\s*icon:\s*'[^']*'", "", src)
+
+ # Drop the interface field declaration.
+ cleaned, n_iface = re.subn(r"\n\s*icon:\s*string;(?=\n\})", "", cleaned, count=1)
+
+ if n_items == 0:
+ print("No catalog icon fields found — nothing to do.", file=sys.stderr)
+ return 1
+
+ path.write_text(cleaned, encoding="utf-8")
+ print(f"Removed {n_items} catalog icon fields and {n_iface} interface field(s).")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From 98eeca9379455a3333f71ecac0e208294ba0ba8e Mon Sep 17 00:00:00 2001
From: BrandDead <1.74973198e+08+BrandDead@users.noreply.github.com>
Date: Thu, 6 Aug 2026 22:48:22 +0000
Subject: [PATCH 2/2] fix(pr97-scope): rebase onto main, resolve conflicts, fix
P0 issues
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rebase onto main-tL2525 (post #98/#101/#96 merges):
- OSShell.tsx conflict: keep CLOUT label from #97 + shot-caller
artwork from #96 (GPT audit recommendation)
- App.tsx: auto-merged cleanly
P0 fixes:
- Remove CLAUDE_SPRINT_15A_PROMPT.md and SPRINT_15B_NOTES.md
(internal sprint artifacts should not be in product PRs)
- marketMembersCatalog.ts: fix invalid CityRegion 'east' -> 'miami'
- MarketMembers.tsx: replace hardcoded roster cap 24 with
maxMembers from useGangStore (store cap is 20)
- k9/recruit/police are now in MemberRole union (from #98 merge),
so no 'as MemberRole' cast is needed here
581 vitest tests pass (26 test files — 3 new from #97).
---
CLAUDE_SPRINT_15A_PROMPT.md | 188 ------------------
SPRINT_15B_NOTES.md | 71 -------
.../src/components/economy/MarketMembers.tsx | 4 +-
frontend/src/utils/marketMembersCatalog.ts | 2 +-
4 files changed, 3 insertions(+), 262 deletions(-)
delete mode 100644 CLAUDE_SPRINT_15A_PROMPT.md
delete mode 100644 SPRINT_15B_NOTES.md
diff --git a/CLAUDE_SPRINT_15A_PROMPT.md b/CLAUDE_SPRINT_15A_PROMPT.md
deleted file mode 100644
index 605c15b..0000000
--- a/CLAUDE_SPRINT_15A_PROMPT.md
+++ /dev/null
@@ -1,188 +0,0 @@
-# Claude / Opus 5 — SLIDE Sprint 15-A
-
-## Your Assignment
-
-You are working on **BrandDead/slide**, a React + TypeScript + Zustand mobile game. Two priorities are yours. Manus is working in parallel on the Underworld Marketplace, Most Wanted bounty system, and Get Back clock — **do not touch those files** (listed under "Hands Off" below).
-
-Branch from `main-tL2525`. Name your branch `feat/sprint-15a-engine-rewiring`. Open a PR against `main-tL2525` when tests are green. You cannot merge — Manus will verify and merge.
-
----
-
-## Context: What This Game Is
-
-SLIDE is a gang-management game built as an iOS-style desktop ("OSShell") of app icons. Each icon opens a mini-game or management screen. All mini-games feed one shared economy: money, heat, morale, gang member progression, and block territory control.
-
-The player claims a real-world city block by address, deploys gang members onto a grid representing that block, and:
-- **Dealers** near the street earn more but are easier to kill in drive-bys
-- **Shooters** defend the block and return fire when opps "slide" (drive-by)
-- **Heat** rises the longer high-level members stay outside; high heat triggers police raids
-- **Morale** drops if the player abandons jailed or hospitalized members
-
-Key stores (all in `frontend/src/stores/`):
-- `gameStore.ts` — `usePlayerStore`, `useGangStore`, `useEconomyStore`, `useNotificationStore`, `useMoraleStore`, `useNavigationStore`, `useTerritoryStore`
-- `blockStore.ts` — `useBlockStore`: blocks keyed by id, each with `placements: BlockPlacement[]`, `heat`, `selectedBlockId`
-
----
-
-## PRIORITY 1 — Wire the Broken Attack Chain
-
-Three connections are broken between `AttackPlanner` → `TopDownShooter` → the shared economy.
-
-### 1A. Pass planner context into TopDownShooter
-
-**File:** `frontend/src/components/missions/AttackPlanner.tsx`
-
-Currently `handleLaunch()` just calls `navigateTo('topdown')` and throws away everything the player configured. Fix this.
-
-Create a new store slice (put it in `frontend/src/stores/attackPlanStore.ts`) that holds the pending attack plan:
-
-```ts
-export interface AttackPlan {
- targetBlockId: string;
- targetAddress: string;
- roles: {
- scout: string | null; // memberId
- enforcer: string | null; // memberId
- driver: string | null; // memberId
- };
- /** Extra attackers beyond the three named roles. */
- additionalMemberIds: string[];
- /** Intel gathered by the scout, null when no scout assigned. */
- intel: AttackIntel | null;
- createdAt: number;
-}
-
-export interface AttackIntel {
- defenderCount: number;
- defenderRoles: string[];
- blockHeat: number;
- policePresence: 'none' | 'light' | 'moderate' | 'heavy';
- /** Known positions, only populated when the scout has high enough level. */
- knownPositions: Array<{ memberId: string; x: number; y: number }>;
-}
-```
-
-`AttackPlanner` writes the plan via `setPlan()` then navigates. `TopDownShooter` reads it via `usePlan()` on mount and clears it on unmount.
-
-**Intel quality must scale with scout level.** A level 1 scout returns only `defenderCount`. Level 3+ returns `defenderRoles` and `blockHeat`. Level 5+ returns `knownPositions` (actual grid coordinates of deployed defenders). Read the scout's level off the `GangMember` record.
-
-If no scout is assigned, `intel` is `null` and TopDownShooter must show the grid with defender positions **hidden** until they fire or are spotted.
-
-### 1B. TopDownShooter reads real defenders from blockStore
-
-**File:** `frontend/src/components/topdown/TopDownShooter.tsx`
-
-Right now `TopDownShooter` generates hardcoded NPC defenders. Replace this.
-
-- Read `useBlockStore().blocks[plan.targetBlockId]`
-- Map each `BlockPlacement` on that block into a `CombatUnit` with `team: 'defender'`
-- Pull each defender's real stats from their `GangMember` record: `level`, `accuracy`, `hp`, and their equipped weapon out of `member.inventory`
-- Defender grid position must come from the placement's actual `x`/`y` on the block, not a random spawn
-- When the block is NPC-owned (no real player behind it), fall back to generating defenders from the NPC store (`useNPCStore`) so the game still works against AI rivals
-- Attacker units come from `plan.roles` + `plan.additionalMemberIds`, using each member's real stats and equipped weapon
-
-Preserve the existing `takeover` mode, action-point system, and cover mechanics — you are swapping the data source, not rewriting the combat loop.
-
-### 1C. Non-cash loot flows into the Trap
-
-**File:** `frontend/src/components/topdown/TopDownShooter.tsx` (results phase)
-
-Currently only `moneyFromLoot` is applied via `updateMoney()`. Drugs and weapons looted off downed defenders are displayed in the results screen and then discarded.
-
-Create `frontend/src/components/topdown/topDownRewards.ts` following the pattern already established in `frontend/src/components/topdown/bipNDipRewards.ts` and `frontend/src/components/raid/policeRaidRewards.ts`. It must:
-
-- Send looted drugs and weapons to `useEconomyStore().addInventoryItem()` (this is the Trap stash)
-- Keep cash going to `updateMoney()`
-- Fence jewelry/valuables at a below-face-value rate for cash, matching how `bipNDipRewards.ts` already handles goods with no inventory slot
-- Apply heat, XP, and morale changes
-- Return a summary object the results screen renders
-
-Also: when a defender is killed, if they were a **real player's member**, that member must be marked dead in the owning player's roster. Look at how `blockStore.applyDriveByCasualties` already handles casualty propagation and follow the same path.
-
----
-
-## PRIORITY 3 — Claim Hit → Opp Contact → Get Back Mission
-
-This is the social/retaliation loop. It is the mechanic that turns a drive-by from a solo activity into a rivalry.
-
-### 3A. Claim the hit
-
-**File:** `frontend/src/components/slide/SlideGame.tsx`
-
-After a successful slide (attacker caused at least one casualty and retreated or won), show a **CLAIM IT** button on the results screen alongside the existing stats.
-
-Claiming is optional and it is a tradeoff, which is the whole point:
-- **Claiming** gives your gang a morale boost and gives the attacking members individual morale + XP. But it reveals your identity to the defender, who can now retaliate.
-- **Not claiming** keeps you anonymous. The defender only sees "someone from [your block] slid on you" — they have to ask around to find out who.
-
-When the player claims:
-1. A trash-talk text is composed and sent to the defending player
-2. The player's gang is saved into the defender's contact book with `status: 'opp'`
-3. The defender's gang is saved into the attacker's contact book with `status: 'opp'`
-4. Attacking gang morale increases; each member present gains individual morale and XP
-5. A **Get Back** window opens for the defender (see 3B)
-
-Let the player pick from generated trash-talk lines rather than typing free text — this keeps content moderated and is faster on mobile. Generate 4–5 options based on the outcome (how many casualties, whether the car escaped clean, whether the block was swept).
-
-**File to create:** `frontend/src/utils/claimHitEngine.ts` — all the rules, no React. Pure functions covering: whether a hit is claimable, generating trash-talk options, computing morale deltas for both sides, and building the contact records.
-
-### 3B. Get Back clock
-
-**Coordinate with Manus here.** Manus is building the Get Back clock UI component and the store slice (`frontend/src/stores/getBackStore.ts`) as part of the Most Wanted work, because the same clock powers bounty fulfilment windows.
-
-Your job is the **trigger and resolution path from the slide side**:
-- When a hit is claimed, call `useGetBackStore().openWindow({ ... })` to start the clock for the defending player
-- When the defender successfully retaliates against any member who was present on the original slide, call `useGetBackStore().resolveWindow(windowId, 'success')`
-- Getting back within the window awards **double morale** to the retaliating gang, and the original attacking gang **loses** that same amount plus the morale they originally gained from claiming
-- If the window expires, call `resolveWindow(windowId, 'expired')` — the defending gang takes an additional morale hit for letting it slide
-
-Manus will have the store interface committed before you need it. If it is not on `main-tL2525` yet when you get here, define the calls against the interface above and leave a `TODO(manus-integration)` comment — Manus will reconcile during merge.
-
-### 3C. Retaliation mission
-
-When a Get Back window is open, the defending player gets a mission surfaced in `Missions`: catch any of the members who were present on the slide. Mark those members as "wanted" for the duration of the window. If the defender attacks a block where one of those members is deployed, the Get Back resolves as a success.
-
----
-
-## Hands Off — Manus Is Working Here
-
-Do not modify these files. Merge conflicts here will cost us a full round trip.
-
-```
-frontend/src/components/economy/Market.tsx
-frontend/src/components/economy/MarketMembers.tsx
-frontend/src/components/economy/MostWanted.tsx
-frontend/src/components/economy/MostWantedPoster.tsx
-frontend/src/stores/mostWantedStore.ts
-frontend/src/stores/getBackStore.ts
-frontend/src/utils/marketMembersCatalog.ts
-frontend/src/components/common/GetBackClock.tsx
-frontend/src/components/layout/OSShell.tsx
-```
-
-You **may** read them for reference. You **may** call into `getBackStore.ts` per 3B.
-
----
-
-## Standards
-
-Follow what is already in the repo — it is consistent and you should match it.
-
-- **Rules live in engines, rendering lives in components.** Every mini-game already splits this way: `slideGameEngine.ts` / `SlideGame.tsx`, `bipNDipEngine.ts` / `BipNDipGame.tsx`, `policeRaidEngine.ts` / `PoliceRaidGame.tsx`. Keep it.
-- **No emoji in any UI string.** We just finished purging them. Use text labels or icon images from `GameSprite`. This is a hard rule.
-- **Tests are required.** `vitest`, colocated in `__tests__/` next to the file under test. The suite is at 430 passing and must stay green. Test the engines thoroughly — they are pure functions and there is no excuse not to.
-- **Comment the why, not the what.** The existing engine files are a good model: they explain design decisions and edge cases, not line-by-line narration.
-- **Types go in `frontend/src/types/game.types.ts`** unless they are local to one engine.
-- Run `npx vitest run` before opening the PR. If `vitest` complains about the Vite version, the repo is pinned to `vitest@2.x` on purpose — do not upgrade it.
-
-## Definition of Done
-
-- `npx vitest run` green, with new tests covering the new engine logic
-- AttackPlanner → TopDownShooter passes target block, roles, and level-scaled intel
-- TopDownShooter fights real deployed defenders from blockStore, falls back to NPC store for AI blocks
-- Looted drugs and weapons land in the Trap; jewelry fences to cash; casualties propagate to the owning roster
-- CLAIM IT works on the slide results screen with generated trash-talk options and correct two-sided morale
-- Both gangs save each other as opps in contacts
-- Get Back window opens on claim and resolves on retaliation or expiry
-- Retaliation mission surfaces in Missions while the window is open
-- PR open against `main-tL2525` with a description covering what changed and anything you had to stub
diff --git a/SPRINT_15B_NOTES.md b/SPRINT_15B_NOTES.md
deleted file mode 100644
index 9c5969a..0000000
--- a/SPRINT_15B_NOTES.md
+++ /dev/null
@@ -1,71 +0,0 @@
-# Sprint 15-B Working Notes (Manus half)
-
-Branch: `feat/marketplace-mostwanted-getback` off `main-tL2525`
-Repo: `/home/ubuntu/slide` (BrandDead/slide)
-Claude prompt written to: `/home/ubuntu/slide/CLAUDE_SPRINT_15A_PROMPT.md`
-
-## Work split
-- **Claude/Opus 5 (Sprint 15-A)**: Priority 1 (AttackPlanner→TopDownShooter wiring, real defenders from blockStore, loot→Trap) + Priority 3 (CLAIM IT flow, opp contacts, retaliation mission). Branch `feat/sprint-15a-engine-rewiring`.
-- **Manus (Sprint 15-B)**: Priority 2 (Market MEMBERS tab, dogs/K9) + Priority 4 (Most Wanted board, Get Back clock, allies).
-
-## Files DONE so far (Manus)
-1. `frontend/src/types/game.types.ts` — APPENDED new types:
- - `SpecialPerson`, `SpecialPersonRelation`, `SpecialPersonStatus`, `MemberOriginStory`
- - `HireableMember`, `HireableRole`, `HireableTier`
- - `MostWantedPoster`, `BountyStatus`, `BountyTargetKind`
- - `GetBackWindow`, `GetBackOutcome`, `GetBackTrigger`
- - `Ally`
-2. `frontend/src/stores/getBackStore.ts` — NEW. Get Back clock store.
- - Exports: `useGetBackStore`, `GET_BACK_CONFIG`, `remainingMs`, `remainingFraction`, `isLapsed`, `urgency`, `formatClock`, `moraleSwing`, `isValidRevengeTarget`
- - Store methods: `openWindow`, `resolveWindow`, `registerCatch`, `sweepExpired`, `activeWindows`, `mostUrgentWindow`, `isMemberWanted`, `allWantedMemberIds`, `pruneHistory`, `clearAll`
- - Wall-clock based (openedAt + durationMs), NOT tick based
- - SUCCESS_MULTIPLIER = 2; EXPIRY_MORALE_PENALTY = 12
-3. `frontend/src/stores/mostWantedStore.ts` — NEW. Bounty board + ally ledger.
- - Exports: `useMostWantedStore`, `BOUNTY_CONFIG`, `listingFee`, `totalPostingCost`, `minimumReward`, `isExpired`, `remainingMs`, `formatRemaining`, `canFulfil`, `bumpStanding`
- - Store methods: `postBounty`, `cancelBounty`, `fulfilBounty`, `sweepExpired`, `openPosters`, `postersBy`, `postersAgainstGang`, `bountyOnTarget`, `isTargetWanted`, `addAlly`, `recordAllyJob`, `getAlly`, `pruneHistory`, `clearAll`
- - `fulfilBounty` returns `PayoutReceipt` for caller to debit/credit/log
-4. `frontend/src/utils/marketMembersCatalog.ts` — NEW. Hireable member generator.
- - Exports: `TIER_BANDS`, `ROLE_PROFILES`, `RELATION_LABELS`, `generateSpecialPeople`, `generateHireable`, `generateMemberBoard`, `hireableToMemberPayload`
- - Tiers: street / seasoned / certified / legend (price mult 1 / 3.2 / 8.5 / 22)
- - Roles: recruit, dealer, shooter, enforcer, driver, lookout, k9
- - Seeded deterministic RNG so listing ids regenerate identically
-
-5. DONE `frontend/src/components/economy/MarketMembers.tsx` + `.css` — MEMBERS tab UI
-6. DONE `frontend/src/components/economy/MostWanted.tsx` + `.css` — bounty board UI
-7. DONE `frontend/src/components/economy/MostWantedApp.tsx` + `.css` — route wrapper
-8. DONE `frontend/src/components/common/GetBackClock.tsx` + `.css` — NBA shot clock HUD
-9. DONE Market.tsx: added `members` virtual category tab rendering MarketMembers; purged ALL emoji (removed dead `icon` field from ITEM_CATALOG via `scripts/strip_market_icons.py`, replaced with CATEGORY_TAGS text labels)
-10. DONE App.tsx: lazy `MostWantedApp` + `case 'most_wanted'` route + global ` ` overlay
-11. DONE OSShell.tsx: `RANKINGS` → `CLOUT` (desc 'Who Runs What'); added `most_wanted` app 'WANTED' with live open-bounty badge
-12. DONE `frontend/src/stores/__tests__/getBackStore.test.ts`
-
-## Files STILL TO DO (Manus)
-13. Tests: `__tests__/mostWantedStore.test.ts`, `__tests__/marketMembersCatalog.test.ts`
-14. Run `npx vitest run` (must stay green, was 430 passing), commit, PR, merge
-
-## Key repo facts
-- Test runner: `npx vitest run` from `/home/ubuntu/slide/frontend`. Pinned to vitest 2.x (Vite 5 compat) — DO NOT upgrade.
-- Baseline before this sprint: 430 tests passing, 22 files.
-- HARD RULE: no emoji in any UI string. Use text labels or `GameSprite` icons.
-- Pattern: rules in `utils/*Engine.ts` or store, rendering in components.
-- `useEconomyStore().inventory` IS the Trap stash. `addInventoryItem` / `transferItemToMember`.
-- `useGangStore()`: `members`, `addMember`, `updateMember`, `removeMember`, `backdoorMember`, `jailMember`, `releaseMember`, `killMember`, `contacts`, `addContact`
-- `usePlayerStore()`: `player` (money, heat, level, xp, gangName, bankBalance), `updateMoney`, `updateHeat`, `addXP`, `updatePlayer`
-- `useBlockStore()`: `blocks` (keyed obj), `selectedBlockId`, `placements` per block
-- Market.tsx existing pattern: `ITEM_CATALOG` array + `CATEGORIES` tuple + `CATEGORY_LABELS` record; `handlePurchase` calls `updateMoney(-total)` then `addInventoryItem({...})` then `addTransaction({...})`
-- Market.tsx currently HAS emoji in `ITEM_CATALOG[].icon` and `CATEGORY_LABELS` — needs purging when I touch it.
-
-## Open GitHub issues (unchanged)
-#77 2.5D visual stack | #78 wire assetManifest into renderers | #79 asset chroma-key cleanup | #80 Block DNA library | #81 NPC rival crew v1 | #45 beta gate
-
-## User design decisions captured this session
-- Members bought on underworld are pre-leveled / higher skilled — YES, implemented via tier bands.
-- Players can order hits on special people of any member known to them from an opposing gang.
-- Most Wanted section: post a picture of target + reward amount. Other players fulfil for quick money.
-- Proof flow: player screenshots the confirmed win (mobile: Cmd+Shift+3 mentioned), uploads to the poster, cashes in.
-- Poster receives inbox message with the pic + "$X was paid to [player]".
-- Shoebox shows a transaction debiting $X paid to the fulfiller's name.
-- Fulfiller is added to poster's contact book as an ALLY.
-- Who cashed in is PUBLIC — can start wars.
-- Get Back clock: NBA shot-clock style, X time to retaliate for a lost member. Success = double morale + skill.
-- Rankings app: keep separate from News. Rename label to CLOUT.
diff --git a/frontend/src/components/economy/MarketMembers.tsx b/frontend/src/components/economy/MarketMembers.tsx
index 9aeaf8f..de884a6 100644
--- a/frontend/src/components/economy/MarketMembers.tsx
+++ b/frontend/src/components/economy/MarketMembers.tsx
@@ -64,7 +64,7 @@ const REFRESH_COST = 250;
const MarketMembers: React.FC = () => {
const { player, updateMoney } = usePlayerStore();
- const { members, addMember } = useGangStore();
+ const { members, addMember, maxMembers } = useGangStore();
const { addTransaction } = useEconomyStore();
const { addNotification } = useNotificationStore();
@@ -108,7 +108,7 @@ const MarketMembers: React.FC = () => {
// Roster cap keeps the weekly payroll from becoming unmanageable.
const activeCount = members.filter((m) => m.status === 'active').length;
- if (activeCount >= 24) {
+ if (activeCount >= maxMembers) {
setResult('ROSTER FULL — BACKDOOR SOMEBODY FIRST');
setTimeout(() => setResult(''), 2200);
return;
diff --git a/frontend/src/utils/marketMembersCatalog.ts b/frontend/src/utils/marketMembersCatalog.ts
index b9ef968..13c2cec 100644
--- a/frontend/src/utils/marketMembersCatalog.ts
+++ b/frontend/src/utils/marketMembersCatalog.ts
@@ -465,7 +465,7 @@ export function hireableToMemberPayload(h: HireableMember, gangId: string) {
avatarUrl: '',
backstory: h.originStory.body,
age: h.role === 'k9' ? 3 : 19 + h.level,
- region: 'east' as const,
+ region: 'miami' as const, // CityRegion — 'east' is not a valid value
stats: {
strength: h.stats.nerve,
agility: h.stats.stealth,