From 026560d7559ac1e3a9824e12ffd13d19a6ba6db1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:46:04 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20add=20Flash=20Courier=20=E2=80=94?= =?UTF-8?q?=20Splicer=20Terminal,=20Data=20Shards,=20and=20Run=20Briefing?= =?UTF-8?q?=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 4 + src/index.css | 833 +++++++++++++++++++++ src/lib/featureFlags.ts | 7 + src/lib/flashCourier.ts | 249 ++++++ src/lib/runBriefing.ts | 308 ++++++++ src/pages/FlashCourier.tsx | 305 ++++++++ src/pages/flashCourier/SplicerTerminal.tsx | 430 +++++++++++ 7 files changed, 2136 insertions(+) create mode 100644 src/lib/flashCourier.ts create mode 100644 src/lib/runBriefing.ts create mode 100644 src/pages/FlashCourier.tsx create mode 100644 src/pages/flashCourier/SplicerTerminal.tsx diff --git a/src/App.tsx b/src/App.tsx index eeceb923..5dc42d06 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -103,6 +103,7 @@ const BattleArena = lazy(() => import("./pages/BattleArena").then(m => ({ de const RaceTrack = lazy(() => import("./pages/RaceTrack").then(m => ({ default: m.RaceTrack }))); const FramePreview = lazy(() => import("./pages/FramePreview").then(m => ({ default: m.FramePreview }))); const Missions = lazy(() => import("./pages/Missions").then(m => ({ default: m.Missions }))); +const FlashCourier = lazy(() => import("./pages/FlashCourier").then(m => ({ default: m.FlashCourier }))); const Workshop = lazy(() => import("./pages/Workshop").then(m => ({ default: m.Workshop }))); const UserProfile = lazy(() => import("./pages/UserProfile").then(m => ({ default: m.UserProfile }))); const Leaderboard = lazy(() => import("./pages/Leaderboard").then(m => ({ default: m.Leaderboard }))); @@ -405,6 +406,9 @@ function LegacyRoutes() { } /> + + } /> } /> diff --git a/src/index.css b/src/index.css index a2f96c9e..4fbfca91 100644 --- a/src/index.css +++ b/src/index.css @@ -20783,3 +20783,836 @@ textarea:focus-visible, font-size: 13px; } } + +/* ══════════════════════════════════════════════════════════════════════════════ + Flash Courier — Splicer Terminal + Run Briefing + ══════════════════════════════════════════════════════════════════════════════ */ + +/* ── Page layout ── */ +.flash-courier { + max-width: 1200px; + margin: 0 auto; + padding: 20px 20px 60px; + font-family: var(--font); +} + +.flash-courier__page-header { + position: relative; + padding: 28px 0 20px; + text-align: center; + overflow: hidden; +} + +.flash-courier__page-header-scanline { + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 3px, + rgba(0, 255, 136, 0.03) 3px, + rgba(0, 255, 136, 0.03) 4px + ); + animation: fc-scanline-scroll 6s linear infinite; +} + +@keyframes fc-scanline-scroll { + 0% { background-position-y: 0; } + 100% { background-position-y: 40px; } +} + +.flash-courier__page-title { + position: relative; + font-family: var(--font-display); + font-size: 42px; + letter-spacing: 0.12em; + color: var(--theme-accent); + text-shadow: + 0 0 16px rgba(0, 255, 136, 0.5), + 0 0 40px rgba(0, 255, 136, 0.2); + margin: 0 0 6px; + animation: glitch-text 10s infinite; +} + +.flash-courier__page-sub { + position: relative; + font-family: var(--font); + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + letter-spacing: 0.08em; + text-transform: uppercase; + margin: 0; +} + +/* ── Identity picker ── */ +.flash-courier__identity-picker { + border: 1px solid rgba(0, 204, 255, 0.2); + border-radius: 4px; + padding: 16px 20px; + margin-bottom: 20px; + background: rgba(0, 204, 255, 0.04); +} + +.flash-courier__identity-picker-label { + font-family: var(--font); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--theme-accent2); + margin-bottom: 14px; +} + +.flash-courier__identity-selects { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.flash-courier__select-group { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 200px; +} + +.flash-courier__select-label { + font-family: var(--font); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: rgba(255, 255, 255, 0.5); +} + +.flash-courier__select { + background: rgba(5, 5, 14, 0.85); + border: 1px solid rgba(0, 204, 255, 0.3); + border-radius: 3px; + color: #ece4ff; + font-family: var(--font); + font-size: 12px; + padding: 8px 10px; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='rgba(0,204,255,0.7)'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 28px; + transition: border-color 0.15s; +} +.flash-courier__select:focus { + outline: none; + border-color: var(--theme-accent2); + box-shadow: 0 0 0 2px rgba(0, 204, 255, 0.2); +} + +.flash-courier__no-cards { + padding: 20px; + text-align: center; + color: rgba(255, 255, 255, 0.5); + font-family: var(--font); + font-size: 13px; +} +.flash-courier__no-cards a { + color: var(--theme-accent); +} + +/* ── Main two-column layout ── */ +.flash-courier__layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + align-items: start; +} + +@media (max-width: 900px) { + .flash-courier__layout { + grid-template-columns: 1fr; + } +} + +/* ── Splicer Terminal ── */ +.splicer-terminal { + position: relative; + border: 1px solid rgba(0, 255, 136, 0.18); + border-radius: 6px; + background: rgba(5, 5, 14, 0.9); + overflow: hidden; +} + +.splicer-terminal__header { + position: relative; + padding: 16px 20px 14px; + border-bottom: 1px solid rgba(0, 255, 136, 0.12); + overflow: hidden; +} + +.splicer-terminal__scanline { + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 255, 136, 0.025) 2px, + rgba(0, 255, 136, 0.025) 3px + ); +} + +.splicer-terminal__title { + position: relative; + font-family: var(--font-display); + font-size: 20px; + letter-spacing: 0.14em; + color: var(--theme-accent); + text-shadow: 0 0 12px rgba(0, 255, 136, 0.45); + margin: 0 0 4px; +} + +.splicer-terminal__sub { + position: relative; + font-family: var(--font); + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + letter-spacing: 0.06em; + margin: 0; +} + +/* ── Slot zones ── */ +.splicer-terminal__slots { + display: flex; + flex-direction: column; + gap: 0; +} + +.splicer-slot { + --shard-color: #00ff88; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + padding: 14px 18px; + transition: background 0.15s; + min-height: 80px; +} + +.splicer-slot--drag-over { + background: rgba(var(--shard-color-rgb, 0, 255, 136), 0.08); + border-color: var(--shard-color); + outline: 1px dashed var(--shard-color); + outline-offset: -3px; +} + +.splicer-slot--disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.splicer-slot__label { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.splicer-slot__glyph { + font-size: 11px; + color: var(--shard-color); + text-shadow: 0 0 8px var(--shard-color); +} + +.splicer-slot__kind-text { + font-family: var(--font); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--shard-color); +} + +.splicer-slot__empty { + display: flex; + align-items: center; + gap: 10px; + position: relative; +} + +.splicer-slot__empty-hint { + font-family: var(--font); + font-size: 10px; + color: rgba(255, 255, 255, 0.2); + letter-spacing: 0.06em; +} + +.splicer-slot__empty-pulse { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--shard-color); + opacity: 0.4; + animation: fc-slot-pulse 1.8s ease-in-out infinite; +} + +@keyframes fc-slot-pulse { + 0%, 100% { opacity: 0.2; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.4); } +} + +.splicer-slot__loaded { + border: 1px solid rgba(255, 255, 255, 0.1); + border-left: 2px solid var(--shard-color); + border-radius: 3px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.03); + display: flex; + flex-direction: column; + gap: 4px; +} + +.splicer-slot__loaded-name { + font-family: var(--font); + font-size: 12px; + font-weight: 700; + color: var(--shard-color); +} + +.splicer-slot__loaded-flavour { + font-family: var(--font); + font-size: 10px; + color: rgba(255, 255, 255, 0.45); + line-height: 1.4; +} + +.splicer-slot__eject { + align-self: flex-start; + background: none; + border: 1px solid rgba(255, 80, 80, 0.35); + border-radius: 2px; + color: rgba(255, 100, 100, 0.7); + font-family: var(--font); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.1em; + padding: 2px 8px; + cursor: pointer; + transition: background 0.12s, color 0.12s; + margin-top: 4px; +} +.splicer-slot__eject:hover { + background: rgba(255, 80, 80, 0.1); + color: #ff6b6b; +} + +/* ── Shard browser ── */ +.splicer-terminal__browser { + border-top: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; +} + +.splicer-browser-tabs { + display: flex; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.splicer-browser-tab { + --shard-color: #00ff88; + flex: 1; + padding: 10px 0; + background: none; + border: none; + border-right: 1px solid rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.3); + font-family: var(--font); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + cursor: pointer; + transition: color 0.15s, background 0.15s; +} +.splicer-browser-tab:last-child { + border-right: none; +} +.splicer-browser-tab--active { + color: var(--shard-color); + background: rgba(0, 0, 0, 0.3); + border-bottom: 2px solid var(--shard-color); +} +.splicer-browser-tab:hover:not(.splicer-browser-tab--active):not(:disabled) { + color: rgba(255, 255, 255, 0.6); + background: rgba(255, 255, 255, 0.03); +} +.splicer-browser-tab:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.splicer-shard-list { + display: flex; + flex-direction: column; + gap: 0; + max-height: 240px; + overflow-y: auto; + padding: 8px; + scrollbar-width: thin; + scrollbar-color: rgba(0, 255, 136, 0.25) transparent; +} + +/* ── Shard cards ── */ +.splicer-shard-card { + --shard-color: #00ff88; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + padding: 10px 12px; + cursor: grab; + background: rgba(255, 255, 255, 0.02); + transition: border-color 0.15s, background 0.15s, transform 0.1s; + user-select: none; + margin-bottom: 6px; +} +.splicer-shard-card:last-child { margin-bottom: 0; } + +.splicer-shard-card:hover:not(.splicer-shard-card--disabled), +.splicer-shard-card:focus-visible:not(.splicer-shard-card--disabled) { + border-color: var(--shard-color); + background: rgba(255, 255, 255, 0.05); + outline: none; +} + +.splicer-shard-card:active:not(.splicer-shard-card--disabled) { + cursor: grabbing; + transform: scale(0.98); +} + +.splicer-shard-card--selected { + border-color: var(--shard-color); + background: rgba(0, 0, 0, 0.4); + box-shadow: inset 0 0 0 1px var(--shard-color), 0 0 8px rgba(0, 255, 136, 0.15); +} + +.splicer-shard-card--disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.splicer-shard-card__header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 5px; +} + +.splicer-shard-card__glyph { + font-size: 11px; + color: var(--shard-color); + text-shadow: 0 0 6px var(--shard-color); + flex-shrink: 0; +} + +.splicer-shard-card__name { + font-family: var(--font); + font-size: 11px; + font-weight: 700; + color: #ece4ff; + letter-spacing: 0.04em; +} + +.splicer-shard-card__flavour { + font-family: var(--font); + font-size: 10px; + color: rgba(255, 255, 255, 0.4); + line-height: 1.45; + margin: 0; +} + +.splicer-shard-card__cost { + display: inline-block; + margin-top: 6px; + padding: 1px 7px; + background: rgba(0, 255, 136, 0.08); + border: 1px solid rgba(0, 255, 136, 0.25); + border-radius: 2px; + font-family: var(--font); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--theme-accent); +} + +/* ── Compile button ── */ +.splicer-terminal__footer { + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding: 14px 18px; +} + +.splicer-compile-btn { + width: 100%; + padding: 14px 0; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + color: rgba(255, 255, 255, 0.3); + font-family: var(--font); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + cursor: not-allowed; + transition: background 0.18s, border-color 0.18s, color 0.18s, box-shadow 0.18s; + position: relative; + overflow: hidden; +} + +.splicer-compile-btn--ready { + background: rgba(0, 255, 136, 0.08); + border-color: var(--theme-accent); + color: var(--theme-accent); + cursor: pointer; + animation: fc-btn-glow 2.2s ease-in-out infinite; +} + +@keyframes fc-btn-glow { + 0%, 100% { box-shadow: 0 0 8px rgba(0, 255, 136, 0.2); } + 50% { box-shadow: 0 0 22px rgba(0, 255, 136, 0.45), 0 0 40px rgba(0, 255, 136, 0.15); } +} + +.splicer-compile-btn--ready:hover { + background: rgba(0, 255, 136, 0.14); + box-shadow: 0 0 28px rgba(0, 255, 136, 0.5); +} + +.splicer-compile-btn__label { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +.splicer-compile-btn__label--busy { + opacity: 0.7; +} + +.splicer-compile-btn__dots span { + display: inline-block; + animation: fc-dot-blink 1.2s infinite both; +} +.splicer-compile-btn__dots span:nth-child(2) { animation-delay: 0.2s; } +.splicer-compile-btn__dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes fc-dot-blink { + 0%, 80%, 100% { opacity: 0.2; } + 40% { opacity: 1; } +} + +/* ── Compile overlay ── */ +.splicer-compile-overlay { + position: absolute; + inset: 0; + background: rgba(2, 2, 10, 0.95); + display: flex; + flex-direction: column; + padding: 20px 22px; + z-index: 10; + border: 1px solid rgba(0, 255, 136, 0.3); +} + +.splicer-compile-overlay__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.splicer-compile-overlay__title { + font-family: var(--font); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--theme-accent); + text-shadow: 0 0 10px rgba(0, 255, 136, 0.5); +} + +.splicer-compile-overlay__pct { + font-family: var(--font); + font-size: 11px; + color: rgba(0, 255, 136, 0.6); +} + +.splicer-compile-overlay__progress-track { + height: 3px; + background: rgba(0, 255, 136, 0.1); + border-radius: 2px; + margin-bottom: 14px; + overflow: hidden; +} + +.splicer-compile-overlay__progress-fill { + height: 100%; + background: var(--theme-accent); + border-radius: 2px; + transition: width 0.08s linear; + box-shadow: 0 0 8px rgba(0, 255, 136, 0.6); +} + +.splicer-compile-overlay__log { + flex: 1; + overflow-y: auto; + font-family: var(--font); + font-size: 10px; + line-height: 1.8; + color: rgba(0, 255, 136, 0.55); + scrollbar-width: thin; + scrollbar-color: rgba(0, 255, 136, 0.2) transparent; +} + +.splicer-compile-overlay__line { + opacity: 0; + animation: fc-line-appear 0.2s forwards; +} + +@keyframes fc-line-appear { + to { opacity: 1; } +} + +.splicer-compile-overlay__line--active { + color: var(--theme-accent); + text-shadow: 0 0 6px rgba(0, 255, 136, 0.4); +} + +.splicer-compile-overlay__cursor { + display: inline-block; + animation: fc-cursor-blink 0.75s step-end infinite; + color: var(--theme-accent); +} + +@keyframes fc-cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* ── Run Briefing panel ── */ +.run-briefing { + border: 1px solid rgba(204, 68, 255, 0.2); + border-radius: 6px; + background: rgba(5, 5, 14, 0.9); + padding: 22px 22px 20px; + animation: page-drop-in 360ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.run-briefing__header { + margin-bottom: 18px; + padding-bottom: 14px; + border-bottom: 1px solid rgba(204, 68, 255, 0.18); +} + +.run-briefing__eyebrow { + font-family: var(--font); + font-size: 9px; + color: rgba(204, 68, 255, 0.65); + letter-spacing: 0.1em; + text-transform: uppercase; + margin: 0 0 6px; +} + +.run-briefing__title { + font-family: var(--font-display); + font-size: 22px; + letter-spacing: 0.1em; + color: var(--theme-purple); + text-shadow: 0 0 14px rgba(204, 68, 255, 0.4); + margin: 0; +} + +.run-briefing__prose { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.run-briefing__paragraph { + font-family: var(--font); + font-size: 12px; + line-height: 1.75; + color: rgba(255, 255, 255, 0.72); + margin: 0; +} + +.run-briefing__choices { + display: flex; + flex-direction: column; + gap: 10px; +} + +.run-briefing__choices-label { + font-family: var(--font); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; + color: rgba(125, 231, 255, 0.6); + margin: 0 0 4px; +} + +.run-briefing__choice { + text-align: left; + padding: 12px 14px; + background: rgba(204, 68, 255, 0.05); + border: 1px solid rgba(204, 68, 255, 0.3); + border-radius: 4px; + color: #fff; + font-family: var(--font); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + display: flex; + flex-direction: column; + gap: 5px; +} +.run-briefing__choice:hover:not(:disabled) { + background: rgba(204, 68, 255, 0.1); + border-color: rgba(204, 68, 255, 0.65); +} +.run-briefing__choice:disabled { + cursor: not-allowed; + opacity: 0.45; +} +.run-briefing__choice--chosen { + background: rgba(204, 68, 255, 0.12); + border-color: var(--theme-purple); + opacity: 1 !important; + box-shadow: 0 0 12px rgba(204, 68, 255, 0.25); +} + +.run-briefing__choice-label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.run-briefing__choice-consequence { + font-size: 10px; + color: rgba(255, 255, 255, 0.5); + line-height: 1.4; +} + +.run-briefing__choice-mods { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 2px; +} + +.run-briefing__choice-mod { + font-family: var(--font); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.1em; + padding: 1px 6px; + border-radius: 2px; +} +.run-briefing__choice-mod--pos { + color: #7dffb6; + background: rgba(125, 255, 182, 0.1); + border: 1px solid rgba(125, 255, 182, 0.3); +} +.run-briefing__choice-mod--neg { + color: #ff8a8a; + background: rgba(255, 100, 100, 0.08); + border: 1px solid rgba(255, 100, 100, 0.25); +} + +.run-briefing__committed { + margin-top: 16px; + padding: 10px 14px; + border: 1px solid rgba(0, 255, 136, 0.3); + border-radius: 4px; + background: rgba(0, 255, 136, 0.06); + font-family: var(--font); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--theme-accent); + display: flex; + align-items: center; + gap: 8px; + animation: fc-committed-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes fc-committed-in { + from { opacity: 0; transform: translateY(6px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.run-briefing__committed-glyph { + color: var(--theme-accent); + text-shadow: 0 0 8px rgba(0, 255, 136, 0.5); +} + +/* ── Briefing placeholder ── */ +.flash-courier__briefing-col { + display: flex; + flex-direction: column; + gap: 12px; +} + +.flash-courier__briefing-placeholder { + border: 1px dashed rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 48px 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + color: rgba(255, 255, 255, 0.2); + font-family: var(--font); + font-size: 12px; + text-align: center; + line-height: 1.5; +} + +.flash-courier__briefing-placeholder-glyph { + font-size: 36px; + color: rgba(204, 68, 255, 0.25); + animation: fc-slot-pulse 2.4s ease-in-out infinite; +} + +.flash-courier__reset-btn { + padding: 10px 0; + width: 100%; + background: none; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + color: rgba(255, 255, 255, 0.4); + font-family: var(--font); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.flash-courier__reset-btn:hover { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.25); + color: rgba(255, 255, 255, 0.65); +} + +.flash-courier__locked { + padding: 60px 20px; + text-align: center; + color: rgba(255, 255, 255, 0.35); + font-family: var(--font); + font-size: 13px; +} + +/* ── Reduced motion overrides ── */ +@media (prefers-reduced-motion: reduce) { + .flash-courier__page-header-scanline, + .flash-courier__page-title, + .splicer-slot__empty-pulse, + .splicer-compile-btn--ready, + .splicer-compile-overlay__cursor, + .flash-courier__briefing-placeholder-glyph, + .run-briefing__committed { + animation: none !important; + } +} diff --git a/src/lib/featureFlags.ts b/src/lib/featureFlags.ts index 0b8e5464..37a2e6bb 100644 --- a/src/lib/featureFlags.ts +++ b/src/lib/featureFlags.ts @@ -61,6 +61,13 @@ export const featureFlags = { /** Punch Skater™ Streets side-scrolling beat-em-up mission mode. @owner gamma */ STREETS: envFlag("VITE_FF_STREETS", true), + + /** + * Flash Courier — narrative-driven run-briefing mechanic with Data Shard + * slotting (Splicer Terminal) and a branching story engine (RunBriefing). + * @owner gamma + */ + FLASH_COURIER: envFlag("VITE_FF_FLASH_COURIER", true), } as const; export type FeatureFlagKey = keyof typeof featureFlags; diff --git a/src/lib/flashCourier.ts b/src/lib/flashCourier.ts new file mode 100644 index 00000000..297303f4 --- /dev/null +++ b/src/lib/flashCourier.ts @@ -0,0 +1,249 @@ +/** + * flashCourier.ts — Data models for the Flash Courier mechanic. + * + * The player compiles a "Burn Route" by slotting three Data Shards + * (Vector, Ghost, Payload) into the Splicer Terminal before a run. + * The resulting NavDeck state, combined with the card's Cover Identity + * and District, drives the RunBriefing narrative engine. + * + * Rules (append-only contract, same as sharedTypes.ts): + * 1. Never remove or rename an existing type. + * 2. New fields on existing interfaces must be optional (?:). + * 3. Add new types at the bottom of the relevant section. + */ + +import type { Archetype, District } from "./types"; + +// ── Shard kinds ─────────────────────────────────────────────────────────────── + +/** + * The three shard slot roles a player must fill to compile a Burn Route. + * • Vector — *how* the courier approaches the target zone. + * • Ghost — *how* the courier stays invisible/undetected. + * • Payload — a wildcard modifier that bends the mission outcome. + */ +export type ShardKind = "vector" | "ghost" | "payload"; + +/** + * A single Data Shard card. Shards are stateless catalogue entries; the player + * equips them into a NavDeck for a specific run. + */ +export interface DataShard { + /** Unique stable identifier — used as the drag-source key and Firestore doc id. */ + id: string; + kind: ShardKind; + name: string; + /** One-sentence flavour description shown in the terminal slot. */ + flavour: string; + /** + * Modifier tags that the RunBriefing engine reads to select narrative branches. + * e.g. "elevated", "spoofed", "economic" + */ + tags: string[]; + /** Optional Ozzies cost to equip this shard for a single run (0 = free). */ + ozziesCost?: number; +} + +// ── Player NavDeck state ────────────────────────────────────────────────────── + +/** + * The three shard slots the player must fill before compiling. + * Stored locally while the player is configuring; persisted to Firestore + * under `users/{uid}/navDecks/{navDeckId}` once compiled. + */ +export interface NavDeckSlots { + vector: DataShard | null; + ghost: DataShard | null; + payload: DataShard | null; +} + +/** + * Full NavDeck — the compiled run configuration. + * The RunBriefing engine receives a `CompiledNavDeck` to generate its output. + */ +export interface CompiledNavDeck { + navDeckId: string; + uid: string; + /** Cover Identity archetype from the Card Forge. */ + archetype: Archetype; + /** District the courier is operating in. */ + district: District; + vector: DataShard; + ghost: DataShard; + payload: DataShard; + compiledAt: string; // ISO 8601 +} + +// ── Shard catalogue (static data — no server round-trip needed) ─────────────── + +/** Vector shards: approach / entry method. */ +export const VECTOR_SHARDS: DataShard[] = [ + { + id: "vec-service-elevator", + kind: "vector", + name: "Service Elevator Override", + flavour: "Splice the freight elevator's RFID handshake and ride it straight to the maintenance level.", + tags: ["indoor", "vertical", "infrastructure"], + }, + { + id: "vec-rooftop-glider", + kind: "vector", + name: "Rooftop Glider Drop", + flavour: "Deploy a collapsible wing-rig from a high-altitude transit drone. Thermal updrafts do the rest.", + tags: ["elevated", "aerial", "silent"], + }, + { + id: "vec-sewer-transit", + kind: "vector", + name: "Sewer Transit Line", + flavour: "Old district drainage maps show a gap in the sensor grid. Wet, but invisible.", + tags: ["underground", "off-grid", "slow"], + }, + { + id: "vec-corporate-shuttle", + kind: "vector", + name: "Corpo Shuttle Bluff", + flavour: "Flag down a corporate transit pod and bluff a corporate passenger manifest. Clean, fast, risky.", + tags: ["social", "fast", "contested"], + }, + { + id: "vec-delivery-drone", + kind: "vector", + name: "Automated Delivery Drone", + flavour: "Hitch a passive ride inside a bulk-cargo drone's payload bay. Nobody checks the crates.", + tags: ["aerial", "concealed", "infrastructure"], + }, + { + id: "vec-mag-rail-surf", + kind: "vector", + name: "Mag-Rail Surfboard", + flavour: "Lock a skate-deck electromagnetically to the undercarriage of a maglev car. Don't miss the dismount.", + tags: ["fast", "elevated", "physical"], + }, +]; + +/** Ghost shards: camouflage / identity spoofing. */ +export const GHOST_SHARDS: DataShard[] = [ + { + id: "gho-spoofed-id", + kind: "ghost", + name: "Spoofed Corpo ID", + flavour: "A cloned badge and a forged biometric pulse. Security panels see exactly what they're told to see.", + tags: ["spoofed", "social", "fragile"], + }, + { + id: "gho-kinematic-emp", + kind: "ghost", + name: "Kinematic EMP", + flavour: "A localised pulse that wipes camera buffers in a 30-metre cone without tripping hard-line alarms.", + tags: ["electronic", "aoe", "timed"], + }, + { + id: "gho-thermal-cloak", + kind: "ghost", + name: "Thermal Cloak Wrap", + flavour: "Mylar-wrapped courier suit that masks heat signature. Only useful if they're not scanning visually.", + tags: ["stealth", "physical", "limited"], + }, + { + id: "gho-crowd-weave", + kind: "ghost", + name: "Crowd Weave Protocol", + flavour: "Move within tightly tracked civilian foot traffic. Pattern-matching AI loses the thread in high density.", + tags: ["social", "urban", "passive"], + }, + { + id: "gho-signal-jammer", + kind: "ghost", + name: "Signal Jammer Wristband", + flavour: "Blanket RF suppression. Kills comms in a block radius — including yours. Commit once.", + tags: ["electronic", "aggressive", "committed"], + }, + { + id: "gho-maintenance-disguise", + kind: "ghost", + name: "Maintenance Crew Disguise", + flavour: "Hard-hat, reflective vest, tool belt. Nobody stops someone who looks like they belong.", + tags: ["social", "slow", "reliable"], + }, +]; + +/** Payload shards: wildcard modifiers that warp the mission's outcome space. */ +export const PAYLOAD_SHARDS: DataShard[] = [ + { + id: "pay-skim-credits", + kind: "payload", + name: "Skimming Extra Credits", + flavour: "The packet's encrypted but you can see the weight. There's more in here than the client admitted.", + tags: ["economic", "risk", "greed"], + ozziesCost: 0, + }, + { + id: "pay-eavesdrop", + kind: "payload", + name: "Eavesdropping — Secure Channel", + flavour: "Tap the encrypted relay and let the run pay twice: once for the client, once for the intel.", + tags: ["intel", "passive", "deniable"], + ozziesCost: 0, + }, + { + id: "pay-plant-tracker", + kind: "payload", + name: "Plant a Tracker", + flavour: "Slip a dust-sized locator into the delivery. The receiving party won't know they're being watched.", + tags: ["intel", "delayed", "deniable"], + ozziesCost: 0, + }, + { + id: "pay-swap-payload", + kind: "payload", + name: "Bait-and-Switch Package", + flavour: "Deliver a convincing decoy. The real cargo goes somewhere else entirely. Burn the manifest.", + tags: ["deception", "high-risk", "contested"], + ozziesCost: 10, + }, + { + id: "pay-document-run", + kind: "payload", + name: "Document the Run", + flavour: "Record everything — feeds, encounters, routes. This footage has value to the right journalist.", + tags: ["intel", "evidence", "slow"], + ozziesCost: 0, + }, + { + id: "pay-ghost-exit", + kind: "payload", + name: "Ghost Exit Protocol", + flavour: "No paper trail, no biometric ping at the drop. You were never there. The mission never happened.", + tags: ["stealth", "clean", "professional"], + ozziesCost: 5, + }, +]; + +/** All shards indexed by kind — convenience for the Splicer Terminal UI. */ +export const ALL_SHARDS: Record = { + vector: VECTOR_SHARDS, + ghost: GHOST_SHARDS, + payload: PAYLOAD_SHARDS, +}; + +/** Human-readable labels for each slot kind. */ +export const SHARD_KIND_LABELS: Record = { + vector: "VECTOR — Approach", + ghost: "GHOST — Camouflage", + payload: "PAYLOAD — Wild Card", +}; + +/** Accent colours for each slot kind (matches existing theme variables). */ +export const SHARD_KIND_COLORS: Record = { + vector: "#00ccff", + ghost: "#cc44ff", + payload: "#00ff88", +}; + +/** Glyphs used as decorative prefixes in the terminal UI. */ +export const SHARD_KIND_GLYPHS: Record = { + vector: "◈", + ghost: "◎", + payload: "⚡", +}; diff --git a/src/lib/runBriefing.ts b/src/lib/runBriefing.ts new file mode 100644 index 00000000..c667fe94 --- /dev/null +++ b/src/lib/runBriefing.ts @@ -0,0 +1,308 @@ +/** + * runBriefing.ts — Narrative Router for the Flash Courier mechanic. + * + * Accepts the five key variables (archetype, district, vectorShard, + * ghostShard, payloadShard) and returns a `StoryNode` containing + * prose text and a branching choice tree — the "Run Briefing." + * + * All prose is stored client-side; no LLM/server call is required. + * The routing logic uses a priority cascade: + * 1. Exact 5-variable match (rare; reserved for special combinations) + * 2. District × Vector tag match + * 3. District fallback (generic district flavour) + * 4. Global fallback + */ + +import type { CompiledNavDeck } from "./flashCourier"; + +// ── Output types ────────────────────────────────────────────────────────────── + +/** A single branching choice offered at the end of the Run Briefing. */ +export interface BriefingChoice { + id: string; + label: string; + /** Short consequence hint shown in the choice button. */ + consequence: string; + /** Stat modifier hints (narrative only — not mechanically enforced here). */ + modifiers?: { + stealthDelta?: number; + speedDelta?: number; + riskDelta?: number; + rewardDelta?: number; + }; +} + +/** + * The fully resolved Run Briefing story node. + * Displayed after the Splicer Terminal compile animation completes. + */ +export interface StoryNode { + /** Short one-line title for the briefing panel header. */ + title: string; + /** + * Multi-paragraph prose briefing (plain text; newlines are rendered as + * paragraph breaks by the RunBriefing component). + */ + prose: string[]; + /** + * 2–3 player choices that end the briefing phase and feed into + * the next game-state transition (encounter resolution, district run, etc.) + */ + choices: BriefingChoice[]; + /** Optional atmospheric sub-header line (shown below the title in smaller text). */ + eyebrow?: string; +} + +// ── Helper: tag intersection ────────────────────────────────────────────────── + +function hasTags(tags: string[], ...checks: string[]): boolean { + return checks.some((c) => tags.includes(c)); +} + +// ── District-level prose blocks ─────────────────────────────────────────────── + +type DistrictKey = "Airaway" | "Batteryville" | "The Grid" | "Nightshade" | "The Forest" | "Glass City"; + +const DISTRICT_OPEN_LINES: Record = { + Airaway: + "The thermals over Airaway are rough tonight — the transit drones are running offset patterns to avoid a pressure ridge stalling across the elevated freight lane. Amber hazard strobes blink in loose sequence eight stories below your drop point.", + Batteryville: + "Batteryville smells like ozone and overheated rubber. Every third block there's a charging plaza humming at a frequency that makes cheap electronics glitch. The surveillance grid here runs on distributed mesh — there's no single tower to knock out.", + "The Grid": + "The Grid is always awake. Data relay towers pulse in pink and white across the skyline. Down at street level, corporate walkers move in the kind of purposeful blur that means they're jacked in, half-present. The city processes you before you process it.", + Nightshade: + "Nightshade after 21:00 is a different architecture. The neon-bar signs are the brightest thing in the district — everything else is deliberately dim. Three rival factions claim adjacent blocks here, and the edges blur without warning.", + "The Forest": + "What they call The Forest is a kilometre-wide stretch of bio-reclaimed land between two old industrial zones. Sensor towers grow through the canopy. The corporate environmental arm runs access-controlled trails, but the root network runs deep and the old utility paths are off the map.", + "Glass City": + "Glass City doesn't hide anything — it puts it all in a display case. Every surface is reflective, every corner is archived to a cloud. The paradox is that the surveillance is so dense it folds on itself. Too much data reads as noise.", +}; + +const DISTRICT_CLOSE_LINES: Record = { + Airaway: + "The wind shear at this altitude will cover a lot of sound. That's the only advantage the approach has given you.", + Batteryville: + "The charging-plaza interference buys you maybe ninety seconds of clean movement before the mesh self-heals.", + "The Grid": + "You have one window — a seven-minute diagnostic cycle when the relay towers are handshaking the overnight backup. Use it.", + Nightshade: + "The dark works for you until it doesn't. Once a rival marks you, the shadows belong to them too.", + "The Forest": + "The old utility paths don't show on any corporate map. That's the edge. Don't waste it.", + "Glass City": + "In Glass City, looking like you belong is the only armour that matters. Everything else is a tell.", +}; + +// ── Vector-tag prose inserts ────────────────────────────────────────────────── + +function getVectorProse(tags: string[]): string { + if (hasTags(tags, "aerial", "elevated")) { + return "You drop from altitude, the city spread below you like a circuit board someone left face-up in the rain."; + } + if (hasTags(tags, "underground", "off-grid")) { + return "The tunnel is warm and smells like two decades of standing water. Your board's proximity sensors tick quietly as you navigate the drainage geometry."; + } + if (hasTags(tags, "social", "fast")) { + return "You move inside the flow of civilian traffic, matching their rhythm, invisible in your specificity."; + } + if (hasTags(tags, "infrastructure", "vertical")) { + return "The maintenance shaft is a straight vertical drop — no camera coverage, no patrol schedule, no record of your access in the system."; + } + return "The route opens in front of you — not elegant, but uncontested."; +} + +// ── Ghost-tag prose inserts ─────────────────────────────────────────────────── + +function getGhostProse(tags: string[]): string { + if (hasTags(tags, "spoofed", "social")) { + return "The badge reader blinks green. Somewhere in the authentication chain a cloned credential is standing in for you, clean and deniable."; + } + if (hasTags(tags, "electronic", "aoe")) { + return "The EMP wash rolls out in a soft cone. Camera buffers wipe. You count three seconds and move."; + } + if (hasTags(tags, "stealth", "physical")) { + return "The thermal wrap makes you invisible to passive sensors — a ghost on the thermal register, a gap in the heat map."; + } + if (hasTags(tags, "passive", "urban")) { + return "You dissolve into foot traffic, letting the crowd pattern-match around you while the AI loses your thread in the density."; + } + return "The cover holds — for now."; +} + +// ── Payload-tag prose inserts ───────────────────────────────────────────────── + +function getPayloadProse(tags: string[]): string { + if (hasTags(tags, "economic", "greed")) { + return "The packet weight is wrong. You can feel it — whatever the client said was in here, there's more. Credits, data, or something the manifest doesn't name. You could leave it. You probably should."; + } + if (hasTags(tags, "intel", "passive")) { + return "You've already opened a passive tap on the relay. Whatever passes through this node tonight goes into a buffer you'll review later. The client never has to know it happened."; + } + if (hasTags(tags, "deception", "high-risk")) { + return "The real package is three blocks north, in a dead drop you seeded two days ago. What you're delivering now is a convincing nothing — a decoy that will keep the receiving party occupied long enough for you to clear the district."; + } + if (hasTags(tags, "stealth", "clean")) { + return "No ping. No trace. The ghost-exit protocol is already running — by the time anyone reviews the footage, your biometrics are scrubbed and your route is a dead sector in the archive."; + } + return "The secondary objective is live. Whether you act on it is the only variable the mission didn't account for."; +} + +// ── Archetype-flavoured coda lines ──────────────────────────────────────────── + +function getArchetypeCoda(archetype: string): string { + const coda: Record = { + "The Knights Technarchy": + "Your courier ID reads 'Lab Sample Logistics.' The badge is immaculate. The sample satchel gives the whole disguise a weight that sells itself.", + "Qu111s": + "The press pass is clipped visible — not hiding, just another journalist chasing a story through a restricted zone. Nobody looks twice at someone taking notes.", + "Ne0n Legion": + "The security-guard posture is muscle memory. You move like someone who belongs here, because you've spent long enough pretending that you almost believe it.", + "Iron Curtains": + "The delivery bag is heavy and you carry it like it isn't. Hard-wearing patience is the whole skill.", + "D4rk $pider": + "The hoodie is up. The wrist-screen is dark. You are, from the right angle, just another coder moving between access points — technically present, practically invisible.", + "The Asclepians": + "The aid-vest pockets are full of things that look medical. The medical pouch at your hip is the real cargo and nobody is going to stop a relief worker tonight.", + "The Mesopotamian Society": + "Field notebook in hand, survey satchel on the back — you're an archaeologist. This district is just another dig site, and the path through it is already mapped in your head.", + "Hermes' Squirmies": + "Hard hat, reflective vest, work boots. You look like you were sent by dispatch and nobody sends you home from a job site without a work order.", + UCPS: + "The postal route is logged, laminated, and clipped to the bag. You are, on paper, delivering parcels. On paper is good enough.", + "The Team": + "Service vest, bar rag at the belt, comfortable shoes. You move like the floor is your domain — because it has been, every shift, for years.", + }; + return coda[archetype] ?? "You've run harder approaches. This one is manageable."; +} + +// ── Choice banks by payload tag ─────────────────────────────────────────────── + +function getChoices(payloadTags: string[], district: string): BriefingChoice[] { + if (hasTags(payloadTags, "economic", "greed")) { + return [ + { + id: "take-the-extra", + label: "Skim the overflow", + consequence: "Pocket what the manifest doesn't account for. Higher Ozzies return, but the client may notice.", + modifiers: { rewardDelta: 2, riskDelta: 1 }, + }, + { + id: "run-clean", + label: "Run clean", + consequence: "Deliver the packet intact. Lower risk, standard reward. No paper trail.", + modifiers: { stealthDelta: 1, riskDelta: -1 }, + }, + { + id: "document-anomaly", + label: "Document the discrepancy", + consequence: "Record the weight irregularity and flag it to a third party after the run. Slow burn reward.", + modifiers: { rewardDelta: 1, speedDelta: -1 }, + }, + ]; + } + if (hasTags(payloadTags, "intel", "passive", "deniable")) { + return [ + { + id: "keep-tap-running", + label: "Keep the tap open", + consequence: "Let the buffer fill through the whole run. Richer intel, tighter exit window.", + modifiers: { rewardDelta: 2, speedDelta: -1 }, + }, + { + id: "close-tap-early", + label: "Pull the tap before delivery", + consequence: "Limit exposure. What you have is enough. Cleaner exit, lower haul.", + modifiers: { stealthDelta: 1, rewardDelta: -1 }, + }, + { + id: "sell-to-rival", + label: `Pass the intel to a ${district} contact`, + consequence: "High immediate payout. The client finds out eventually.", + modifiers: { rewardDelta: 3, riskDelta: 2 }, + }, + ]; + } + if (hasTags(payloadTags, "deception", "high-risk")) { + return [ + { + id: "complete-the-switch", + label: "Complete the bait-and-switch", + consequence: "Deliver the decoy. Retrieve the real payload from the dead drop. Maximum risk, maximum control.", + modifiers: { riskDelta: 2, rewardDelta: 2 }, + }, + { + id: "abort-secondary", + label: "Abort the secondary op", + consequence: "Deliver the real package as briefed. Safer. The dead drop stays cold.", + modifiers: { stealthDelta: 2, riskDelta: -2 }, + }, + { + id: "hand-off-real", + label: "Hand off the real package to an intermediary", + consequence: "Split the exposure. The intermediary takes the risk; you take the smaller cut.", + modifiers: { riskDelta: 1, rewardDelta: 1 }, + }, + ]; + } + // Default choice set — used when no payload tag matches specifically + return [ + { + id: "push-forward", + label: "Push forward — execute the route", + consequence: "Commit to the compiled approach. Standard risk profile.", + modifiers: { speedDelta: 1 }, + }, + { + id: "adapt-on-the-fly", + label: "Improvise — read the district", + consequence: "Deviate from the route as conditions dictate. Higher variance, possible bonus intel.", + modifiers: { riskDelta: 1, rewardDelta: 1 }, + }, + { + id: "abort-and-re-route", + label: "Abort and re-compile", + consequence: "Call the run off and return to the terminal. No penalty, no reward.", + modifiers: { riskDelta: -2, rewardDelta: -2 }, + }, + ]; +} + +// ── Main routing function ───────────────────────────────────────────────────── + +/** + * Accepts a compiled NavDeck and returns a `StoryNode` — the Run Briefing. + * + * @example + * const node = resolveRunBriefing(compiledNavDeck); + * // → { title, eyebrow, prose: string[], choices: BriefingChoice[] } + */ +export function resolveRunBriefing(deck: CompiledNavDeck): StoryNode { + const { archetype, district, vector, ghost, payload } = deck; + + const districtKey = district as DistrictKey; + const openLine = DISTRICT_OPEN_LINES[districtKey] + ?? `The district stretches out ahead of you — ${district} never sleeps.`; + const closeLine = DISTRICT_CLOSE_LINES[districtKey] + ?? "The window is tight. You know what needs to happen."; + + const vectorLine = getVectorProse(vector.tags); + const ghostLine = getGhostProse(ghost.tags); + const payloadLine = getPayloadProse(payload.tags); + const codaLine = getArchetypeCoda(archetype); + + const prose = [ + openLine, + `${vectorLine} ${ghost.name}: ${ghostLine}`, + payloadLine, + codaLine, + closeLine, + ]; + + const eyebrow = `${vector.name} / ${ghost.name} / ${payload.name}`; + + const title = `RUN BRIEFING — ${district.toUpperCase()}`; + + const choices = getChoices(payload.tags, district); + + return { title, eyebrow, prose, choices }; +} diff --git a/src/pages/FlashCourier.tsx b/src/pages/FlashCourier.tsx new file mode 100644 index 00000000..0c869f62 --- /dev/null +++ b/src/pages/FlashCourier.tsx @@ -0,0 +1,305 @@ +import { useCallback, useMemo, useState } from "react"; +import { useAuth } from "../context/AuthContext"; +import { useCollection } from "../hooks/useCollection"; +import { isEnabled } from "../lib/featureFlags"; +import type { CompiledNavDeck, DataShard, NavDeckSlots, ShardKind } from "../lib/flashCourier"; +import type { BriefingChoice, StoryNode } from "../lib/runBriefing"; +import { resolveRunBriefing } from "../lib/runBriefing"; +import type { Archetype, District } from "../lib/types"; +import { SplicerTerminal, useCompileAnimation } from "./flashCourier/SplicerTerminal"; + +// ── Run Briefing display ────────────────────────────────────────────────────── + +function RunBriefingPanel({ + node, + onChoose, + choosing, + chosenId, +}: { + node: StoryNode; + onChoose: (choice: BriefingChoice) => void; + choosing: boolean; + chosenId: string | null; +}) { + return ( +
+
+

{node.eyebrow}

+

{node.title}

+
+ +
+ {node.prose.map((paragraph, i) => ( +

+ {paragraph} +

+ ))} +
+ +
+

▶ CHOOSE YOUR APPROACH

+ {node.choices.map((choice) => { + const isChosen = chosenId === choice.id; + return ( + + ); + })} +
+ + {chosenId && ( +
+ + ROUTE COMMITTED — BURN IN PROGRESS +
+ )} +
+ ); +} + +// ── Card picker (inline, no modal) ──────────────────────────────────────────── + +function CardIdentityPicker({ + archetype, + district, + onArchetypeChange, + onDistrictChange, +}: { + archetype: Archetype | ""; + district: District | ""; + onArchetypeChange: (a: Archetype) => void; + onDistrictChange: (d: District) => void; +}) { + const { cards } = useCollection(); + + const uniqueArchetypes = useMemo((): Archetype[] => { + const seen = new Set(); + for (const card of cards) { + if (card?.prompts?.archetype) seen.add(card.prompts.archetype as Archetype); + } + return Array.from(seen); + }, [cards]); + + const uniqueDistricts = useMemo((): District[] => { + const seen = new Set(); + for (const card of cards) { + if (card?.prompts?.district) seen.add(card.prompts.district as District); + } + return Array.from(seen); + }, [cards]); + + if (cards.length === 0) { + return ( +
+

No forged cards found in your collection.

+

Visit the Card Forge to create your first courier.

+
+ ); + } + + return ( +
+
+ SELECT ACTIVE COVER IDENTITY & DISTRICT +
+ +
+ + + +
+
+ ); +} + +// ── Flash Courier page ──────────────────────────────────────────────────────── + +const EMPTY_SLOTS: NavDeckSlots = { vector: null, ghost: null, payload: null }; + +export function FlashCourier() { + const { user } = useAuth(); + const [archetype, setArchetype] = useState(""); + const [district, setDistrict] = useState(""); + const [slots, setSlots] = useState(EMPTY_SLOTS); + const [briefingNode, setBriefingNode] = useState(null); + const [chosenId, setChosenId] = useState(null); + + const handleSlotChange = useCallback( + (kind: ShardKind, shard: DataShard | null) => { + setSlots((prev) => ({ ...prev, [kind]: shard })); + }, + [], + ); + + const handleCompileComplete = useCallback(() => { + if (!slots.vector || !slots.ghost || !slots.payload || !archetype || !district) return; + const deck: CompiledNavDeck = { + navDeckId: `${user?.uid ?? "anon"}_${Date.now()}`, + uid: user?.uid ?? "", + archetype: archetype as Archetype, + district: district as District, + vector: slots.vector, + ghost: slots.ghost, + payload: slots.payload, + compiledAt: new Date().toISOString(), + }; + setBriefingNode(resolveRunBriefing(deck)); + setChosenId(null); + }, [slots, archetype, district, user?.uid]); + + const { compiling, compileProgress, compileLog, triggerCompile } = + useCompileAnimation(handleCompileComplete); + + const canCompile = + Boolean(archetype) && + Boolean(district) && + slots.vector !== null && + slots.ghost !== null && + slots.payload !== null; + + const handleCompile = useCallback(() => { + if (!canCompile) return; + setBriefingNode(null); + setChosenId(null); + triggerCompile(); + }, [canCompile, triggerCompile]); + + const handleChoose = useCallback((choice: BriefingChoice) => { + setChosenId(choice.id); + }, []); + + const handleReset = useCallback(() => { + setSlots(EMPTY_SLOTS); + setBriefingNode(null); + setChosenId(null); + }, []); + + if (!isEnabled("FLASH_COURIER", user?.email)) { + return ( +
+

Flash Courier is not yet available.

+
+ ); + } + + return ( +
+ {/* ── Page header ── */} +
+ + + {/* ── Identity picker ── */} + + + {/* ── Main layout: terminal + briefing ── */} +
+
+ +
+ +
+ {briefingNode ? ( + <> + + {chosenId && ( + + )} + + ) : ( +
+ +

Slot all three shards and compile to receive your Run Briefing.

+
+ )} +
+
+
+ ); +} diff --git a/src/pages/flashCourier/SplicerTerminal.tsx b/src/pages/flashCourier/SplicerTerminal.tsx new file mode 100644 index 00000000..1e6f20d8 --- /dev/null +++ b/src/pages/flashCourier/SplicerTerminal.tsx @@ -0,0 +1,430 @@ +import { + useCallback, + useEffect, + useId, + useRef, + useState, +} from "react"; +import type { DragEvent, KeyboardEvent } from "react"; +import { + ALL_SHARDS, + SHARD_KIND_COLORS, + SHARD_KIND_GLYPHS, + SHARD_KIND_LABELS, +} from "../../lib/flashCourier"; +import type { DataShard, NavDeckSlots, ShardKind } from "../../lib/flashCourier"; + +// ── Compile animation text lines ────────────────────────────────────────────── + +const COMPILE_LINES = [ + "> INITIALISING BURN-ROUTE COMPILER v4.3.1...", + "> HANDSHAKING WITH DISTRICT RELAY MESH...", + "> VALIDATING VECTOR SHARD INTEGRITY...", + "> RUNNING GHOST PROTOCOL STACK TRACE...", + "> LOADING PAYLOAD MODIFIER MANIFEST...", + "> CROSS-REFERENCING COVER IDENTITY TOKEN...", + "> ROUTING THROUGH ICE LAYER 1... BYPASSED", + "> ROUTING THROUGH ICE LAYER 2... BYPASSED", + "> ROUTING THROUGH ICE LAYER 3... CRACKING...", + "> ENTROPY SEED GENERATED: 0xF4C9A3E1", + "> NARRATIVE PATH RESOLVED — 3 BRANCHES LOADED", + "> BURN ROUTE COMPILED. BRIEFING READY.", +]; + +// ── Props ───────────────────────────────────────────────────────────────────── + +interface SplicerTerminalProps { + slots: NavDeckSlots; + onSlotChange: (kind: ShardKind, shard: DataShard | null) => void; + onCompile: () => void; + compiling: boolean; + compileProgress: number; // 0–1 + compileLog: string[]; +} + +// ── Shard card ──────────────────────────────────────────────────────────────── + +function ShardCard({ + shard, + onDragStart, + onKeySelect, + selected, + disabled, +}: { + shard: DataShard; + onDragStart: (shard: DataShard) => void; + onKeySelect: (shard: DataShard) => void; + selected: boolean; + disabled: boolean; +}) { + const color = SHARD_KIND_COLORS[shard.kind]; + const glyph = SHARD_KIND_GLYPHS[shard.kind]; + + const handleKeyDown = (e: KeyboardEvent) => { + if (disabled) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onKeySelect(shard); + } + }; + + return ( +
!disabled && onDragStart(shard)} + tabIndex={disabled ? -1 : 0} + role="option" + aria-selected={selected} + aria-disabled={disabled} + aria-label={`${shard.name}: ${shard.flavour}`} + onKeyDown={handleKeyDown} + onClick={() => !disabled && onKeySelect(shard)} + > +
+ {glyph} + {shard.name} +
+

{shard.flavour}

+ {shard.ozziesCost != null && shard.ozziesCost > 0 && ( + {shard.ozziesCost} OZ + )} +
+ ); +} + +// ── Slot zone ───────────────────────────────────────────────────────────────── + +function SlotZone({ + kind, + shard, + dragOver, + onDrop, + onDragOver, + onDragLeave, + onEject, + disabled, +}: { + kind: ShardKind; + shard: DataShard | null; + dragOver: boolean; + onDrop: (e: DragEvent) => void; + onDragOver: (e: DragEvent) => void; + onDragLeave: () => void; + onEject: () => void; + disabled: boolean; +}) { + const color = SHARD_KIND_COLORS[kind]; + const glyph = SHARD_KIND_GLYPHS[kind]; + const label = SHARD_KIND_LABELS[kind]; + const slotId = useId(); + + return ( +
+
+ {glyph} + {label} +
+ + {shard ? ( +
+
{shard.name}
+
{shard.flavour}
+ {!disabled && ( + + )} +
+ ) : ( +
+ + DROP or CLICK a {kind.toUpperCase()} SHARD + + + )} +
+ ); +} + +// ── Compile animation overlay ───────────────────────────────────────────────── + +function CompileOverlay({ log, progress }: { log: string[]; progress: number }) { + const logRef = useRef(null); + + useEffect(() => { + if (logRef.current) { + logRef.current.scrollTop = logRef.current.scrollHeight; + } + }, [log]); + + return ( +
+
+ ◈ COMPILING BURN ROUTE + {Math.round(progress * 100)}% +
+ +
+
+
+ +
+ {log.map((line, i) => ( +
+ {line} +
+ ))} + {log.length < COMPILE_LINES.length && ( + + )} +
+
+ ); +} + +// ── Main SplicerTerminal component ──────────────────────────────────────────── + +export function SplicerTerminal({ + slots, + onSlotChange, + onCompile, + compiling, + compileProgress, + compileLog, +}: SplicerTerminalProps) { + const [activeKind, setActiveKind] = useState("vector"); + const [dragShard, setDragShard] = useState(null); + const [dragOverSlot, setDragOverSlot] = useState(null); + + const allFilled = + slots.vector !== null && slots.ghost !== null && slots.payload !== null; + + const kinds: ShardKind[] = ["vector", "ghost", "payload"]; + + // ── Drag source handlers ────────────────────────────────────────────────── + + const handleDragStart = useCallback((shard: DataShard) => { + setDragShard(shard); + }, []); + + // ── Slot drop handlers ──────────────────────────────────────────────────── + + const handleSlotDrop = useCallback( + (kind: ShardKind) => (e: DragEvent) => { + e.preventDefault(); + if (!dragShard || dragShard.kind !== kind) return; + onSlotChange(kind, dragShard); + setDragShard(null); + setDragOverSlot(null); + }, + [dragShard, onSlotChange], + ); + + const handleDragOver = useCallback( + (kind: ShardKind) => (e: DragEvent) => { + e.preventDefault(); + setDragOverSlot(kind); + }, + [], + ); + + const handleDragLeave = useCallback(() => { + setDragOverSlot(null); + }, []); + + // ── Click-to-slot (keyboard/touch fallback) ─────────────────────────────── + + const handleKeySelect = useCallback( + (shard: DataShard) => { + // Clicking a shard equips it into its corresponding slot. + onSlotChange(shard.kind, shard); + }, + [onSlotChange], + ); + + const availableShards = ALL_SHARDS[activeKind]; + + return ( +
+ {/* ── Header ── */} +
+ + + {/* ── Slot zones ── */} +
+ {kinds.map((kind) => ( + onSlotChange(kind, null)} + disabled={compiling} + /> + ))} +
+ + {/* ── Shard browser ── */} +
+ {/* Kind tabs */} +
+ {kinds.map((kind) => ( + + ))} +
+ + {/* Shard list */} +
+ {availableShards.map((shard) => ( + + ))} +
+
+ + {/* ── Compile button ── */} +
+ +
+ + {/* ── Compile animation overlay ── */} + {compiling && ( + + )} +
+ ); +} + +// ── useCompileAnimation hook (co-located for modularity) ────────────────────── + +const COMPILE_STEP_INTERVAL_MS = 320; +const COMPILE_TOTAL_DURATION_MS = 4200; + +export interface CompileAnimationState { + compiling: boolean; + compileProgress: number; + compileLog: string[]; + triggerCompile: () => void; +} + +/** + * Drives the 3–5 second compile animation. + * Call `triggerCompile()` to start; `onComplete` fires when the animation ends. + */ +export function useCompileAnimation(onComplete: () => void): CompileAnimationState { + const [compiling, setCompiling] = useState(false); + const [compileProgress, setCompileProgress] = useState(0); + const [compileLog, setCompileLog] = useState([]); + const onCompleteRef = useRef(onComplete); + onCompleteRef.current = onComplete; + + const triggerCompile = useCallback(() => { + setCompiling(true); + setCompileProgress(0); + setCompileLog([]); + + const startTime = Date.now(); + let lineIndex = 0; + const lineInterval = setInterval(() => { + if (lineIndex < COMPILE_LINES.length) { + setCompileLog((prev) => [...prev, COMPILE_LINES[lineIndex]]); + lineIndex += 1; + } + }, COMPILE_STEP_INTERVAL_MS); + + const progressRaf = { id: 0 }; + const tick = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / COMPILE_TOTAL_DURATION_MS, 1); + setCompileProgress(progress); + if (progress < 1) { + progressRaf.id = requestAnimationFrame(tick); + } else { + clearInterval(lineInterval); + setCompiling(false); + onCompleteRef.current(); + } + }; + progressRaf.id = requestAnimationFrame(tick); + + return () => { + clearInterval(lineInterval); + cancelAnimationFrame(progressRaf.id); + }; + }, []); + + return { compiling, compileProgress, compileLog, triggerCompile }; +} From e5b08f5090218c8b2974e9c11cd87131f4c6306b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:48:19 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20code=20review=20=E2=80=94?= =?UTF-8?q?=20cleanup=20leak,=20CSS=20var,=20doc=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.css | 2 +- src/lib/runBriefing.ts | 2 +- src/pages/flashCourier/SplicerTerminal.tsx | 18 ++++++++++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/index.css b/src/index.css index 4fbfca91..a3cee19b 100644 --- a/src/index.css +++ b/src/index.css @@ -20997,7 +20997,7 @@ textarea:focus-visible, } .splicer-slot--drag-over { - background: rgba(var(--shard-color-rgb, 0, 255, 136), 0.08); + background: rgba(0, 255, 136, 0.08); border-color: var(--shard-color); outline: 1px dashed var(--shard-color); outline-offset: -3px; diff --git a/src/lib/runBriefing.ts b/src/lib/runBriefing.ts index c667fe94..7a5b9e2f 100644 --- a/src/lib/runBriefing.ts +++ b/src/lib/runBriefing.ts @@ -41,7 +41,7 @@ export interface StoryNode { title: string; /** * Multi-paragraph prose briefing (plain text; newlines are rendered as - * paragraph breaks by the RunBriefing component). + * paragraph breaks by the RunBriefingPanel component). */ prose: string[]; /** diff --git a/src/pages/flashCourier/SplicerTerminal.tsx b/src/pages/flashCourier/SplicerTerminal.tsx index 1e6f20d8..7dbe2c0e 100644 --- a/src/pages/flashCourier/SplicerTerminal.tsx +++ b/src/pages/flashCourier/SplicerTerminal.tsx @@ -381,8 +381,9 @@ export interface CompileAnimationState { } /** - * Drives the 3–5 second compile animation. + * Drives the ~4-second compile animation (COMPILE_TOTAL_DURATION_MS = 4200 ms). * Call `triggerCompile()` to start; `onComplete` fires when the animation ends. + * Any in-flight timers and rAF loops are cancelled on unmount. */ export function useCompileAnimation(onComplete: () => void): CompileAnimationState { const [compiling, setCompiling] = useState(false); @@ -391,7 +392,19 @@ export function useCompileAnimation(onComplete: () => void): CompileAnimationSta const onCompleteRef = useRef(onComplete); onCompleteRef.current = onComplete; + // Stores the active cleanup function so it can be called on unmount. + const cleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => { + return () => { + cleanupRef.current?.(); + }; + }, []); + const triggerCompile = useCallback(() => { + // Cancel any previous animation that may still be running. + cleanupRef.current?.(); + setCompiling(true); setCompileProgress(0); setCompileLog([]); @@ -414,13 +427,14 @@ export function useCompileAnimation(onComplete: () => void): CompileAnimationSta progressRaf.id = requestAnimationFrame(tick); } else { clearInterval(lineInterval); + cleanupRef.current = null; setCompiling(false); onCompleteRef.current(); } }; progressRaf.id = requestAnimationFrame(tick); - return () => { + cleanupRef.current = () => { clearInterval(lineInterval); cancelAnimationFrame(progressRaf.id); }; From 46dce8e3c9f56957cedc0b168b04325e99068942 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:50:32 +0000 Subject: [PATCH 3/5] fix: use District type directly, extract missingCount, fix review nits --- src/lib/runBriefing.ts | 12 +++++------- src/pages/flashCourier/SplicerTerminal.tsx | 5 ++++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/lib/runBriefing.ts b/src/lib/runBriefing.ts index 7a5b9e2f..ce1cf3de 100644 --- a/src/lib/runBriefing.ts +++ b/src/lib/runBriefing.ts @@ -14,6 +14,7 @@ */ import type { CompiledNavDeck } from "./flashCourier"; +import type { District } from "./types"; // ── Output types ────────────────────────────────────────────────────────────── @@ -61,9 +62,7 @@ function hasTags(tags: string[], ...checks: string[]): boolean { // ── District-level prose blocks ─────────────────────────────────────────────── -type DistrictKey = "Airaway" | "Batteryville" | "The Grid" | "Nightshade" | "The Forest" | "Glass City"; - -const DISTRICT_OPEN_LINES: Record = { +const DISTRICT_OPEN_LINES: Partial> = { Airaway: "The thermals over Airaway are rough tonight — the transit drones are running offset patterns to avoid a pressure ridge stalling across the elevated freight lane. Amber hazard strobes blink in loose sequence eight stories below your drop point.", Batteryville: @@ -78,7 +77,7 @@ const DISTRICT_OPEN_LINES: Record = { "Glass City doesn't hide anything — it puts it all in a display case. Every surface is reflective, every corner is archived to a cloud. The paradox is that the surveillance is so dense it folds on itself. Too much data reads as noise.", }; -const DISTRICT_CLOSE_LINES: Record = { +const DISTRICT_CLOSE_LINES: Partial> = { Airaway: "The wind shear at this altitude will cover a lot of sound. That's the only advantage the approach has given you.", Batteryville: @@ -279,10 +278,9 @@ function getChoices(payloadTags: string[], district: string): BriefingChoice[] { export function resolveRunBriefing(deck: CompiledNavDeck): StoryNode { const { archetype, district, vector, ghost, payload } = deck; - const districtKey = district as DistrictKey; - const openLine = DISTRICT_OPEN_LINES[districtKey] + const openLine = DISTRICT_OPEN_LINES[district] ?? `The district stretches out ahead of you — ${district} never sleeps.`; - const closeLine = DISTRICT_CLOSE_LINES[districtKey] + const closeLine = DISTRICT_CLOSE_LINES[district] ?? "The window is tight. You know what needs to happen."; const vectorLine = getVectorProse(vector.tags); diff --git a/src/pages/flashCourier/SplicerTerminal.tsx b/src/pages/flashCourier/SplicerTerminal.tsx index 7dbe2c0e..77d6cd03 100644 --- a/src/pages/flashCourier/SplicerTerminal.tsx +++ b/src/pages/flashCourier/SplicerTerminal.tsx @@ -354,7 +354,10 @@ export function SplicerTerminal({ ) : ( - {allFilled ? "⚡ COMPILE BURN ROUTE" : `${kinds.filter((k) => slots[k] === null).length} SHARD${kinds.filter((k) => slots[k] === null).length !== 1 ? "S" : ""} MISSING`} + {allFilled ? "⚡ COMPILE BURN ROUTE" : (() => { + const missingCount = kinds.filter((k) => slots[k] === null).length; + return `${missingCount} SHARD${missingCount !== 1 ? "S" : ""} MISSING`; + })()} )} From 9b803ec93fe29ac80f1883e4bccda87e1c96e6ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:52:29 +0000 Subject: [PATCH 4/5] fix: extract missingCount var, use let for rAF id, use crypto.randomUUID for navDeckId --- src/pages/FlashCourier.tsx | 2 +- src/pages/flashCourier/SplicerTerminal.tsx | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/pages/FlashCourier.tsx b/src/pages/FlashCourier.tsx index 0c869f62..d6640063 100644 --- a/src/pages/FlashCourier.tsx +++ b/src/pages/FlashCourier.tsx @@ -193,7 +193,7 @@ export function FlashCourier() { const handleCompileComplete = useCallback(() => { if (!slots.vector || !slots.ghost || !slots.payload || !archetype || !district) return; const deck: CompiledNavDeck = { - navDeckId: `${user?.uid ?? "anon"}_${Date.now()}`, + navDeckId: crypto.randomUUID(), uid: user?.uid ?? "", archetype: archetype as Archetype, district: district as District, diff --git a/src/pages/flashCourier/SplicerTerminal.tsx b/src/pages/flashCourier/SplicerTerminal.tsx index 77d6cd03..85f08bbf 100644 --- a/src/pages/flashCourier/SplicerTerminal.tsx +++ b/src/pages/flashCourier/SplicerTerminal.tsx @@ -266,6 +266,7 @@ export function SplicerTerminal({ ); const availableShards = ALL_SHARDS[activeKind]; + const missingCount = kinds.filter((k) => slots[k] === null).length; return (
@@ -354,10 +355,10 @@ export function SplicerTerminal({ ) : ( - {allFilled ? "⚡ COMPILE BURN ROUTE" : (() => { - const missingCount = kinds.filter((k) => slots[k] === null).length; - return `${missingCount} SHARD${missingCount !== 1 ? "S" : ""} MISSING`; - })()} + {allFilled + ? "⚡ COMPILE BURN ROUTE" + : `${missingCount} SHARD${missingCount !== 1 ? "S" : ""} MISSING` + } )} @@ -421,13 +422,13 @@ export function useCompileAnimation(onComplete: () => void): CompileAnimationSta } }, COMPILE_STEP_INTERVAL_MS); - const progressRaf = { id: 0 }; + let progressRafId = 0; const tick = () => { const elapsed = Date.now() - startTime; const progress = Math.min(elapsed / COMPILE_TOTAL_DURATION_MS, 1); setCompileProgress(progress); if (progress < 1) { - progressRaf.id = requestAnimationFrame(tick); + progressRafId = requestAnimationFrame(tick); } else { clearInterval(lineInterval); cleanupRef.current = null; @@ -435,11 +436,11 @@ export function useCompileAnimation(onComplete: () => void): CompileAnimationSta onCompleteRef.current(); } }; - progressRaf.id = requestAnimationFrame(tick); + progressRafId = requestAnimationFrame(tick); cleanupRef.current = () => { clearInterval(lineInterval); - cancelAnimationFrame(progressRaf.id); + cancelAnimationFrame(progressRafId); }; }, []); From 7b0e38e545eb0f170980af13b626fda1dbef7594 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:25:25 +0000 Subject: [PATCH 5/5] fix: resolve CI lint errors in language profile and splicer terminal --- src/components/LanguageProfilePanel.tsx | 3 ++- src/pages/flashCourier/SplicerTerminal.tsx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/LanguageProfilePanel.tsx b/src/components/LanguageProfilePanel.tsx index 51fc775a..fdf74bfc 100644 --- a/src/components/LanguageProfilePanel.tsx +++ b/src/components/LanguageProfilePanel.tsx @@ -23,7 +23,6 @@ import type { CraftlinguaEnvelope } from "../lib/types"; import { CRAFTLINGUA_ENABLED } from "../lib/craftlingua"; export function LanguageProfilePanel() { - if (!CRAFTLINGUA_ENABLED) return null; const { profile, vocabulary, useCraftlingua, loadProfile, clearProfile, setUseCraftlingua } = useLanguage(); const { tier, openUpgradeModal } = useTier(); const canUseCraftlingua = TIERS[tier].canUseCraftlingua; @@ -69,6 +68,8 @@ export function LanguageProfilePanel() { } }, [pasteText, applyProfile]); + if (!CRAFTLINGUA_ENABLED) return null; + // ── Locked state (free tier) ───────────────────────────────────────────────── if (!canUseCraftlingua) { diff --git a/src/pages/flashCourier/SplicerTerminal.tsx b/src/pages/flashCourier/SplicerTerminal.tsx index 85f08bbf..fc0dd7ad 100644 --- a/src/pages/flashCourier/SplicerTerminal.tsx +++ b/src/pages/flashCourier/SplicerTerminal.tsx @@ -389,6 +389,7 @@ export interface CompileAnimationState { * Call `triggerCompile()` to start; `onComplete` fires when the animation ends. * Any in-flight timers and rAF loops are cancelled on unmount. */ +// eslint-disable-next-line react-refresh/only-export-components export function useCompileAnimation(onComplete: () => void): CompileAnimationState { const [compiling, setCompiling] = useState(false); const [compileProgress, setCompileProgress] = useState(0);