From 23852939470d7e4e98f2c56198fd6bf3c8cd926c Mon Sep 17 00:00:00 2001 From: BrandDead <1.74973198e+08+BrandDead@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:13:59 +0000 Subject: [PATCH 1/2] feat(sprint-15b): Market members, Most Wanted board, Get Back clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Priority 2 and Priority 4 from the app audit. Priority 1 and 3 are delegated to Claude/Opus 5 via CLAUDE_SPRINT_15A_PROMPT.md. Market MEMBERS tab (Priority 2) - marketMembersCatalog.ts: procedural hireable generator. Four tiers (street/seasoned/certified/legend) with monotonic pricing, and an inverse relationship between tier and loyalty — the good ones cost more and hold worse. Heat factor and baggage odds both rise with tier, so a legend shooter is a genuine tradeoff rather than a straight upgrade. Seven roles including k9, which is cheap to keep and has no special people. - Listings are seeded off their id so a board survives re-renders mid-purchase and can be rebuilt without persisting every field. - MarketMembers.tsx/.css: listing wall plus detail sheet showing stats, origin story, terms, special people, and the catch. Board refresh costs money so it cannot be rerolled for free. Roster capped at 24 active to keep payroll bounded. Most Wanted board (Priority 4) - mostWantedStore.ts: post, cancel, and fulfil bounties plus an ally ledger. Listing fee is non-refundable on cancel, minimum reward scales with target level, and contracts on a member's people carry a premium. canFulfil blocks self-cashing and double payouts. - MostWanted.tsx/.css + MostWantedApp.tsx/.css: aged-paper poster wall, post form with photo upload, proof-screenshot upload that pays out automatically, and an allies tab with standing meters. Fulfilling a contract saves the poster as an ally and opens a Get Back window against the hunter — the war starter. - Proof and payout are public record by design; reputation does the policing rather than a server-side referee. Get Back clock (Priority 4) - getBackStore.ts: wall-clock-derived revenge windows so backgrounding the app cannot desync them. Success doubles the morale the offender banked and strips it from them; expiry penalises the retaliator. Resolution is idempotent so morale can never be applied twice. - GetBackClock.tsx/.css: shot-clock badge above the dock with an expandable panel listing open debts and who is catchable. Sweeps lapsed windows on its own tick so penalties land even if the panel is never opened. Gang-wide morale is applied per active member, since morale lives on members not on the gang. Wiring and cleanup - OSShell: RANKINGS renamed to CLOUT; new WANTED app with a live open-bounty badge. - App.tsx: most_wanted route plus GetBackClock as a global overlay. - Market.tsx: emoji purge. Removed the dead `icon` field from ITEM_CATALOG (scripts/strip_market_icons.py) since the UI now uses CATEGORY_TAGS text labels. Tests: 565 passing, up from 430. Typecheck clean except three errors that pre-date this branch on main-tL2525. Production build succeeds. --- CLAUDE_SPRINT_15A_PROMPT.md | 188 +++++ SPRINT_15B_NOTES.md | 71 ++ frontend/src/App.tsx | 6 + .../src/components/common/GetBackClock.css | 281 +++++++ .../src/components/common/GetBackClock.tsx | 260 ++++++ frontend/src/components/economy/Market.tsx | 92 ++- .../src/components/economy/MarketMembers.css | 475 +++++++++++ .../src/components/economy/MarketMembers.tsx | 362 +++++++++ .../src/components/economy/MostWanted.css | 672 +++++++++++++++ .../src/components/economy/MostWanted.tsx | 766 ++++++++++++++++++ .../src/components/economy/MostWantedApp.css | 56 ++ .../src/components/economy/MostWantedApp.tsx | 43 + frontend/src/components/layout/OSShell.tsx | 27 +- .../src/stores/__tests__/getBackStore.test.ts | 365 +++++++++ .../stores/__tests__/mostWantedStore.test.ts | 451 +++++++++++ frontend/src/stores/getBackStore.ts | 294 +++++++ frontend/src/stores/mostWantedStore.ts | 368 +++++++++ frontend/src/types/game.types.ts | 223 +++++ .../__tests__/marketMembersCatalog.test.ts | 341 ++++++++ frontend/src/utils/marketMembersCatalog.ts | 502 ++++++++++++ scripts/strip_market_icons.py | 36 + 21 files changed, 5838 insertions(+), 41 deletions(-) create mode 100644 CLAUDE_SPRINT_15A_PROMPT.md create mode 100644 SPRINT_15B_NOTES.md create mode 100644 frontend/src/components/common/GetBackClock.css create mode 100644 frontend/src/components/common/GetBackClock.tsx create mode 100644 frontend/src/components/economy/MarketMembers.css create mode 100644 frontend/src/components/economy/MarketMembers.tsx create mode 100644 frontend/src/components/economy/MostWanted.css create mode 100644 frontend/src/components/economy/MostWanted.tsx create mode 100644 frontend/src/components/economy/MostWantedApp.css create mode 100644 frontend/src/components/economy/MostWantedApp.tsx create mode 100644 frontend/src/stores/__tests__/getBackStore.test.ts create mode 100644 frontend/src/stores/__tests__/mostWantedStore.test.ts create mode 100644 frontend/src/stores/getBackStore.ts create mode 100644 frontend/src/stores/mostWantedStore.ts create mode 100644 frontend/src/utils/__tests__/marketMembersCatalog.test.ts create mode 100644 frontend/src/utils/marketMembersCatalog.ts create mode 100644 scripts/strip_market_icons.py diff --git a/CLAUDE_SPRINT_15A_PROMPT.md b/CLAUDE_SPRINT_15A_PROMPT.md new file mode 100644 index 0000000..605c15b --- /dev/null +++ b/CLAUDE_SPRINT_15A_PROMPT.md @@ -0,0 +1,188 @@ +# 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 new file mode 100644 index 0000000..9c5969a --- /dev/null +++ b/SPRINT_15B_NOTES.md @@ -0,0 +1,71 @@ +# 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/App.tsx b/frontend/src/App.tsx index 85e8737..cadb0e9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,7 @@ import { IS_DEMO_MODE, seedDemoState } from './utils/demoSeed'; import OSShell from './components/layout/OSShell'; import GameEventOverlay from './components/layout/GameEventOverlay'; import RaidEventOverlay from './components/layout/RaidEventOverlay'; +import GetBackClock from './components/common/GetBackClock'; import TutorialOverlay from './components/tutorial/TutorialOverlay'; import NPCThreatBanner from './components/map/NPCThreatBanner'; @@ -63,6 +64,7 @@ const WeeklyUpdateRoute = React.lazy(() => import('./components/news/WeeklyUpdat const PhoneApp = React.lazy(() => import('./components/phone/PhoneApp')); const AttackPlanner = React.lazy(() => import('./components/missions/AttackPlanner')); const TrapApp = React.lazy(() => import('./components/trap/TrapApp')); +const MostWantedApp = React.lazy(() => import('./components/economy/MostWantedApp')); import './App.css'; @@ -196,6 +198,7 @@ const App: React.FC = () => { case 'news': return }>; case 'phone': return }>; case 'trap': return }>; + case 'most_wanted': return }>; default: return ( { + {/* Get Back shot clock — global HUD. Hides itself when no debt is open. */} + + {/* Payroll modal — renders when Shoebox can't cover weekly wages */} {salarySystem.payrollDue && } diff --git a/frontend/src/components/common/GetBackClock.css b/frontend/src/components/common/GetBackClock.css new file mode 100644 index 0000000..d30bb6b --- /dev/null +++ b/frontend/src/components/common/GetBackClock.css @@ -0,0 +1,281 @@ +/* ============================================================ + GetBackClock — revenge shot clock HUD + Sprint 15-B + ============================================================ */ + +/* ── Badge ── */ +.gbc-badge { + position: fixed; + left: 50%; + bottom: 92px; + transform: translateX(-50%); + z-index: 620; + + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px 10px; + border-radius: 999px; + border: 1px solid rgba(239, 68, 68, 0.45); + background: rgba(20, 8, 8, 0.93); + backdrop-filter: blur(8px); + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.55); + cursor: pointer; + overflow: hidden; +} + +.gbc-badge.urgent { + border-color: rgba(239, 68, 68, 0.85); + animation: gbc-pulse 1.4s ease-in-out infinite; +} + +@keyframes gbc-pulse { + 0%, 100% { box-shadow: 0 4px 18px rgba(0, 0, 0, 0.55); } + 50% { box-shadow: 0 4px 26px rgba(239, 68, 68, 0.55); } +} + +.gbc-badge-label { + font-size: 9px; + font-weight: 800; + letter-spacing: 1.6px; + color: #fca5a5; +} + +.gbc-badge-clock { + font-size: 15px; + font-weight: 900; + letter-spacing: 0.6px; + color: #fff; + /* Tabular figures keep the clock from jittering as digits change. */ + font-variant-numeric: tabular-nums; +} + +.gbc-badge.urgent .gbc-badge-clock { + color: #fca5a5; +} + +.gbc-badge-more { + padding: 1px 6px; + border-radius: 999px; + background: rgba(239, 68, 68, 0.28); + color: #fecaca; + font-size: 9px; + font-weight: 800; +} + +.gbc-badge-bar { + position: absolute; + left: 0; + bottom: 0; + height: 3px; + background: linear-gradient(90deg, #ef4444, #f59e0b); + transition: width 1s linear; +} + +/* ── Overlay panel ── */ +.gbc-overlay { + position: fixed; + inset: 0; + z-index: 940; + display: flex; + align-items: flex-end; + justify-content: center; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); +} + +.gbc-panel { + width: 100%; + max-width: 440px; + max-height: 88vh; + overflow-y: auto; + padding: 18px 16px 22px; + border-radius: 16px 16px 0 0; + background: linear-gradient(180deg, #1a0d0d, #0a0708); + border-top: 1px solid rgba(239, 68, 68, 0.3); +} + +.gbc-panel-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 8px; +} + +.gbc-panel-title { + font-size: 16px; + font-weight: 900; + letter-spacing: 2.4px; + color: #fca5a5; +} + +.gbc-panel-sub { + font-size: 10px; + color: #78716c; +} + +.gbc-explainer { + margin: 0 0 14px; + font-size: 11px; + color: #9ca3af; + line-height: 1.6; +} + +/* ── Items ── */ +.gbc-list { + display: flex; + flex-direction: column; + gap: 11px; +} + +.gbc-item { + padding: 12px; + border-radius: 10px + ; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); +} + +.gbc-item.urgent { + border-color: rgba(239, 68, 68, 0.45); + background: rgba(239, 68, 68, 0.07); +} + +.gbc-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 7px; +} + +.gbc-item-trigger { + font-size: 9px; + font-weight: 800; + letter-spacing: 1.1px; + color: #a8a29e; +} + +.gbc-item-clock { + font-size: 17px; + font-weight: 900; + color: #e7e5e4; + font-variant-numeric: tabular-nums; +} + +.gbc-item-clock.urgent { + color: #f87171; +} + +.gbc-item-track { + height: 4px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; + margin-bottom: 9px; +} + +.gbc-item-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #4ade80, #facc15); + transition: width 1s linear; +} + +.gbc-item-fill.urgent { + background: linear-gradient(90deg, #ef4444, #f97316); +} + +.gbc-item-lost { + font-size: 11px; + color: #a1a1aa; + margin-bottom: 5px; +} + +.gbc-item-lost strong { + color: #f4f4f5; + font-weight: 700; +} + +.gbc-item-copy { + margin: 0 0 9px; + font-size: 11px; + color: #78716c; + line-height: 1.55; +} + +/* ── Wanted list ── */ +.gbc-wanted { + margin-bottom: 9px; +} + +.gbc-wanted-label { + display: block; + font-size: 8px; + font-weight: 800; + letter-spacing: 1.2px; + color: #52525b; + margin-bottom: 5px; +} + +.gbc-wanted-names { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.gbc-wanted-name { + padding: 3px 8px; + border-radius: 999px; + background: rgba(239, 68, 68, 0.14); + border: 1px solid rgba(239, 68, 68, 0.28); + color: #fca5a5; + font-size: 9px; + font-weight: 700; +} + +/* ── Stakes ── */ +.gbc-stakes { + display: flex; + justify-content: space-between; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + font-size: 10px; + font-weight: 700; + color: #4ade80; +} + +.gbc-stakes-loss { + color: #f87171; +} + +/* ── Actions ── */ +.gbc-hunt-btn { + width: 100%; + margin-top: 16px; + padding: 13px; + border: none; + border-radius: 9px; + background: linear-gradient(135deg, #dc2626, #991b1b); + color: #fff; + font-size: 12px; + font-weight: 800; + letter-spacing: 1.1px; + cursor: pointer; +} + +.gbc-hunt-btn:active { + transform: scale(0.98); +} + +.gbc-close { + width: 100%; + margin-top: 8px; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: transparent; + color: #71717a; + font-size: 11px; + cursor: pointer; +} diff --git a/frontend/src/components/common/GetBackClock.tsx b/frontend/src/components/common/GetBackClock.tsx new file mode 100644 index 0000000..1d68f22 --- /dev/null +++ b/frontend/src/components/common/GetBackClock.tsx @@ -0,0 +1,260 @@ +// ============================================================ +// GetBackClock — NBA-style shot clock on revenge +// Sprint 15-B +// +// A persistent HUD badge that sits above the dock and counts down +// the window you have to get back for a lost member. Tapping it +// expands a panel listing who is wanted and what is at stake. +// +// The clock derives its display from wall time on every tick rather +// than decrementing a stored counter, so backgrounding the app or +// reloading does not desync it. A single one-second interval drives +// re-renders; expiry sweeping is handled here too so morale +// penalties land even if the player never opens the panel. +// ============================================================ + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + useGetBackStore, + remainingMs, + remainingFraction, + formatClock, + urgency, + GET_BACK_CONFIG, +} from '../../stores/getBackStore'; +import { + useGangStore, + useMoraleStore, + useNotificationStore, + useNavigationStore, +} from '../../stores/gameStore'; +import type { GetBackWindow, GetBackTrigger } from '../../types/game.types'; +import './GetBackClock.css'; + +const TRIGGER_LABELS: Record = { + slide_casualty: 'LOST ON THE BLOCK', + bounty_fulfilled: 'CONTRACT CASHED ON YOURS', + special_person: 'THEY TOUCHED FAMILY', + block_attack: 'LOST DEFENDING THE BLOCK', +}; + +const TRIGGER_COPY: Record = { + slide_casualty: + 'Somebody slid on your block and took one of yours. Catch anybody who was in that car.', + bounty_fulfilled: + 'Somebody cashed a contract on one of yours. The payout is public — you know who.', + special_person: + 'They went after a member\u2019s people. Handle it or watch that member\u2019s loyalty go.', + block_attack: + 'They came to your block and you lost somebody holding it down. Return the visit.', +}; + +interface GetBackClockProps { + /** Hide the badge on screens that already own the bottom of the viewport. */ + hidden?: boolean; +} + +const GetBackClock: React.FC = ({ hidden = false }) => { + const windows = useGetBackStore((s) => s.windows); + const sweepExpired = useGetBackStore((s) => s.sweepExpired); + const { members } = useGangStore(); + const { applyMoraleChange } = useMoraleStore(); + const { addNotification } = useNotificationStore(); + const { navigateTo } = useNavigationStore(); + + /** + * There is no single gang-wide morale value — morale lives per member. A + * gang-wide swing is applied to every active member so the roster drifts + * together, which is what "the gang lost faith" actually means mechanically. + */ + const applyGangWideMorale = useCallback( + (delta: number) => { + for (const m of members) { + if (m.status !== 'active') continue; + applyMoraleChange(m.id, delta, Math.round(delta / 2)); + } + }, + [members, applyMoraleChange], + ); + + const [now, setNow] = useState(() => Date.now()); + const [expanded, setExpanded] = useState(false); + + // One interval drives every countdown on screen. + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + + const active = useMemo( + () => windows.filter((w) => w.outcome === 'pending' && remainingMs(w, now) > 0), + [windows, now], + ); + + const soonest: GetBackWindow | null = useMemo(() => { + if (active.length === 0) return null; + return active.reduce((best, w) => + remainingMs(w, now) < remainingMs(best, now) ? w : best, + ); + }, [active, now]); + + // Sweep lapsed windows and apply the morale penalty for letting it slide. + // Runs off the same tick so it happens whether or not the panel is open. + useEffect(() => { + const lapsed = windows.filter( + (w) => w.outcome === 'pending' && remainingMs(w, now) === 0, + ); + if (lapsed.length === 0) return; + + const results = sweepExpired(now); + for (const r of results) { + applyGangWideMorale(r.moraleSwing.retaliator); + addNotification({ + type: 'danger', + title: 'CLOCK RAN OUT', + message: + `Nobody answered for ${r.window.lostMemberName}. ` + + `${r.window.offendingGangName} walked. Gang morale down ${Math.abs(r.moraleSwing.retaliator)}.`, + timestamp: Date.now(), + } as never); + } + // `now` is intentionally the only trigger — windows is read fresh each tick. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [now]); + + const handleGoHunt = useCallback(() => { + setExpanded(false); + navigateTo('map'); + }, [navigateTo]); + + if (hidden || !soonest) return null; + + const band = urgency(soonest, now); + const ms = remainingMs(soonest, now); + const fraction = remainingFraction(soonest, now); + + return ( + <> + setExpanded(true)} + initial={{ y: 20, opacity: 0 }} + animate={{ y: 0, opacity: 1 }} + whileTap={{ scale: 0.96 }} + > + GET BACK + {formatClock(ms)} + {active.length > 1 && ( + +{active.length - 1} + )} + + + + + {expanded && ( + setExpanded(false)} + > + e.stopPropagation()} + > +
+ GET BACK + + {active.length} open {active.length === 1 ? 'debt' : 'debts'} + +
+ +

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

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

{TRIGGER_COPY[w.trigger]}

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

🏪 MARKET

+

UNDERWORLD MARKET

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

{selectedItem.name}

{selectedItem.description}

- {selectedItem.damage > 0 && ⚔️ DMG: {selectedItem.damage}} - {selectedItem.defense > 0 && 🛡️ DEF: {selectedItem.defense}} + {selectedItem.damage > 0 && DMG: {selectedItem.damage}} + {selectedItem.defense > 0 && DEF: {selectedItem.defense}} {selectedItem.rarity.toUpperCase()} diff --git a/frontend/src/components/economy/MarketMembers.css b/frontend/src/components/economy/MarketMembers.css new file mode 100644 index 0000000..8052bda --- /dev/null +++ b/frontend/src/components/economy/MarketMembers.css @@ -0,0 +1,475 @@ +/* ============================================================ + MarketMembers — underworld hires board + Sprint 15-B + ============================================================ */ + +.mm-root { + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 4px 24px; +} + +/* ── Pitch strip ── */ +.mm-pitch { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border-radius: 8px; + background: linear-gradient(90deg, rgba(245, 158, 11, 0.12), rgba(0, 0, 0, 0)); + border-left: 3px solid #f59e0b; +} + +.mm-pitch-label { + font-size: 10px; + font-weight: 800; + letter-spacing: 1.4px; + color: #f59e0b; +} + +.mm-pitch-copy { + font-size: 11px; + color: #9ca3af; + line-height: 1.45; +} + +/* ── Filters ── */ +.mm-filters { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 4px; + scrollbar-width: none; +} + +.mm-filters::-webkit-scrollbar { + display: none; +} + +.mm-filter { + flex: 0 0 auto; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: #9ca3af; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.8px; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.mm-filter.active { + background: rgba(239, 68, 68, 0.18); + border-color: rgba(239, 68, 68, 0.45); + color: #fca5a5; +} + +/* ── Board bar ── */ +.mm-board-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.mm-count { + font-size: 11px; + color: #6b7280; + letter-spacing: 0.4px; +} + +.mm-refresh { + padding: 7px 12px; + border-radius: 6px; + border: 1px solid rgba(96, 165, 250, 0.35); + background: rgba(96, 165, 250, 0.12); + color: #93c5fd; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.6px; + cursor: pointer; +} + +.mm-refresh:active { + transform: scale(0.97); +} + +/* ── Toast ── */ +.mm-toast { + padding: 9px 12px; + border-radius: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + text-align: center; +} + +.mm-toast.ok { + background: rgba(74, 222, 128, 0.15); + color: #86efac; + border: 1px solid rgba(74, 222, 128, 0.3); +} + +.mm-toast.fail { + background: rgba(239, 68, 68, 0.15); + color: #fca5a5; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +/* ── Grid ── */ +.mm-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 9px; +} + +.mm-empty { + grid-column: 1 / -1; + padding: 32px 16px; + text-align: center; + font-size: 12px; + color: #6b7280; + line-height: 1.6; +} + +.mm-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 11px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(160deg, rgba(24, 24, 27, 0.95), rgba(9, 9, 11, 0.95)); + cursor: pointer; + transition: transform 0.12s, border-color 0.15s; +} + +.mm-card.unaffordable { + opacity: 0.5; +} + +.mm-card-top { + display: flex; + align-items: center; + justify-content: space-between; +} + +.mm-tier { + font-size: 9px; + font-weight: 800; + letter-spacing: 1.2px; +} + +.mm-level { + font-size: 9px; + font-weight: 700; + color: #6b7280; + letter-spacing: 0.6px; +} + +.mm-name { + font-size: 13px; + font-weight: 700; + color: #f4f4f5; + line-height: 1.2; +} + +.mm-nick { + font-size: 11px; + color: #a1a1aa; + font-style: italic; +} + +.mm-role { + font-size: 9px; + font-weight: 700; + letter-spacing: 1px; + color: #71717a; + margin-top: 1px; +} + +.mm-hook { + font-size: 10px; + color: #8b8b93; + line-height: 1.45; + margin: 4px 0 2px; + min-height: 28px; +} + +.mm-statline { + display: flex; + flex-wrap: wrap; + gap: 6px; + font-size: 9px; + font-weight: 600; + color: #71717a; + letter-spacing: 0.4px; +} + +.mm-baggage-flag { + margin-top: 4px; + font-size: 8px; + font-weight: 800; + letter-spacing: 1px; + color: #fbbf24; +} + +.mm-card-bottom { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-top: 7px; + padding-top: 7px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.mm-price { + font-size: 14px; + font-weight: 800; + color: #4ade80; +} + +.mm-salary { + font-size: 9px; + color: #6b7280; +} + +/* ── Modal ── */ +.mm-overlay { + position: fixed; + inset: 0; + z-index: 900; + display: flex; + align-items: flex-end; + justify-content: center; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(3px); +} + +.mm-modal { + width: 100%; + max-width: 460px; + max-height: 88vh; + overflow-y: auto; + padding: 18px 16px 22px; + border-radius: 16px 16px 0 0; + background: linear-gradient(180deg, #18181b, #0a0a0b); + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.mm-modal-head { + margin-bottom: 12px; +} + +.mm-modal-head h3 { + margin: 4px 0 2px; + font-size: 19px; + font-weight: 800; + color: #fafafa; +} + +.mm-modal-nick { + font-size: 12px; + color: #a1a1aa; +} + +.mm-role-desc { + margin: 0 0 14px; + font-size: 12px; + color: #9ca3af; + line-height: 1.5; +} + +.mm-section { + margin-bottom: 14px; +} + +.mm-section-label { + font-size: 9px; + font-weight: 800; + letter-spacing: 1.4px; + color: #52525b; + margin-bottom: 6px; +} + +.mm-story { + margin: 0; + font-size: 12px; + color: #d4d4d8; + line-height: 1.6; +} + +/* ── Stat bars ── */ +.mm-stat-bars { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mm-stat-row { + display: grid; + grid-template-columns: 62px 1fr 28px; + align-items: center; + gap: 8px; +} + +.mm-stat-label { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.8px; + color: #71717a; +} + +.mm-stat-track { + height: 5px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); + overflow: hidden; +} + +.mm-stat-fill { + height: 100%; + border-radius: 999px; + transition: width 0.3s; +} + +.mm-stat-val { + font-size: 10px; + font-weight: 700; + color: #a1a1aa; + text-align: right; +} + +/* ── Terms ── */ +.mm-terms { + display: flex; + flex-direction: column; + gap: 5px; +} + +.mm-term { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + color: #9ca3af; +} + +.mm-term-val { + font-weight: 700; + color: #e4e4e7; +} + +/* ── Special people ── */ +.mm-people-warn { + margin: 0 0 7px; + font-size: 10px; + color: #78716c; + line-height: 1.5; +} + +.mm-person { + display: flex; + align-items: baseline; + gap: 8px; + padding: 6px 8px; + margin-bottom: 4px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.035); +} + +.mm-person-name { + font-size: 11px; + font-weight: 600; + color: #e4e4e7; +} + +.mm-person-rel { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.6px; + color: #a78bfa; +} + +.mm-person-hood { + margin-left: auto; + font-size: 9px; + color: #52525b; +} + +/* ── Warning ── */ +.mm-warning { + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 12px; + margin-bottom: 14px; + border-radius: 8px; + background: rgba(251, 191, 36, 0.1); + border-left: 3px solid #fbbf24; +} + +.mm-warning-label { + font-size: 9px; + font-weight: 800; + letter-spacing: 1.4px; + color: #fbbf24; +} + +.mm-warning-copy { + font-size: 11px; + color: #d4d4d8; + line-height: 1.5; +} + +/* ── Actions ── */ +.mm-hire-btn { + width: 100%; + padding: 13px; + border: none; + border-radius: 9px; + background: linear-gradient(135deg, #16a34a, #15803d); + color: #fff; + font-size: 13px; + font-weight: 800; + letter-spacing: 1px; + cursor: pointer; +} + +.mm-hire-btn:active { + transform: scale(0.98); +} + +.mm-modal-result { + padding: 12px; + border-radius: 8px; + text-align: center; + font-size: 12px; + font-weight: 800; + letter-spacing: 1px; +} + +.mm-modal-result.ok { + background: rgba(74, 222, 128, 0.15); + color: #86efac; +} + +.mm-modal-result.fail { + background: rgba(239, 68, 68, 0.15); + color: #fca5a5; +} + +.mm-close { + width: 100%; + margin-top: 9px; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: transparent; + color: #71717a; + font-size: 11px; + cursor: pointer; +} diff --git a/frontend/src/components/economy/MarketMembers.tsx b/frontend/src/components/economy/MarketMembers.tsx new file mode 100644 index 0000000..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) => ( + + ))} +
+ +
+ {visible.length} available + +
+ + {result && !selected && ( +
+ {result} +
+ )} + + {/* Listings */} +
+ {visible.length === 0 ? ( +
+ Nobody in this category right now. Refresh the board or check back. +
+ ) : ( + visible.map((h) => { + const affordable = player.money >= h.price; + return ( + { setSelected(h); setResult(''); }} + whileTap={{ scale: 0.98 }} + > +
+ + {TIER_BANDS[h.tier].label} + + LVL {h.level} +
+ +
{h.name}
+
“{h.nickname}”
+
{ROLE_PROFILES[h.role].label}
+ +
{h.originStory.hook}
+ +
+ SHT {h.stats.shooting} + DLR {h.stats.dealing} + NRV {h.stats.nerve} + STL {h.stats.stealth} +
+ + {h.baggage &&
CARRIES BAGGAGE
} + +
+ {formatMoney(h.price)} + {formatMoney(h.salary)}/wk +
+
+ ); + }) + )} +
+ + {/* Detail / hire modal */} + + {selected && ( + { setSelected(null); setResult(''); }} + > + e.stopPropagation()} + > +
+ + {TIER_BANDS[selected.tier].label} · LVL {selected.level} + +

{selected.name}

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

{ROLE_PROFILES[selected.role].description}

+ +
+
ORIGIN
+

{selected.originStory.body}

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

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

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