diff --git a/styles/zero-engine.css b/styles/zero-engine.css index 7b44d3d..eadd5df 100644 --- a/styles/zero-engine.css +++ b/styles/zero-engine.css @@ -4671,3 +4671,1847 @@ body > .ze-pause-root .ze-sat-inner { font-size: 10px; padding: 0 1px; line-height: 1; } .gm-status-window .gsw-remove-cond:hover { color: #ff0000; } + + +/* ══════════════════════════════════════════════════════════════════════════ + SLA GM TOOLS — v1.7.0 + ══════════════════════════════════════════════════════════════════════════ */ + + +/* ── 💳 CREDIT DISTRIBUTION TOOL ──────────────────────────────────────────────────── */ +/* ============================================================ + SLA Credit Distribution Tool — .credit-tool-window scoped + Dark sci-fi aesthetic: navy/midnight backgrounds, cyan + orange neon + ============================================================ */ + +.credit-tool-window .window-content { + background: #0d1117; + color: #c9d1d9; + padding: 0; + font-family: "SLA Borg UI", "Rajdhani", "Oxanium", monospace, sans-serif; +} + +/* ── Wrapper ─────────────────────────────────────────────── */ +.credit-tool-window .ctw-wrapper { + display: flex; + flex-direction: column; + gap: 0; +} + +/* ── Section containers ──────────────────────────────────── */ +.credit-tool-window .ctw-section { + padding: 10px 12px; + border-bottom: 1px solid #1e2d3d; +} + +.credit-tool-window .ctw-section:last-child { + border-bottom: none; +} + +.credit-tool-window .ctw-section-title { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #00d4ff; + margin: 0 0 8px 0; + padding-bottom: 5px; + border-bottom: 1px solid rgba(0, 212, 255, 0.25); +} + +/* ── Table ───────────────────────────────────────────────── */ +.credit-tool-window .ctw-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} + +.credit-tool-window .ctw-header-row { + background: #111927; +} + +.credit-tool-window .ctw-th { + padding: 5px 6px; + text-align: center; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #7d8fa1; + border-bottom: 1px solid #1e2d3d; + white-space: nowrap; +} + +.credit-tool-window .ctw-th-name { + text-align: left; +} + +.credit-tool-window .ctw-row { + transition: background 0.15s ease; + border-bottom: 1px solid #161d27; +} + +.credit-tool-window .ctw-row:hover { + background: rgba(0, 212, 255, 0.04); +} + +.credit-tool-window .ctw-row:last-child { + border-bottom: none; +} + +.credit-tool-window .ctw-cell { + padding: 5px 6px; + text-align: center; + vertical-align: middle; + font-family: "Courier New", "Consolas", monospace; + font-size: 11px; +} + +/* ── Name cell ───────────────────────────────────────────── */ +.credit-tool-window .ctw-name { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 6px; + text-align: left; + white-space: nowrap; +} + +.credit-tool-window .ctw-portrait { + width: 28px; + height: 28px; + border-radius: 3px; + object-fit: cover; + border: 1px solid #2a3a4a; + flex-shrink: 0; +} + +.credit-tool-window .ctw-actor-name { + font-size: 11px; + font-weight: 600; + color: #e6edf3; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 120px; +} + +.credit-tool-window .ctw-scl { + font-size: 9px; + color: #7d8fa1; + font-family: "Courier New", monospace; + flex-shrink: 0; +} + +/* ── Credits input ───────────────────────────────────────── */ +.credit-tool-window .ctw-credits-input { + width: 70px; + background: #0d1117; + border: 1px solid #2a3a4a; + border-radius: 3px; + color: #00d4ff; + font-family: "Courier New", "Consolas", monospace; + font-size: 11px; + font-weight: 700; + text-align: center; + padding: 3px 4px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.credit-tool-window .ctw-credits-input:focus { + outline: none; + border-color: #00d4ff; + box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.2); +} + +/* Remove browser spin buttons */ +.credit-tool-window .ctw-credits-input::-webkit-inner-spin-button, +.credit-tool-window .ctw-credits-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* ── Colour helpers ──────────────────────────────────────── */ +.credit-tool-window .ctw-positive { + color: #3fb950; + font-weight: 700; +} + +.credit-tool-window .ctw-negative { + color: #f85149; + font-weight: 700; +} + +.credit-tool-window .ctw-greyed { + color: #7d8fa1; +} + +.credit-tool-window .ctw-debt { + color: #ff6b35; + font-weight: 700; +} + +/* ── Empty state ─────────────────────────────────────────── */ +.credit-tool-window .ctw-empty { + text-align: center; + padding: 18px 12px; + color: #7d8fa1; + font-size: 11px; + font-style: italic; +} + +/* ── Buttons ─────────────────────────────────────────────── */ +.credit-tool-window .ctw-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 10px; + border-radius: 3px; + border: 1px solid transparent; + font-family: "SLA Borg UI", "Rajdhani", monospace, sans-serif; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.credit-tool-window .ctw-btn-save { + background: rgba(0, 212, 255, 0.1); + border-color: rgba(0, 212, 255, 0.4); + color: #00d4ff; +} + +.credit-tool-window .ctw-btn-save:hover { + background: rgba(0, 212, 255, 0.22); + border-color: #00d4ff; + box-shadow: 0 0 6px rgba(0, 212, 255, 0.3); +} + +.credit-tool-window .ctw-btn-pay-all { + background: rgba(63, 185, 80, 0.12); + border-color: rgba(63, 185, 80, 0.5); + color: #3fb950; +} + +.credit-tool-window .ctw-btn-pay-all:hover { + background: rgba(63, 185, 80, 0.25); + border-color: #3fb950; + box-shadow: 0 0 6px rgba(63, 185, 80, 0.3); +} + +.credit-tool-window .ctw-btn-danger { + background: rgba(248, 81, 73, 0.1); + border-color: rgba(248, 81, 73, 0.4); + color: #f85149; +} + +.credit-tool-window .ctw-btn-danger:hover { + background: rgba(248, 81, 73, 0.22); + border-color: #f85149; + box-shadow: 0 0 6px rgba(248, 81, 73, 0.3); +} + +.credit-tool-window .ctw-btn:active { + transform: scale(0.96); +} + +/* ── Bulk panel ──────────────────────────────────────────── */ +.credit-tool-window .ctw-bulk-panel { + background: rgba(0, 212, 255, 0.02); +} + +.credit-tool-window .ctw-bulk-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.credit-tool-window .ctw-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #7d8fa1; + white-space: nowrap; +} + +.credit-tool-window .ctw-bulk-amount { + width: 80px; +} + +.credit-tool-window .ctw-radio-group { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.credit-tool-window .ctw-radio-label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + font-weight: 600; + color: #c9d1d9; + cursor: pointer; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.credit-tool-window .ctw-radio-label input[type="radio"] { + accent-color: #ff6b35; + width: 12px; + height: 12px; + cursor: pointer; +} + +.credit-tool-window .ctw-radio-label:hover { + color: #ff6b35; +} + +.credit-tool-window .ctw-bulk-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-left: auto; +} + +/* ── Scrollable tbody if many actors ─────────────────────── */ +.credit-tool-window .ctw-table tbody { + display: table-row-group; +} + +.credit-tool-window.credit-tool-window .window-content { + overflow-y: auto; +} + +/* ── 📒 SHIFT LEDGER TOOL ──────────────────────────────────────────────────── */ +/* ═══════════════════════════════════════════════════════════════════════════ + SLA Shift Ledger — temp_ledger.css + All rules scoped to .shift-ledger-window + ═══════════════════════════════════════════════════════════════════════════ */ + +.shift-ledger-window .window-content { + padding: 0; + background: #0d0d1a; + color: #c8d0e0; + font-family: 'Signika', 'Palatino Linotype', serif; +} + +/* ── Root container ────────────────────────────────────────────────────────── */ +.shift-ledger-window .slw-root { + display: flex; + flex-direction: column; + gap: 0; + min-height: 100%; +} + +/* ── Shared colour utilities ───────────────────────────────────────────────── */ +.shift-ledger-window .slw-positive { color: #44cc66; } +.shift-ledger-window .slw-negative { color: #cc2222; } +.shift-ledger-window .slw-debt { color: #ff6b35; } +.shift-ledger-window .slw-credits { color: #00d4ff; } +.shift-ledger-window .slw-neutral { color: #888; } +.shift-ledger-window .slw-mono { font-family: 'Courier New', Courier, monospace; } + +/* ── Summary bar ───────────────────────────────────────────────────────────── */ +.shift-ledger-window .slw-summary-bar { + display: flex; + flex-wrap: wrap; + gap: 0; + background: linear-gradient(135deg, #16213e 0%, #1a1a2e 100%); + border-bottom: 2px solid #00d4ff44; + padding: 10px 14px; +} + +.shift-ledger-window .slw-summary-item { + flex: 1 1 120px; + display: flex; + flex-direction: column; + align-items: center; + padding: 6px 10px; + border-right: 1px solid #ffffff12; +} + +.shift-ledger-window .slw-summary-item:last-child { + border-right: none; +} + +.shift-ledger-window .slw-summary-label { + font-size: 0.68em; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #8899aa; + margin-bottom: 3px; + white-space: nowrap; +} + +.shift-ledger-window .slw-summary-value { + font-family: 'Courier New', Courier, monospace; + font-size: 1.25em; + font-weight: bold; +} + +/* ── PC list container ─────────────────────────────────────────────────────── */ +.shift-ledger-window .slw-pc-list { + display: flex; + flex-direction: column; + gap: 0; + padding: 10px 10px 4px 10px; +} + +.shift-ledger-window .slw-empty { + color: #667; + font-style: italic; + text-align: center; + padding: 20px; +} + +/* ── Individual PC card ────────────────────────────────────────────────────── */ +.shift-ledger-window .slw-pc-card { + background: #12122a; + border: 1px solid #2a2a4a; + border-left: 3px solid #00d4ff55; + border-radius: 4px; + margin-bottom: 8px; + overflow: hidden; + transition: border-left-color 0.15s ease; +} + +.shift-ledger-window .slw-pc-card:hover { + border-left-color: #00d4ff; +} + +/* PC card header */ +.shift-ledger-window .slw-pc-header { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 12px; + background: linear-gradient(90deg, #1a1a3a 0%, #16213e 100%); + border-bottom: 1px solid #2a2a4a; +} + +.shift-ledger-window .slw-pc-name { + flex: 1; + font-size: 1em; + font-weight: bold; + color: #e0e8f0; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.shift-ledger-window .slw-scl-badge { + background: #ff6b3522; + color: #ff6b35; + border: 1px solid #ff6b3566; + border-radius: 3px; + font-size: 0.72em; + font-weight: bold; + padding: 2px 7px; + letter-spacing: 0.06em; + white-space: nowrap; +} + +.shift-ledger-window .slw-pc-credits { + font-family: 'Courier New', Courier, monospace; + font-size: 1.15em; + font-weight: bold; + color: #00d4ff; + white-space: nowrap; +} + +/* PC card body — three columns */ +.shift-ledger-window .slw-pc-body { + display: flex; + gap: 0; + padding: 8px 0 10px 0; +} + +.shift-ledger-window .slw-finance-col { + flex: 1; + padding: 0 12px; + border-right: 1px solid #1e1e3a; +} + +.shift-ledger-window .slw-finance-col:last-child { + border-right: none; +} + +.shift-ledger-window .slw-finance-col--summary { + flex: 0 0 160px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.shift-ledger-window .slw-col-title { + font-size: 0.7em; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #667788; + margin-bottom: 5px; + padding-bottom: 3px; + border-bottom: 1px solid #1e2a3a; +} + +/* Finance rows */ +.shift-ledger-window .slw-finance-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 1px 0; + font-size: 0.8em; +} + +.shift-ledger-window .slw-finance-label { + color: #8899aa; + white-space: nowrap; + margin-right: 8px; +} + +.shift-ledger-window .slw-finance-val { + text-align: right; + min-width: 52px; + color: #b0bcc8; +} + +/* Total row emphasis */ +.shift-ledger-window .slw-total-row { + border-top: 1px solid #2a2a4a; + margin-top: 3px; + padding-top: 3px; + font-weight: bold; +} + +.shift-ledger-window .slw-total-row .slw-finance-label { + color: #aabbc8; +} + +/* ── Buttons ───────────────────────────────────────────────────────────────── */ +.shift-ledger-window .slw-btn { + display: inline-block; + padding: 5px 10px; + font-size: 0.78em; + font-family: 'Signika', sans-serif; + font-weight: bold; + letter-spacing: 0.04em; + border: none; + border-radius: 3px; + cursor: pointer; + transition: filter 0.12s ease, transform 0.08s ease; + white-space: nowrap; +} + +.shift-ledger-window .slw-btn:hover { + filter: brightness(1.2); + transform: translateY(-1px); +} + +.shift-ledger-window .slw-btn:active { + transform: translateY(0); + filter: brightness(0.9); +} + +/* Per-PC process button */ +.shift-ledger-window .slw-btn--process { + background: linear-gradient(135deg, #1a3a2a 0%, #224433 100%); + color: #44cc66; + border: 1px solid #44cc6655; + margin-top: 8px; + width: 100%; +} + +.shift-ledger-window .slw-btn--process:hover { + background: linear-gradient(135deg, #224433 0%, #2a5540 100%); + border-color: #44cc66aa; +} + +/* ── Action bar (bottom) ───────────────────────────────────────────────────── */ +.shift-ledger-window .slw-action-bar { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 8px 12px 10px 12px; + background: #0d0d1a; + border-top: 1px solid #1e1e3a; +} + +/* Process all */ +.shift-ledger-window .slw-btn--all { + background: linear-gradient(135deg, #1a2a3a 0%, #162030 100%); + color: #00d4ff; + border: 1px solid #00d4ff55; +} + +.shift-ledger-window .slw-btn--all:hover { + background: linear-gradient(135deg, #1e3040 0%, #1a2838 100%); + border-color: #00d4ffaa; +} + +/* Export to chat */ +.shift-ledger-window .slw-btn--export { + background: linear-gradient(135deg, #2a1a2a 0%, #221422 100%); + color: #cc88ff; + border: 1px solid #cc88ff55; +} + +.shift-ledger-window .slw-btn--export:hover { + background: linear-gradient(135deg, #311e31 0%, #291828 100%); + border-color: #cc88ffaa; +} + +/* ── Scrollbar theming ─────────────────────────────────────────────────────── */ +.shift-ledger-window .window-content::-webkit-scrollbar { + width: 6px; +} + +.shift-ledger-window .window-content::-webkit-scrollbar-track { + background: #0d0d1a; +} + +.shift-ledger-window .window-content::-webkit-scrollbar-thumb { + background: #2a2a4a; + border-radius: 3px; +} + +.shift-ledger-window .window-content::-webkit-scrollbar-thumb:hover { + background: #00d4ff44; +} + +/* ── ☠ NPC THREAT BOARD ──────────────────────────────────────────────────── */ +/* ────────────────────────────────────────────────────────────────────────────── + SLANPCThreatBoard — dark sci-fi NPC tactical overview + All rules scoped to .npc-threat-board + ────────────────────────────────────────────────────────────────────────────── */ + +/* ── Window shell ─────────────────────────────────────────────────────────── */ +.npc-threat-board { + background: #0d0d1a; + color: #c8d0e0; + font-family: 'Helvetica Neue', Arial, sans-serif; + font-size: 12px; +} + +.npc-threat-board .window-content { + background: #0d0d1a; + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* ── Outer wrapper ────────────────────────────────────────────────────────── */ +.npc-threat-board .ntb-wrapper { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +/* ── Filter bar ───────────────────────────────────────────────────────────── */ +.npc-threat-board .ntb-filterbar { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + align-items: center; + padding: 6px 8px; + background: #111126; + border-bottom: 1px solid #2a2a4a; + flex-shrink: 0; +} + +.npc-threat-board .ntb-filter-group { + display: flex; + align-items: center; + gap: 3px; +} + +.npc-threat-board .ntb-sort-label { + color: #6a7090; + font-size: 11px; + margin-right: 2px; +} + +.npc-threat-board .ntb-filter-btn, +.npc-threat-board .ntb-threat-filter, +.npc-threat-board .ntb-sort-btn { + background: #1a1a2e; + border: 1px solid #2e2e50; + color: #8890aa; + border-radius: 3px; + padding: 2px 7px; + font-size: 11px; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + white-space: nowrap; + line-height: 1.6; +} + +.npc-threat-board .ntb-filter-btn:hover, +.npc-threat-board .ntb-threat-filter:hover, +.npc-threat-board .ntb-sort-btn:hover { + background: #22224a; + color: #c8d0e0; + border-color: #4a4a88; +} + +.npc-threat-board .ntb-filter-btn.ntb-active, +.npc-threat-board .ntb-threat-filter.ntb-active, +.npc-threat-board .ntb-sort-btn.ntb-active { + background: #2a2a5c; + color: #e0e8ff; + border-color: #5555aa; +} + +/* Active threat filters — color-coded active state */ +.npc-threat-board .ntb-threat-filter[data-threat="low"].ntb-active { background: #1a3a22; border-color: #44cc66; color: #7deeaa; } +.npc-threat-board .ntb-threat-filter[data-threat="med"].ntb-active { background: #3a2e10; border-color: #ffaa00; color: #ffd055; } +.npc-threat-board .ntb-threat-filter[data-threat="high"].ntb-active { background: #3a1212; border-color: #cc2222; color: #ff6666; } +.npc-threat-board .ntb-threat-filter[data-threat="elite"].ntb-active{ background: #2a1040; border-color: #9933cc; color: #cc77ff; } + +/* Dead toggle active */ +.npc-threat-board .ntb-toggle-dead.ntb-active { + background: #2a1010; + border-color: #882222; + color: #ff8888; +} + +/* ── Scrollable card grid ─────────────────────────────────────────────────── */ +.npc-threat-board .ntb-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px; + overflow-y: auto; + flex: 1 1 0; + align-content: flex-start; +} + +/* Thin scrollbar */ +.npc-threat-board .ntb-grid::-webkit-scrollbar { width: 6px; } +.npc-threat-board .ntb-grid::-webkit-scrollbar-track { background: #0d0d1a; } +.npc-threat-board .ntb-grid::-webkit-scrollbar-thumb { background: #2a2a4a; border-radius: 3px; } +.npc-threat-board .ntb-grid::-webkit-scrollbar-thumb:hover { background: #4a4a88; } + +/* ── Empty state ──────────────────────────────────────────────────────────── */ +.npc-threat-board .ntb-empty { + width: 100%; + text-align: center; + padding: 40px 20px; + color: #4a5070; + font-style: italic; + font-size: 13px; +} + +/* ── NPC Card ─────────────────────────────────────────────────────────────── */ +.npc-threat-board .ntb-card { + position: relative; + background: #13132a; + border: 1px solid #2a2a4a; + border-radius: 5px; + padding: 7px 8px 6px; + width: 175px; + flex-shrink: 0; + cursor: pointer; + transition: background 0.15s, border-color 0.2s, transform 0.1s; + overflow: hidden; +} + +.npc-threat-board .ntb-card:hover { + background: #1a1a38; + border-color: #4a4a88; + transform: translateY(-1px); +} + +/* Card threat border accent — left bar */ +.npc-threat-board .ntb-card::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + border-radius: 5px 0 0 5px; +} +.npc-threat-board .ntb-card.threat-low::before { background: #44cc66; } +.npc-threat-board .ntb-card.threat-med::before { background: #ffaa00; } +.npc-threat-board .ntb-card.threat-high::before { background: #cc3333; } +.npc-threat-board .ntb-card.threat-elite::before { background: #9933cc; } + +/* Dead card dimming */ +.npc-threat-board .ntb-card.ntb-dead { + opacity: 0.55; + filter: grayscale(60%); +} + +/* Dead skull overlay */ +.npc-threat-board .ntb-skull-overlay { + position: absolute; + top: 4px; right: 5px; + font-size: 16px; + pointer-events: none; + opacity: 0.8; + z-index: 2; +} + +/* ── Card header: portrait + name + threat badge ──────────────────────────── */ +.npc-threat-board .ntb-card-header { + display: flex; + align-items: flex-start; + gap: 7px; + margin-bottom: 6px; +} + +.npc-threat-board .ntb-portrait-wrap { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid #2a2a4a; + overflow: hidden; +} + +/* Portrait border color mirrors threat */ +.npc-threat-board .ntb-border-threat-low { border-color: #44cc66; } +.npc-threat-board .ntb-border-threat-med { border-color: #ffaa00; } +.npc-threat-board .ntb-border-threat-high { border-color: #cc3333; } +.npc-threat-board .ntb-border-threat-elite { border-color: #9933cc; } + +.npc-threat-board .ntb-portrait { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.npc-threat-board .ntb-name-block { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 1px; +} + +.npc-threat-board .ntb-name { + font-weight: bold; + font-size: 12px; + color: #d8e0f0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +/* Scene indicator badge */ +.npc-threat-board .ntb-scene-badge { + display: inline-block; + margin-left: 4px; + color: #44aaff; + font-size: 10px; + vertical-align: middle; +} + +/* ── Threat badge ─────────────────────────────────────────────────────────── */ +.npc-threat-board .ntb-threat-badge { + display: inline-block; + font-size: 10px; + font-weight: bold; + padding: 1px 5px; + border-radius: 3px; + letter-spacing: 0.04em; + text-transform: uppercase; + line-height: 1.5; + align-self: flex-start; +} + +.npc-threat-board .ntb-threat-badge.threat-low { background: #1a3a22; color: #7deeaa; border: 1px solid #44cc66; } +.npc-threat-board .ntb-threat-badge.threat-med { background: #3a2e10; color: #ffd055; border: 1px solid #ffaa00; } +.npc-threat-board .ntb-threat-badge.threat-high { background: #3a1212; color: #ff7070; border: 1px solid #cc3333; } +.npc-threat-board .ntb-threat-badge.threat-elite { background: #2a1040; color: #cc77ff; border: 1px solid #9933cc; } + +/* ── HP bar + controls ────────────────────────────────────────────────────── */ +.npc-threat-board .ntb-hp-row { + margin-bottom: 5px; +} + +.npc-threat-board .ntb-hp-track { + width: 100%; + height: 6px; + background: #0a0a18; + border-radius: 3px; + overflow: hidden; + margin-bottom: 3px; + border: 1px solid #1e1e38; +} + +.npc-threat-board .ntb-hp-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease, background 0.3s ease; + min-width: 1px; +} + +.npc-threat-board .ntb-hp-controls { + display: flex; + align-items: center; + gap: 4px; + justify-content: center; +} + +.npc-threat-board .ntb-hp-text { + font-size: 11px; + color: #9090b8; + min-width: 40px; + text-align: center; + flex: 1; +} + +.npc-threat-board .ntb-hp-btn { + background: #1c1c38; + border: 1px solid #2e2e50; + color: #8890aa; + border-radius: 3px; + width: 18px; + height: 18px; + font-size: 14px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; + transition: background 0.15s, color 0.15s; +} + +.npc-threat-board .ntb-hp-btn:hover { + background: #28284e; + color: #d0d8ee; + border-color: #5555aa; +} + +.npc-threat-board .ntb-hp-dec:hover { color: #ff6666; border-color: #882222; } +.npc-threat-board .ntb-hp-inc:hover { color: #66dd88; border-color: #226644; } + +/* ── Attribute mini-grid ──────────────────────────────────────────────────── */ +.npc-threat-board .ntb-attrs { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 3px; + margin-bottom: 5px; + background: #0f0f22; + border-radius: 3px; + padding: 4px 2px; + border: 1px solid #1e1e38; +} + +.npc-threat-board .ntb-attr { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; +} + +.npc-threat-board .ntb-attr-label { + font-size: 9px; + color: #5a6080; + text-transform: uppercase; + letter-spacing: 0.06em; + line-height: 1; +} + +.npc-threat-board .ntb-attr-val { + font-size: 13px; + font-weight: bold; + color: #b8c8e0; + line-height: 1.1; +} + +/* ── Damage / Armor badges + status dots ──────────────────────────────────── */ +.npc-threat-board .ntb-badges { + display: flex; + align-items: center; + gap: 5px; + flex-wrap: wrap; +} + +.npc-threat-board .ntb-badge { + display: inline-block; + font-size: 10px; + padding: 1px 5px; + border-radius: 3px; + line-height: 1.5; + white-space: nowrap; +} + +.npc-threat-board .ntb-badge-dmg { + background: #2a1010; + color: #ff9090; + border: 1px solid #551111; +} + +.npc-threat-board .ntb-badge-armor { + background: #10202a; + color: #70bbee; + border: 1px solid #11334a; +} + +.npc-threat-board .ntb-statuses { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-items: center; +} + +.npc-threat-board .ntb-status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #aa8800; + border: 1px solid #ffcc44; + cursor: default; +} + +/* ── 📋 BPN TRACKER ──────────────────────────────────────────────────── */ +/* ============================================================ + BPN Tracker — scoped to .bpn-tracker-window + Dark sci-fi aesthetic, SLA Industries + ============================================================ */ + +.bpn-tracker-window .window-content { + background: #0f1117; + color: #c8cdd8; + font-family: 'Roboto', 'Arial', sans-serif; + font-size: 13px; + padding: 0; + overflow-y: auto; +} + +/* ── Inner wrapper ─────────────────────────────────────────── */ +.bpn-tracker-window .bpn-tracker-inner { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; +} + +/* ── Empty state ───────────────────────────────────────────── */ +.bpn-tracker-window .bpn-empty-state { + color: #555; + font-style: italic; + text-align: center; + padding: 20px 0; +} + +/* ── Mission card ──────────────────────────────────────────── */ +.bpn-tracker-window .bpn-mission-card { + background: #14181f; + border: 1px solid #2a2d35; + border-left: 4px solid #3399ff; /* overridden inline per type */ + border-radius: 4px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Card header row */ +.bpn-tracker-window .bpn-card-header { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +/* BPN code — monospace, prominent */ +.bpn-tracker-window .bpn-code { + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + font-weight: 700; + color: #e0e6f0; + letter-spacing: 0.04em; + flex-shrink: 0; +} + +/* Type badge */ +.bpn-tracker-window .bpn-type-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 7px; + border-radius: 3px; + border: 1px solid; + letter-spacing: 0.05em; + text-transform: uppercase; + flex-shrink: 0; +} + +/* Status badge */ +.bpn-tracker-window .bpn-status-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 7px; + border-radius: 3px; + letter-spacing: 0.04em; + text-transform: uppercase; + flex-shrink: 0; +} + +.bpn-tracker-window .bpn-status-pending { background: #2a2a2a; color: #888; border: 1px solid #555; } +.bpn-tracker-window .bpn-status-active { background: #0d2a2a; color: #00e5e5; border: 1px solid #00bfbf; } +.bpn-tracker-window .bpn-status-complete { background: #0d2a1a; color: #44dd66; border: 1px solid #33aa44; } +.bpn-tracker-window .bpn-status-failed { background: #2a0d0d; color: #ff5555; border: 1px solid #cc2222; } + +/* Clear button — right-aligned */ +.bpn-tracker-window .bpn-clear-btn { + margin-left: auto; + background: transparent; + border: 1px solid #443333; + color: #885555; + font-size: 11px; + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; +} +.bpn-tracker-window .bpn-clear-btn:hover { + background: #3a1a1a; + color: #ff6666; + border-color: #cc2222; +} + +/* ── Card rows ─────────────────────────────────────────────── */ +.bpn-tracker-window .bpn-card-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.bpn-tracker-window .bpn-label { + font-size: 11px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.06em; + flex-shrink: 0; +} + +.bpn-tracker-window .bpn-label-type { + margin-left: 8px; +} + +.bpn-tracker-window .bpn-status-select, +.bpn-tracker-window .bpn-type-select { + background: #1a1e26; + border: 1px solid #2e323c; + color: #c8cdd8; + font-size: 12px; + padding: 3px 6px; + border-radius: 3px; + cursor: pointer; +} +.bpn-tracker-window .bpn-status-select:focus, +.bpn-tracker-window .bpn-type-select:focus { + outline: none; + border-color: #3399ff; +} + +/* Reward row */ +.bpn-tracker-window .bpn-reward-row { + align-items: center; +} + +.bpn-tracker-window .bpn-reward-label { + font-size: 12px; + color: #8899aa; +} +.bpn-tracker-window .bpn-reward-label strong { + color: #c8b46e; + font-family: 'Courier New', Courier, monospace; +} + +/* Pay Out button — gold/amber */ +.bpn-tracker-window .bpn-payout-btn { + background: #3a2a00; + border: 1px solid #8a6a00; + color: #ffc233; + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: 3px; + cursor: pointer; + letter-spacing: 0.04em; + transition: background 0.15s; + margin-left: auto; +} +.bpn-tracker-window .bpn-payout-btn:hover { + background: #5a4400; + border-color: #ccaa00; + color: #ffe066; +} + +/* ── Description / Objectives fields ──────────────────────── */ +.bpn-tracker-window .bpn-card-field { + display: flex; + flex-direction: column; + gap: 3px; +} + +.bpn-tracker-window .bpn-textarea { + background: #0d1014; + border: 1px solid #252830; + color: #b0b8c8; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + line-height: 1.5; + padding: 6px 8px; + border-radius: 3px; + resize: vertical; + width: 100%; + box-sizing: border-box; + transition: border-color 0.15s; +} +.bpn-tracker-window .bpn-textarea:focus { + outline: none; + border-color: #3399ff; + background: #0f1318; +} +.bpn-tracker-window .bpn-textarea::placeholder { + color: #3a3f4a; +} + +/* ── Card footer ───────────────────────────────────────────── */ +.bpn-tracker-window .bpn-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding-top: 4px; + border-top: 1px solid #1e2228; +} + +/* PC badge strip */ +.bpn-tracker-window .bpn-pc-row { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.bpn-tracker-window .bpn-pc-badge { + background: #1a2030; + border: 1px solid #2e3a50; + color: #7899cc; + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + letter-spacing: 0.03em; +} + +.bpn-tracker-window .bpn-pc-badge-unassigned { + background: #1a1a1a; + border-color: #333; + color: #555; +} + +/* Save button */ +.bpn-tracker-window .bpn-save-btn { + background: #0d2233; + border: 1px solid #1a5080; + color: #55aadd; + font-size: 11px; + font-weight: 600; + padding: 3px 12px; + border-radius: 3px; + cursor: pointer; + letter-spacing: 0.03em; + transition: background 0.15s; + flex-shrink: 0; +} +.bpn-tracker-window .bpn-save-btn:hover { + background: #163348; + border-color: #3399ff; + color: #88ccff; +} + +/* ── Unassigned section ────────────────────────────────────── */ +.bpn-tracker-window .bpn-unassigned-section { + background: #111318; + border: 1px dashed #2a2a2a; + border-radius: 4px; + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.bpn-tracker-window .bpn-unassigned-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #444; +} + +/* ── Assign BPN Panel ──────────────────────────────────────── */ +.bpn-tracker-window .bpn-assign-panel { + background: #111520; + border: 1px solid #1e2535; + border-radius: 4px; + overflow: hidden; +} + +.bpn-tracker-window .bpn-assign-header { + background: #151a28; + border-bottom: 1px solid #1e2535; + color: #6688bb; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + padding: 7px 12px; +} + +.bpn-tracker-window .bpn-assign-body { + display: flex; + gap: 12px; + padding: 10px 12px; +} + +/* PC checklist — left column */ +.bpn-tracker-window .bpn-assign-pcs { + min-width: 130px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.bpn-tracker-window .bpn-pc-checklist { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 160px; + overflow-y: auto; + padding-right: 4px; +} + +.bpn-tracker-window .bpn-pc-check { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #8899aa; + cursor: pointer; +} +.bpn-tracker-window .bpn-pc-check:hover { + color: #aabbcc; +} +.bpn-tracker-window .bpn-pc-check input[type="checkbox"] { + accent-color: #3399ff; + cursor: pointer; +} + +/* Fields — right column */ +.bpn-tracker-window .bpn-assign-fields { + flex: 1; + display: flex; + flex-direction: column; + gap: 7px; +} + +.bpn-tracker-window .bpn-assign-row { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; +} + +.bpn-tracker-window .bpn-assign-code, +.bpn-tracker-window .bpn-assign-reward { + background: #0d1014; + border: 1px solid #252830; + color: #c8cdd8; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + padding: 4px 8px; + border-radius: 3px; + flex: 1; + min-width: 80px; +} +.bpn-tracker-window .bpn-assign-code:focus, +.bpn-tracker-window .bpn-assign-reward:focus { + outline: none; + border-color: #3399ff; +} + +.bpn-tracker-window .bpn-assign-type, +.bpn-tracker-window .bpn-assign-status { + background: #1a1e26; + border: 1px solid #2e323c; + color: #c8cdd8; + font-size: 12px; + padding: 3px 6px; + border-radius: 3px; + cursor: pointer; + flex: 1; + min-width: 70px; +} +.bpn-tracker-window .bpn-assign-type:focus, +.bpn-tracker-window .bpn-assign-status:focus { + outline: none; + border-color: #3399ff; +} + +.bpn-tracker-window .bpn-assign-submit-row { + justify-content: flex-end; + padding-top: 4px; + border-top: 1px solid #1e2228; + margin-top: 2px; +} + +/* Assign BPN submit button */ +.bpn-tracker-window .bpn-assign-btn { + background: #0d2a18; + border: 1px solid #1a6640; + color: #44cc88; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; + padding: 5px 16px; + border-radius: 3px; + cursor: pointer; + text-transform: uppercase; + transition: background 0.15s; +} +.bpn-tracker-window .bpn-assign-btn:hover { + background: #164030; + border-color: #33cc77; + color: #88ffbb; +} + +/* ── Scrollbar styling (webkit) ────────────────────────────── */ +.bpn-tracker-window .window-content::-webkit-scrollbar, +.bpn-tracker-window .bpn-pc-checklist::-webkit-scrollbar { + width: 5px; +} +.bpn-tracker-window .window-content::-webkit-scrollbar-track, +.bpn-tracker-window .bpn-pc-checklist::-webkit-scrollbar-track { + background: #0a0c10; +} +.bpn-tracker-window .window-content::-webkit-scrollbar-thumb, +.bpn-tracker-window .bpn-pc-checklist::-webkit-scrollbar-thumb { + background: #2a3040; + border-radius: 3px; +} +.bpn-tracker-window .window-content::-webkit-scrollbar-thumb:hover, +.bpn-tracker-window .bpn-pc-checklist::-webkit-scrollbar-thumb:hover { + background: #3a4455; +} + +/* ── ⚡ CONDITION APPLICATOR ──────────────────────────────────────────────────── */ +/* ============================================================ + SLA Condition Applicator — temp_condapp.css + Scoped to .condition-applicator-window + ============================================================ */ + +.condition-applicator-window .window-content { + padding: 0; + background: #0d0d1a; + color: #c8d0e0; + font-family: 'Roboto Condensed', 'Arial Narrow', Arial, sans-serif; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* ---- Wrapper ---- */ +.condition-applicator-window .cap-wrapper { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +/* ============================================================ + HEADER + ============================================================ */ +.condition-applicator-window .cap-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #13132b; + border-bottom: 1px solid #00d4ff33; + flex-shrink: 0; + gap: 8px; +} + +.condition-applicator-window .cap-header-info { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.condition-applicator-window .cap-header-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #6a7a9a; +} + +.condition-applicator-window .cap-selected-name { + font-size: 0.95rem; + font-weight: 700; + color: #00d4ff; + text-shadow: 0 0 8px #00d4ff88; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; +} + +.condition-applicator-window .cap-header-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +/* ---- Buttons ---- */ +.condition-applicator-window .cap-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + font-size: 0.75rem; + font-weight: 600; + border: 1px solid #2a2a4a; + border-radius: 3px; + background: #1a1a2e; + color: #8a9bbf; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.condition-applicator-window .cap-btn:hover { + background: #22224a; + border-color: #00d4ff55; + color: #c8d0e0; +} + +.condition-applicator-window .cap-btn--toggle.cap-btn--active { + background: #0a2a3a; + border-color: #00d4ff; + color: #00d4ff; + box-shadow: 0 0 6px #00d4ff44; +} + +.condition-applicator-window .cap-btn--danger { + border-color: #3a1a1a; + color: #c05050; +} + +.condition-applicator-window .cap-btn--danger:hover { + background: #2a0f0f; + border-color: #ff4444; + color: #ff6666; +} + +/* ============================================================ + BODY — two-column layout + ============================================================ */ +.condition-applicator-window .cap-body { + display: flex; + flex: 1 1 0; + overflow: hidden; +} + +/* ============================================================ + LEFT PANEL — PC Roster (30%) + ============================================================ */ +.condition-applicator-window .cap-pc-list { + width: 30%; + min-width: 140px; + display: flex; + flex-direction: column; + border-right: 1px solid #1e1e3a; + background: #0a0a18; + flex-shrink: 0; +} + +.condition-applicator-window .cap-pc-list-header { + padding: 6px 10px; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #4a5a7a; + background: #0d0d22; + border-bottom: 1px solid #1a1a30; + flex-shrink: 0; +} + +.condition-applicator-window .cap-pc-list-scroll { + overflow-y: auto; + flex: 1 1 0; + scrollbar-width: thin; + scrollbar-color: #1e2a4a #0a0a18; +} + +.condition-applicator-window .cap-pc-list-scroll::-webkit-scrollbar { + width: 4px; +} +.condition-applicator-window .cap-pc-list-scroll::-webkit-scrollbar-track { + background: #0a0a18; +} +.condition-applicator-window .cap-pc-list-scroll::-webkit-scrollbar-thumb { + background: #1e2a4a; + border-radius: 2px; +} + +/* PC Row */ +.condition-applicator-window .cap-pc-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 7px 10px; + cursor: pointer; + border-bottom: 1px solid #111128; + border-left: 3px solid transparent; + transition: background 0.12s, border-left-color 0.12s; + gap: 4px; +} + +.condition-applicator-window .cap-pc-row:hover { + background: #12122a; + border-left-color: #3a3a6a; +} + +.condition-applicator-window .cap-pc-row--selected { + background: #141430; + border-left-color: #ff8800; +} + +.condition-applicator-window .cap-pc-row--selected .cap-pc-name { + color: #ffaa44; +} + +.condition-applicator-window .cap-pc-name { + font-size: 0.82rem; + font-weight: 600; + color: #a8b8d8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1 1 0; +} + +.condition-applicator-window .cap-pc-dots { + display: flex; + gap: 3px; + flex-wrap: wrap; + justify-content: flex-end; + flex-shrink: 0; + max-width: 60px; +} + +.condition-applicator-window .cap-cond-dot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + background: #00d4ff22; + border: 1px solid #00d4ff66; + font-size: 0.45rem; + color: #00d4ff; +} + +.condition-applicator-window .cap-no-pcs { + padding: 12px 10px; + font-size: 0.75rem; + color: #4a5a7a; + font-style: italic; +} + +/* ============================================================ + RIGHT PANEL — Condition Grid (70%) + ============================================================ */ +.condition-applicator-window .cap-cond-panel { + flex: 1 1 0; + overflow-y: auto; + padding: 8px; + background: #0d0d1a; + scrollbar-width: thin; + scrollbar-color: #1e2a4a #0d0d1a; +} + +.condition-applicator-window .cap-cond-panel::-webkit-scrollbar { + width: 4px; +} +.condition-applicator-window .cap-cond-panel::-webkit-scrollbar-track { + background: #0d0d1a; +} +.condition-applicator-window .cap-cond-panel::-webkit-scrollbar-thumb { + background: #1e2a4a; + border-radius: 2px; +} + +.condition-applicator-window .cap-cond-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-content: flex-start; +} + +/* Condition Tile */ +.condition-applicator-window .cap-cond-tile { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + padding: 6px 4px 4px; + border-radius: 4px; + background: #111128; + border: 1px solid #1e1e3a; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; + gap: 4px; + overflow: hidden; + text-align: center; +} + +.condition-applicator-window .cap-cond-tile:hover { + background: #181838; + border-color: #3a3a7a; +} + +.condition-applicator-window .cap-cond-tile--active { + background: #062030; + border: 1px solid #00d4ff; + box-shadow: 0 0 8px #00d4ff44, inset 0 0 12px #00d4ff18; +} + +.condition-applicator-window .cap-cond-tile--active .cap-cond-icon, +.condition-applicator-window .cap-cond-tile--active .cap-cond-label { + color: #00d4ff; + text-shadow: 0 0 6px #00d4ff88; +} + +.condition-applicator-window .cap-cond-icon { + font-size: 1.4rem; + color: #6a7a9a; + line-height: 1; + transition: color 0.15s, text-shadow 0.15s; +} + +.condition-applicator-window .cap-cond-label { + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #7a8aaa; + line-height: 1.2; + word-break: break-word; + transition: color 0.15s, text-shadow 0.15s; +} + +/* Dice Modifier Badge */ +.condition-applicator-window .cap-mod-badge { + position: absolute; + top: 2px; + right: 2px; + font-size: 0.5rem; + font-weight: 700; + background: #8b0000; + color: #ffaaaa; + border-radius: 2px; + padding: 1px 3px; + line-height: 1.3; + white-space: nowrap; + border: 1px solid #cc222233; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.condition-applicator-window .cap-no-conds { + padding: 16px; + font-size: 0.8rem; + color: #4a5a7a; + font-style: italic; +} + +/* ============================================================ + STATUS BAR + ============================================================ */ +.condition-applicator-window .cap-statusbar { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + background: #0a0a18; + border-top: 1px solid #1a1a30; + flex-shrink: 0; + min-height: 30px; + overflow: hidden; +} + +.condition-applicator-window .cap-statusbar-label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #4a5a7a; + flex-shrink: 0; +} + +.condition-applicator-window .cap-statusbar-tags { + display: flex; + flex-wrap: nowrap; + gap: 5px; + overflow-x: auto; + scrollbar-width: none; + align-items: center; +} + +.condition-applicator-window .cap-statusbar-tags::-webkit-scrollbar { + display: none; +} + +.condition-applicator-window .cap-status-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + border-radius: 10px; + background: #062030; + border: 1px solid #00d4ff55; + color: #00d4ff; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.condition-applicator-window .cap-status-none { + font-size: 0.7rem; + color: #3a4a6a; + font-style: italic; +} + +.condition-applicator-window .cap-status-mode { + font-size: 0.7rem; + color: #00d4ff88; + font-style: italic; +} diff --git a/system.json b/system.json index edca695..4c141b5 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "zero-engine", "title": "Zero Engine (SLA Industries)", "description": "A basic game system for SLA Industries using the Zero Engine rules.", - "version": "1.6.5", + "version": "1.7.0", "compatibility": { "minimum": 13, "verified": "14.359" diff --git a/zero-engine.mjs b/zero-engine.mjs index 6d7ec16..deeab84 100644 --- a/zero-engine.mjs +++ b/zero-engine.mjs @@ -3741,34 +3741,34 @@ Hooks.once("setup", () => { /** * Ready hook */ -// ── GM SCENE CONTROLS: PC STATUS BUTTON ────────────────────────────────────── -// Opens the draggable SLAGMStatusWindow (GM only). +// ── GM SCENE CONTROLS: SLA TOOLS ───────────────────────────────────────────── +// Adds all SLA GM tool buttons to the scene controls (GM only). // Foundry v14: controls is an object; v13: controls is an array. Hooks.on("getSceneControlButtons", (controls) => { if (!game.user?.isGM) return; - const openStatus = () => SLAGMStatusWindow.open(); - - const tool = { - name: "sla-pc-status", - title: "PC Status Board", - icon: "fas fa-clipboard-list", - visible: true, - button: true, - onChange: openStatus, // v14 - onClick: openStatus // v13 compat - }; + const SLA_TOOLS = [ + { name: "sla-pc-status", title: "PC Status Board", icon: "fas fa-clipboard-list", fn: () => SLAGMStatusWindow.open() }, + { name: "sla-credit", title: "Credit Distribution", icon: "fas fa-credit-card", fn: () => SLACreditDistributionTool.open() }, + { name: "sla-ledger", title: "Shift Ledger", icon: "fas fa-book-open", fn: () => SLAShiftLedger.open() }, + { name: "sla-threat", title: "NPC Threat Board", icon: "fas fa-skull", fn: () => SLANPCThreatBoard.open() }, + { name: "sla-bpn", title: "BPN Tracker", icon: "fas fa-clipboard-check", fn: () => SLABPNTracker.open() }, + { name: "sla-condapp", title: "Condition Applicator", icon: "fas fa-bolt", fn: () => SLAConditionApplicator.open() }, + ]; // Foundry v14: controls is a plain object keyed by group name if (controls && !Array.isArray(controls)) { - controls["sla-gm-tools"] = { - name: "sla-gm-tools", - title: "SLA GM Tools", - icon: "fas fa-clipboard-list", - visible: true, - button: true, - onChange: openStatus - }; + const grp = controls.tokens ?? controls.token ?? null; + if (grp) { + grp.tools ??= {}; + for (const t of SLA_TOOLS) { + grp.tools[t.name] = { + name: t.name, title: t.title, icon: t.icon, + visible: true, button: true, toggle: false, + onChange: t.fn + }; + } + } return; } @@ -3776,8 +3776,15 @@ Hooks.on("getSceneControlButtons", (controls) => { const tokenGroup = controls.find(c => c.name === "token" || c.name === "tokens"); if (tokenGroup) { tokenGroup.tools ??= []; - if (Array.isArray(tokenGroup.tools)) tokenGroup.tools.push(tool); - else tokenGroup.tools["sla-pc-status"] = tool; + for (const t of SLA_TOOLS) { + const entry = { + name: t.name, title: t.title, icon: t.icon, + visible: true, button: true, + onChange: t.fn, onClick: t.fn + }; + if (Array.isArray(tokenGroup.tools)) tokenGroup.tools.push(entry); + else tokenGroup.tools[t.name] = entry; + } } }); @@ -7050,6 +7057,1596 @@ class SLAGMStatusWindow extends Application { }); this._hooks = []; SLAGMStatusWindow._inst = null; + +// ── 💳 CREDIT DISTRIBUTION ──────────────────────────────────────────────────────────── +class SLACreditDistributionTool extends Application { + constructor(...args) { + super(...args); + this._hooks = []; + } + + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: 'sla-credit-tool', + title: '💳 Credit Distribution', + width: 560, + height: 'auto', + resizable: true, + classes: ['zero-engine', 'credit-tool-window'] + }); + } + + static open() { + if (!SLACreditDistributionTool._inst) SLACreditDistributionTool._inst = new SLACreditDistributionTool(); + SLACreditDistributionTool._inst.render(true); + return SLACreditDistributionTool._inst; + } + + _pcActors() { + return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter); + } + + _calcIncome(actor) { + const fin = actor.system?.finances?.income ?? {}; + return (fin.salary ?? 0) + (fin.bpnReward ?? 0) + (fin.other ?? 0); + } + + _calcExpenses(actor) { + const exp = actor.system?.finances?.expenses ?? {}; + return (exp.accommodation ?? 0) + (exp.drugs ?? 0) + (exp.subscriptions ?? 0) + (exp.other ?? 0) + (exp.bulletTax ?? 0); + } + + async _renderInner() { + const pcs = this._pcActors(); + + // Build rows for each PC + const rows = pcs.map(actor => { + const credits = actor.system?.details?.credits ?? 0; + const scl = actor.system?.details?.scl ?? 0; + const income = this._calcIncome(actor); + const expenses = this._calcExpenses(actor); + const debt = actor.system?.finances?.debt ?? 0; + const net = income - expenses; + + const netClass = net >= 0 ? 'ctw-positive' : 'ctw-negative'; + const debtClass = debt > 0 ? 'ctw-debt' : ''; + const imgSrc = actor.img ?? 'icons/svg/mystery-man.svg'; + + return ` + + + ${actor.name} + ${actor.name} + SCL ${scl} + + + + + ${income} + ${expenses} + ${debt} + ${net} + + + + `; + }).join(''); + + const emptyMsg = pcs.length === 0 + ? `No player characters found. Mark actors as Player Characters on their sheets.` + : ''; + + const html = ` +
+ +
+

Player Credit Ledger

+ + + + + + + + + + + + + + ${rows} + ${emptyMsg} + +
CharacterCredits ¢IncomeExpensesDebtNet
+
+ +
+

Bulk Distribution

+
+ + +
+ + + +
+
+ + +
+
+
+ +
`; + + return $(html); + } + + async _render(force, options) { + await super._render(force, options); + if (!this._hooks.length) { + const refresh = () => { if (this.rendered) this.render(false); }; + this._hooks.push( + Hooks.on('updateActor', refresh) + ); + } + } + + async close(options) { + this._hooks.forEach((id) => { + Hooks.off('updateActor', id); + }); + this._hooks = []; + SLACreditDistributionTool._inst = null; + return super.close(options); + } + + activateListeners(html) { + super.activateListeners(html); + + // --- Save individual credits --- + html.find('.ctw-btn-save').on('click', async (ev) => { + const actorId = ev.currentTarget.dataset.actorId; + const actor = game.actors.get(actorId); + if (!actor) return ui.notifications.error(`Actor not found: ${actorId}`); + + const input = html.find(`#credits-input-${actorId}`); + const newValue = parseInt(input.val(), 10); + + if (isNaN(newValue) || newValue < 0) { + return ui.notifications.error(`Invalid credit value for ${actor.name}.`); + } + + try { + await actor.update({ 'system.details.credits': newValue }); + ui.notifications.info(`${actor.name}: credits set to ¢${newValue}.`); + } catch (err) { + console.error('SLACreditDistributionTool | save error', err); + ui.notifications.error(`Failed to update credits for ${actor.name}.`); + } + }); + + // --- Pay All PCs --- + html.find('#ctw-pay-all-btn').on('click', async () => { + const pcs = this._pcActors(); + if (pcs.length === 0) return ui.notifications.error('No player characters found.'); + + const amount = parseInt(html.find('#ctw-bulk-amount').val(), 10); + if (isNaN(amount) || amount <= 0) { + return ui.notifications.error('Enter a valid amount greater than 0.'); + } + + const payType = html.find('input[name="ctw-pay-type"]:checked').val(); + + let successCount = 0; + for (const actor of pcs) { + try { + if (payType === 'custom') { + // Custom: add directly to credits balance + const currentCredits = actor.system?.details?.credits ?? 0; + await actor.update({ 'system.details.credits': currentCredits + amount }); + } else if (payType === 'salary') { + await actor.update({ 'system.finances.income.salary': amount }); + } else if (payType === 'bpnReward') { + await actor.update({ 'system.finances.income.bpnReward': amount }); + } + successCount++; + } catch (err) { + console.error(`SLACreditDistributionTool | pay error for ${actor.name}`, err); + ui.notifications.error(`Failed to pay ${actor.name}.`); + } + } + + if (successCount > 0) { + const label = payType === 'custom' ? `¢${amount} credits` : `${payType} set to ¢${amount}`; + ui.notifications.info(`Paid ${successCount} PC(s): ${label}.`); + } + }); + + // --- Deduct All Expenses --- + html.find('#ctw-deduct-expenses-btn').on('click', async () => { + const pcs = this._pcActors(); + if (pcs.length === 0) return ui.notifications.error('No player characters found.'); + + let successCount = 0; + for (const actor of pcs) { + try { + const currentCredits = actor.system?.details?.credits ?? 0; + const totalExpenses = this._calcExpenses(actor); + const newCredits = Math.max(0, currentCredits - totalExpenses); + await actor.update({ 'system.details.credits': newCredits }); + successCount++; + } catch (err) { + console.error(`SLACreditDistributionTool | deduct error for ${actor.name}`, err); + ui.notifications.error(`Failed to deduct expenses for ${actor.name}.`); + } + } + + if (successCount > 0) { + ui.notifications.info(`Expenses deducted from ${successCount} PC(s).`); + } + }); + } +} + +SLACreditDistributionTool._inst = null; + +// ── 📒 SHIFT LEDGER ──────────────────────────────────────────────────────────── +class SLAShiftLedger extends Application { + constructor(...args) { + super(...args); + this._hooks = []; + } + + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: 'sla-shift-ledger', + title: '📒 Shift Ledger', + width: 700, + height: 'auto', + resizable: true, + classes: ['zero-engine', 'shift-ledger-window'] + }); + } + + static open() { + if (!SLAShiftLedger._inst) SLAShiftLedger._inst = new SLAShiftLedger(); + SLAShiftLedger._inst.render(true); + return SLAShiftLedger._inst; + } + + _pcActors() { + return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter); + } + + _calcNet(actor) { + const fin = actor.system?.finances ?? {}; + const inc = fin.income ?? {}; + const exp = fin.expenses ?? {}; + const totalIncome = (inc.salary || 0) + (inc.bpnReward || 0) + (inc.other || 0); + const totalExpenses = (exp.accommodation || 0) + (exp.drugs || 0) + (exp.subscriptions || 0) + (exp.other || 0) + (exp.bulletTax || 0); + return { totalIncome, totalExpenses, net: totalIncome - totalExpenses }; + } + + async _processShift(actor) { + const { totalIncome, totalExpenses } = this._calcNet(actor); + const currentCredits = actor.system?.details?.credits ?? 0; + const currentDebt = actor.system?.finances?.debt ?? 0; + + let newCredits = currentCredits + totalIncome - totalExpenses; + let newDebt = currentDebt; + + if (newCredits < 0) { + newDebt = currentDebt + Math.abs(newCredits); + newCredits = 0; + } + + try { + await actor.update({ + 'system.details.credits': newCredits, + 'system.finances.debt': newDebt + }); + ui.notifications.info(`Shift processed for ${actor.name}: ${newCredits}¢ credits, ${newDebt}¢ debt.`); + } catch (err) { + console.error('SLAShiftLedger | Failed to process shift for', actor.name, err); + ui.notifications.error(`Failed to process shift for ${actor.name}. See console for details.`); + } + } + + async _processAllShifts() { + const pcs = this._pcActors(); + if (!pcs.length) { + ui.notifications.error('No player characters found to process.'); + return; + } + for (const actor of pcs) { + await this._processShift(actor); + } + ui.notifications.info(`Shift processed for all ${pcs.length} player character(s).`); + } + + async _exportToChat() { + const pcs = this._pcActors(); + if (!pcs.length) { + ui.notifications.error('No player characters found to export.'); + return; + } + + let lines = ['

📒 Shift Ledger Summary

']; + lines.push(''); + + let totalCredits = 0, totalIncome = 0, totalExpenses = 0, totalDebt = 0; + + for (const actor of pcs) { + const { totalIncome: inc, totalExpenses: exp, net } = this._calcNet(actor); + const credits = actor.system?.details?.credits ?? 0; + const scl = actor.system?.details?.scl ?? '–'; + const debt = actor.system?.finances?.debt ?? 0; + totalCredits += credits; + totalIncome += inc; + totalExpenses += exp; + totalDebt += debt; + const netColor = net >= 0 ? '#44cc66' : '#cc2222'; + const debtColor = debt > 0 ? '#cc2222' : 'inherit'; + lines.push( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); + } + + const groupNet = totalIncome - totalExpenses; + const groupNetColor = groupNet >= 0 ? '#44cc66' : '#cc2222'; + lines.push( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); + lines.push('
CharacterSCLCreditsIncomeExpensesNetDebt
${actor.name}${scl}${credits}¢+${inc}¢-${exp}¢${net >= 0 ? '+' : ''}${net}¢${debt}¢
GROUP TOTALS${totalCredits}¢+${totalIncome}¢-${totalExpenses}¢${groupNet >= 0 ? '+' : ''}${groupNet}¢${totalDebt}¢
'); + + try { + await ChatMessage.create({ + content: lines.join(''), + whisper: [game.user.id], + speaker: { alias: 'Shift Ledger' } + }); + ui.notifications.info('Shift ledger exported to chat (GM only).'); + } catch (err) { + console.error('SLAShiftLedger | Failed to export to chat:', err); + ui.notifications.error('Failed to export ledger to chat. See console for details.'); + } + } + + async _renderInner() { + const pcs = this._pcActors(); + + // ── Aggregate totals ────────────────────────────────────────────────────── + let aggCredits = 0, aggIncome = 0, aggExpenses = 0, aggDebt = 0; + for (const actor of pcs) { + const { totalIncome, totalExpenses } = this._calcNet(actor); + aggCredits += actor.system?.details?.credits ?? 0; + aggIncome += totalIncome; + aggExpenses += totalExpenses; + aggDebt += actor.system?.finances?.debt ?? 0; + } + const aggNet = aggIncome - aggExpenses; + + // ── Summary bar ────────────────────────────────────────────────────────── + const summaryBar = ` +
+
+ Group Credits + ${aggCredits}¢ +
+
+ Total Income + +${aggIncome}¢ +
+
+ Total Expenses + -${aggExpenses}¢ +
+
+ Net This Shift + ${aggNet >= 0 ? '+' : ''}${aggNet}¢ +
+
+ Total Debt + ${aggDebt}¢ +
+
`; + + // ── Per-PC cards ───────────────────────────────────────────────────────── + let pcCards = ''; + if (pcs.length === 0) { + pcCards = '
No player characters found. Mark actors as Player Characters in their sheet details.
'; + } else { + for (const actor of pcs) { + const det = actor.system?.details ?? {}; + const fin = actor.system?.finances ?? {}; + const inc = fin.income ?? {}; + const exp = fin.expenses ?? {}; + const { totalIncome, totalExpenses, net } = this._calcNet(actor); + const credits = det.credits ?? 0; + const scl = det.scl ?? '–'; + const debt = fin.debt ?? 0; + + pcCards += ` +
+
+ ${actor.name} + SCL ${scl} + ${credits}¢ +
+ +
+
+
Income
+
+ Salary + ${inc.salary ?? 0}¢ +
+
+ BPN Reward + ${inc.bpnReward ?? 0}¢ +
+
+ Other + ${inc.other ?? 0}¢ +
+
+ Total Income + +${totalIncome}¢ +
+
+ +
+
Expenses
+
+ Accommodation + ${exp.accommodation ?? 0}¢ +
+
+ Drugs + ${exp.drugs ?? 0}¢ +
+
+ Subscriptions + ${exp.subscriptions ?? 0}¢ +
+
+ Other + ${exp.other ?? 0}¢ +
+
+ Bullet Tax + ${exp.bulletTax ?? 0}¢ +
+
+ Total Expenses + -${totalExpenses}¢ +
+
+ +
+
Summary
+
+ Net Balance + ${net >= 0 ? '+' : ''}${net}¢ +
+
+ Debt + ${debt}¢ +
+ +
+
+
`; + } + } + + // ── Global action bar ──────────────────────────────────────────────────── + const actionBar = ` +
+ + +
`; + + const html = ` +
+ ${summaryBar} +
+ ${pcCards} +
+ ${actionBar} +
`; + + return $(html); + } + + async _render(force, options) { + await super._render(force, options); + if (!this._hooks.length) { + const refresh = () => { if (this.rendered) this.render(false); }; + this._hooks.push(Hooks.on('updateActor', refresh)); + } + } + + async close(options) { + this._hooks.forEach(id => Hooks.off('updateActor', id)); + this._hooks = []; + SLAShiftLedger._inst = null; + return super.close(options); + } + + activateListeners(html) { + super.activateListeners(html); + + // Per-PC process shift buttons + html.find('.slw-btn--process').on('click', async (ev) => { + const actorId = ev.currentTarget.dataset.actorId; + const actor = game.actors.get(actorId); + if (!actor) { + ui.notifications.error('Actor not found.'); + return; + } + await this._processShift(actor); + }); + + // Process all shifts button + html.find('#slw-process-all').on('click', async () => { + await this._processAllShifts(); + }); + + // Export to chat button + html.find('#slw-export-chat').on('click', async () => { + await this._exportToChat(); + }); + } +} + +SLAShiftLedger._inst = null; + +// ── ☠ NPC THREAT BOARD ──────────────────────────────────────────────────────────── +// ────────────────────────────────────────────────────────────────────────────── +// SLANPCThreatBoard — GM tactical overview of all NPC actors +// ────────────────────────────────────────────────────────────────────────────── + +function threatLabel(val) { + if (!val || val <= 2) return { label: 'Low', cls: 'threat-low' }; + if (val <= 3) return { label: 'Med', cls: 'threat-med' }; + if (val <= 5) return { label: 'High', cls: 'threat-high' }; + return { label: 'Elite', cls: 'threat-elite' }; +} + +function hpColor(cur, max) { + const pct = max > 0 ? cur / max : 1; + if (pct > 0.50) return '#44cc66'; + if (pct > 0.25) return '#ffaa00'; + return '#cc2222'; +} + +class SLANPCThreatBoard extends Application { + constructor(...args) { + super(...args); + this._hooks = []; + this._filterThreat = 'all'; // 'all' | 'low' | 'med' | 'high' | 'elite' + this._filterScene = false; // show only actors present in the active scene + this._showDead = false; // include dead actors + this._sortBy = 'threat'; // 'name' | 'threat' | 'health' + } + + // ── AppV1 config ───────────────────────────────────────────────────────────── + + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: 'sla-npc-threat-board', + title: '☠ NPC Threat Board', + width: 640, + height: 500, + resizable: true, + classes: ['zero-engine', 'npc-threat-board'] + }); + } + + static open() { + if (!SLANPCThreatBoard._inst) SLANPCThreatBoard._inst = new SLANPCThreatBoard(); + SLANPCThreatBoard._inst.render(true); + return SLANPCThreatBoard._inst; + } + + // ── Data helpers ────────────────────────────────────────────────────────────── + + _npcActors() { + return (game.actors ?? []).filter( + a => a.type === 'npc' || (a.type === 'character' && !a.system?.details?.isPlayerCharacter) + ); + } + + /** Returns a Set of actorIds present in the currently active scene. */ + _sceneActorIds() { + const ids = new Set(); + const tokens = game.scenes?.active?.tokens ?? []; + for (const t of tokens) { + if (t.actorId) ids.add(t.actorId); + } + return ids; + } + + // ── HTML builder ────────────────────────────────────────────────────────────── + + async _renderInner() { + const sceneIds = this._sceneActorIds(); + let actors = this._npcActors(); + + // ── Filter: scene ──────────────────────────────────────────────────────── + if (this._filterScene) { + actors = actors.filter(a => sceneIds.has(a.id)); + } + + // ── Filter: dead ───────────────────────────────────────────────────────── + if (!this._showDead) { + actors = actors.filter(a => { + const dead = a.statuses?.has('dead') ?? false; + const hpVal = a.system?.health?.value ?? 0; + return !dead && hpVal > 0; + }); + } + + // ── Filter: threat tier ────────────────────────────────────────────────── + if (this._filterThreat !== 'all') { + actors = actors.filter(a => { + const t = a.system?.threat || 0; + const { cls } = threatLabel(t); + return cls === `threat-${this._filterThreat}`; + }); + } + + // ── Sort ───────────────────────────────────────────────────────────────── + if (this._sortBy === 'name') { + actors.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); + } else if (this._sortBy === 'threat') { + actors.sort((a, b) => (b.system?.threat || 0) - (a.system?.threat || 0)); + } else if (this._sortBy === 'health') { + actors.sort((a, b) => { + const pctA = (a.system?.health?.max || 0) > 0 + ? (a.system.health.value || 0) / a.system.health.max : 0; + const pctB = (b.system?.health?.max || 0) > 0 + ? (b.system.health.value || 0) / b.system.health.max : 0; + return pctA - pctB; // lowest HP first — most urgent + }); + } + + // ── Build card HTML ────────────────────────────────────────────────────── + const cards = actors.map(actor => { + const hpCur = actor.system?.health?.value ?? 0; + const hpMax = actor.system?.health?.max ?? 0; + const threat = actor.system?.threat ?? 0; + const str = actor.system?.attributes?.strength ?? 0; + const agi = actor.system?.attributes?.agility ?? 0; + const wit = actor.system?.attributes?.wits ?? 0; + const emp = actor.system?.attributes?.empathy ?? 0; + const dmg = actor.system?.damage ?? '—'; + const armor = actor.system?.armor ?? 0; + const imgSrc = actor.img ?? 'icons/svg/mystery-man.svg'; + + const { label: tLabel, cls: tCls } = threatLabel(threat); + const hpPct = hpMax > 0 ? Math.max(0, Math.min(1, hpCur / hpMax)) : 0; + const hpBarPct = Math.round(hpPct * 100); + const barColor = hpColor(hpCur, hpMax); + + const isDead = (actor.statuses?.has('dead') ?? false) || hpCur <= 0; + + // Status dots (all active statuses other than 'dead') + const statuses = actor.statuses ? [...actor.statuses].filter(s => s !== 'dead') : []; + const statusDots = statuses.slice(0, 6).map(s => + `` + ).join(''); + + // Scene indicator + const inScene = sceneIds.has(actor.id) + ? `◉` + : ''; + + return ` +
+ ${isDead ? '
💀
' : ''} + +
+
+ ${actor.name} +
+
+
${actor.name}${inScene}
+ ${tLabel} ${threat || '–'} +
+
+ +
+
+
+
+
+ + ${hpCur} / ${hpMax} + +
+
+ +
+
STR${str}
+
AGI${agi}
+
WIT${wit}
+
EMP${emp}
+
+ +
+ ⚔ ${dmg} + 🛡 ${armor} +
${statusDots}
+
+
`; + }).join(''); + + const emptyMsg = actors.length === 0 + ? `
No NPCs match the current filters.
` + : ''; + + // ── Filter / sort bar HTML ──────────────────────────────────────────────── + const html = ` +
+ +
+ +
+ + + +
+ +
+ + + + + +
+ +
+ Sort: + + + +
+ +
+ +
+ ${cards} + ${emptyMsg} +
+ +
`; + + return $(html); + } + + // ── Lifecycle ───────────────────────────────────────────────────────────────── + + async _render(force, options) { + await super._render(force, options); + if (!this._hooks.length) { + const refresh = () => { if (this.rendered) this.render(false); }; + this._hooks.push( + Hooks.on('updateActor', refresh), + Hooks.on('createActor', refresh), + Hooks.on('deleteActor', refresh) + ); + } + } + + async close(options) { + const evts = ['updateActor', 'createActor', 'deleteActor']; + this._hooks.forEach((id, i) => Hooks.off(evts[i], id)); + this._hooks = []; + SLANPCThreatBoard._inst = null; + return super.close(options); + } + + // ── Listeners ───────────────────────────────────────────────────────────────── + + activateListeners(html) { + super.activateListeners(html); + + // ── Scene filter buttons ───────────────────────────────────────────────── + html.find('[data-filter-scene]').on('click', (ev) => { + this._filterScene = ev.currentTarget.dataset.filterScene === 'true'; + this.render(false); + }); + + // ── Dead toggle ────────────────────────────────────────────────────────── + html.find('.ntb-toggle-dead').on('click', () => { + this._showDead = !this._showDead; + this.render(false); + }); + + // ── Threat filter buttons ──────────────────────────────────────────────── + html.find('.ntb-threat-filter').on('click', (ev) => { + this._filterThreat = ev.currentTarget.dataset.threat || 'all'; + this.render(false); + }); + + // ── Sort buttons ───────────────────────────────────────────────────────── + html.find('.ntb-sort-btn').on('click', (ev) => { + this._sortBy = ev.currentTarget.dataset.sort || 'threat'; + this.render(false); + }); + + // ── HP decrement ───────────────────────────────────────────────────────── + html.find('.ntb-hp-dec').on('click', async (ev) => { + ev.stopPropagation(); + const actorId = ev.currentTarget.dataset.actorId; + const actor = game.actors?.get(actorId); + if (!actor) return; + const cur = actor.system?.health?.value ?? 0; + const newVal = cur - 1; + try { + await actor.update({ 'system.health.value': newVal }); + } catch (err) { + console.error('SLANPCThreatBoard | HP decrement error', err); + ui.notifications?.error(`Failed to update HP for ${actor.name}.`); + } + }); + + // ── HP increment ───────────────────────────────────────────────────────── + html.find('.ntb-hp-inc').on('click', async (ev) => { + ev.stopPropagation(); + const actorId = ev.currentTarget.dataset.actorId; + const actor = game.actors?.get(actorId); + if (!actor) return; + const cur = actor.system?.health?.value ?? 0; + const hpMax = actor.system?.health?.max ?? 0; + const newVal = hpMax > 0 ? Math.min(hpMax, cur + 1) : cur + 1; + try { + await actor.update({ 'system.health.value': newVal }); + } catch (err) { + console.error('SLANPCThreatBoard | HP increment error', err); + ui.notifications?.error(`Failed to update HP for ${actor.name}.`); + } + }); + + // ── Open actor sheet on card click ─────────────────────────────────────── + html.find('.ntb-card').on('click', (ev) => { + // Don't open sheet if a HP button was clicked + if (ev.target.closest('.ntb-hp-btn')) return; + const actorId = ev.currentTarget.dataset.actorId; + const actor = game.actors?.get(actorId); + if (actor) actor.sheet.render(true); + }); + } +} + +SLANPCThreatBoard._inst = null; + +// ── 📋 BPN TRACKER ──────────────────────────────────────────────────────────── +class SLABPNTracker extends Application { + constructor(...args) { + super(...args); + this._hooks = []; + this._editMode = false; + } + + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: 'sla-bpn-tracker', + title: '📋 BPN Tracker', + width: 520, + height: 'auto', + resizable: true, + classes: ['zero-engine', 'bpn-tracker-window'] + }); + } + + static open() { + if (!SLABPNTracker._inst) SLABPNTracker._inst = new SLABPNTracker(); + SLABPNTracker._inst.render(true); + return SLABPNTracker._inst; + } + + _pcActors() { + return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter); + } + + _groupByBPN(actors) { + const groups = new Map(); + for (const actor of actors) { + const code = actor.system?.bpn?.code?.trim() || ''; + const key = code === '' ? '__unassigned__' : code; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(actor); + } + return groups; + } + + _bpnTypeColors() { + return { + 'Blue': { bg: '#1a3a6e', border: '#3399ff', text: '#88ccff' }, + 'White': { bg: '#3a3a3a', border: '#cccccc', text: '#ffffff' }, + 'Green': { bg: '#1a3a1a', border: '#44cc66', text: '#88ff88' }, + 'Red': { bg: '#3a1a1a', border: '#cc2222', text: '#ff6666' }, + 'Black': { bg: '#0d0d0d', border: '#555555', text: '#aaaaaa' }, + 'Silver': { bg: '#2a2a3a', border: '#aaaacc', text: '#ccccff' } + }; + } + + _buildTypeBadge(bpnType) { + const colors = this._bpnTypeColors(); + const c = colors[bpnType] ?? { bg: '#222', border: '#666', text: '#ccc' }; + return `${bpnType || 'Unknown'}`; + } + + _buildStatusBadge(status) { + const cls = { + 'Active': 'bpn-status-active', + 'Pending': 'bpn-status-pending', + 'Complete': 'bpn-status-complete', + 'Failed': 'bpn-status-failed' + }[status] ?? 'bpn-status-pending'; + return `${status || 'Pending'}`; + } + + _buildMissionCard(code, actors) { + const rep = actors[0]; + const bpn = rep.system?.bpn ?? {}; + const bpnType = bpn.type || 'Unknown'; + const status = bpn.status || 'Pending'; + const reward = bpn.reward ?? 0; + const description = bpn.description || ''; + const objectives = bpn.objectives || ''; + + const colors = this._bpnTypeColors(); + const c = colors[bpnType] ?? { bg: '#1a1a1a', border: '#555', text: '#aaa' }; + + const actorIds = actors.map(a => a.id).join(','); + + const pcBadges = actors.map(a => + `${a.name}` + ).join(''); + + const statusOptions = ['Pending', 'Active', 'Complete', 'Failed'].map(s => + `` + ).join(''); + + const typeOptions = ['Blue', 'White', 'Green', 'Red', 'Black', 'Silver', 'Unknown'].map(t => + `` + ).join(''); + + return ` +
+
+ ${code} + ${this._buildTypeBadge(bpnType)} + ${this._buildStatusBadge(status)} + +
+ +
+ + + + +
+ +
+ 💳 Reward: ${reward}¢ + +
+ +
+ + +
+ +
+ + +
+ + +
`; + } + + _buildAssignPanel(actors) { + const pcCheckboxes = actors.map(a => + `` + ).join(''); + + const typeOptions = ['Blue', 'White', 'Green', 'Red', 'Black', 'Silver'].map(t => + `` + ).join(''); + + const statusOptions = ['Pending', 'Active', 'Complete', 'Failed'].map(s => + `` + ).join(''); + + return ` +
+
Assign BPN
+
+
+ +
${pcCheckboxes}
+
+
+
+ + +
+
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
`; + } + + async _renderInner() { + const actors = this._pcActors(); + const groups = this._groupByBPN(actors); + + let cardsHtml = ''; + + // Render assigned BPNs first (all non-unassigned keys) + for (const [key, group] of groups.entries()) { + if (key === '__unassigned__') continue; + cardsHtml += this._buildMissionCard(key, group); + } + + // Render unassigned section + const unassigned = groups.get('__unassigned__') ?? []; + let unassignedHtml = ''; + if (unassigned.length > 0) { + const badges = unassigned.map(a => `${a.name}`).join(''); + unassignedHtml = ` +
+
Unassigned PCs
+
${badges}
+
`; + } + + const assignPanel = this._buildAssignPanel(actors); + + const html = ` +
+
+ ${cardsHtml || '
No active BPNs. Assign one below.
'} +
+ ${unassignedHtml} + ${assignPanel} +
`; + + return $(html); + } + + async _render(force, options) { + await super._render(force, options); + if (!this._hooks.length) { + const refresh = () => { if (this.rendered) this.render(false); }; + this._hooks.push(Hooks.on('updateActor', refresh)); + this._hooks.push(Hooks.on('createActor', refresh)); + this._hooks.push(Hooks.on('deleteActor', refresh)); + } + } + + async close(options) { + this._hooks.forEach(id => Hooks.off('updateActor', id)); + this._hooks = []; + SLABPNTracker._inst = null; + return super.close(options); + } + + activateListeners(html) { + super.activateListeners(html); + + // Status change — update all PCs on this BPN + html.on('change', '.bpn-status-select', async (ev) => { + const card = ev.currentTarget.closest('.bpn-mission-card'); + const actorIds = card.dataset.actorIds.split(',').filter(Boolean); + const newStatus = ev.currentTarget.value; + for (const id of actorIds) { + const actor = game.actors.get(id); + if (!actor) continue; + try { + await actor.update({ 'system.bpn.status': newStatus }); + } catch (err) { + console.error(`BPN Tracker | Failed to update status for ${actor.name}:`, err); + } + } + // Update the badge in-place without full re-render + const badge = card.querySelector('.bpn-status-badge'); + if (badge) { + badge.className = 'bpn-status-badge ' + ({ + 'Active': 'bpn-status-active', + 'Pending': 'bpn-status-pending', + 'Complete': 'bpn-status-complete', + 'Failed': 'bpn-status-failed' + }[newStatus] ?? 'bpn-status-pending'); + badge.textContent = newStatus; + } + ui.notifications.info(`BPN status updated to "${newStatus}".`); + }); + + // Type change — update all PCs on this BPN + html.on('change', '.bpn-type-select', async (ev) => { + const card = ev.currentTarget.closest('.bpn-mission-card'); + const actorIds = card.dataset.actorIds.split(',').filter(Boolean); + const newType = ev.currentTarget.value; + const colors = this._bpnTypeColors(); + const c = colors[newType] ?? { bg: '#222', border: '#666', text: '#ccc' }; + for (const id of actorIds) { + const actor = game.actors.get(id); + if (!actor) continue; + try { + await actor.update({ 'system.bpn.type': newType }); + } catch (err) { + console.error(`BPN Tracker | Failed to update type for ${actor.name}:`, err); + } + } + // Update badge and border color in-place + const badge = card.querySelector('.bpn-type-badge'); + if (badge) { + badge.style.background = c.bg; + badge.style.borderColor = c.border; + badge.style.color = c.text; + badge.textContent = newType; + } + card.style.borderLeftColor = c.border; + ui.notifications.info(`BPN type updated to "${newType}".`); + }); + + // Pay Out — add reward to each PC's credits + html.on('click', '[data-action="payout"]', async (ev) => { + const card = ev.currentTarget.closest('.bpn-mission-card'); + const actorIds = card.dataset.actorIds.split(',').filter(Boolean); + let paid = 0; + for (const id of actorIds) { + const actor = game.actors.get(id); + if (!actor) continue; + const reward = actor.system?.bpn?.reward ?? 0; + const cur = actor.system?.details?.credits ?? 0; + try { + await actor.update({ 'system.details.credits': cur + reward }); + paid++; + } catch (err) { + console.error(`BPN Tracker | Failed to pay out reward to ${actor.name}:`, err); + } + } + ui.notifications.info(`Reward paid out to ${paid} PC(s).`); + }); + + // Save description + objectives back to all PCs on this BPN + html.on('click', '[data-action="save-bpn"]', async (ev) => { + const card = ev.currentTarget.closest('.bpn-mission-card'); + const actorIds = card.dataset.actorIds.split(',').filter(Boolean); + const description = card.querySelector('.bpn-description').value; + const objectives = card.querySelector('.bpn-objectives').value; + for (const id of actorIds) { + const actor = game.actors.get(id); + if (!actor) continue; + try { + await actor.update({ + 'system.bpn.description': description, + 'system.bpn.objectives': objectives + }); + } catch (err) { + console.error(`BPN Tracker | Failed to save BPN fields for ${actor.name}:`, err); + } + } + ui.notifications.info('BPN description and objectives saved.'); + }); + + // Clear BPN — remove code and status from all PCs on this BPN + html.on('click', '[data-action="clear-bpn"]', async (ev) => { + const card = ev.currentTarget.closest('.bpn-mission-card'); + const actorIds = card.dataset.actorIds.split(',').filter(Boolean); + for (const id of actorIds) { + const actor = game.actors.get(id); + if (!actor) continue; + try { + await actor.update({ 'system.bpn.code': '', 'system.bpn.status': '' }); + } catch (err) { + console.error(`BPN Tracker | Failed to clear BPN for ${actor.name}:`, err); + } + } + ui.notifications.info('BPN cleared from assigned PCs.'); + if (this.rendered) this.render(false); + }); + + // Assign BPN — write BPN data to selected PCs + html.on('click', '[data-action="assign-bpn"]', async (ev) => { + const panel = ev.currentTarget.closest('.bpn-assign-panel'); + const selectedIds = [...panel.querySelectorAll('.bpn-assign-pc-check:checked')].map(el => el.value); + if (selectedIds.length === 0) { + ui.notifications.warn('No PCs selected for BPN assignment.'); + return; + } + const code = panel.querySelector('.bpn-assign-code').value.trim(); + if (!code) { + ui.notifications.warn('Please enter a BPN code.'); + return; + } + const bpnType = panel.querySelector('.bpn-assign-type').value; + const status = panel.querySelector('.bpn-assign-status').value; + const reward = Number(panel.querySelector('.bpn-assign-reward').value) || 0; + const description = panel.querySelector('.bpn-assign-desc').value; + const objectives = panel.querySelector('.bpn-assign-obj').value; + + for (const id of selectedIds) { + const actor = game.actors.get(id); + if (!actor) continue; + try { + await actor.update({ + 'system.bpn.code': code, + 'system.bpn.type': bpnType, + 'system.bpn.status': status, + 'system.bpn.reward': reward, + 'system.bpn.description': description, + 'system.bpn.objectives': objectives + }); + } catch (err) { + console.error(`BPN Tracker | Failed to assign BPN to ${actor.name}:`, err); + } + } + ui.notifications.info(`BPN "${code}" assigned to ${selectedIds.length} PC(s).`); + + // Clear the form + panel.querySelector('.bpn-assign-code').value = ''; + panel.querySelector('.bpn-assign-reward').value = '0'; + panel.querySelector('.bpn-assign-desc').value = ''; + panel.querySelector('.bpn-assign-obj').value = ''; + panel.querySelectorAll('.bpn-assign-pc-check').forEach(el => el.checked = false); + + if (this.rendered) this.render(false); + }); + } +} + +SLABPNTracker._inst = null; + +// ── ⚡ CONDITION APPLICATOR ──────────────────────────────────────────────────────────── +class SLAConditionApplicator extends Application { + constructor(...args) { + super(...args); + this._hooks = []; + this._selectedActorId = null; + this._allPCsMode = false; + } + + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: 'sla-condition-applicator', + title: '⚡ Condition Applicator', + width: 600, + height: 520, + resizable: true, + classes: ['zero-engine', 'condition-applicator-window'] + }); + } + + static open() { + if (!SLAConditionApplicator._inst) SLAConditionApplicator._inst = new SLAConditionApplicator(); + SLAConditionApplicator._inst.render(true); + return SLAConditionApplicator._inst; + } + + _pcActors() { + return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter); + } + + async _renderInner() { + const pcs = this._pcActors(); + + // Default selected actor to first PC if none set or stale + if (!this._selectedActorId || !pcs.find(a => a.id === this._selectedActorId)) { + this._selectedActorId = pcs.length ? pcs[0].id : null; + } + + const selActor = pcs.find(a => a.id === this._selectedActorId) ?? null; + + // --- Build PC roster rows --- + let pcRowsHtml = ''; + for (const actor of pcs) { + const activeConditions = SLA_CONDITIONS.filter(c => actor.statuses?.has(c.id)); + const dotBadges = activeConditions.map(c => + `` + ).join(''); + const isSelected = actor.id === this._selectedActorId && !this._allPCsMode; + pcRowsHtml += ` +
+ ${actor.name} + ${dotBadges} +
`; + } + if (!pcRowsHtml) { + pcRowsHtml = '
No PC actors found.
'; + } + + // --- Build condition grid tiles --- + let condTilesHtml = ''; + for (const cond of SLA_CONDITIONS) { + let isActive = false; + if (this._allPCsMode) { + // Active if ALL PCs have it (useful visual: show partial state too) + const pcCount = pcs.length; + const hasCount = pcs.filter(a => a.statuses?.has(cond.id)).length; + if (hasCount === pcCount && pcCount > 0) isActive = true; + } else if (selActor) { + isActive = selActor.statuses?.has(cond.id) ?? false; + } + + // Build dice modifier badge text + let modBadge = ''; + if (cond.diceModifiers && Object.keys(cond.diceModifiers).length) { + const parts = Object.entries(cond.diceModifiers).map(([key, val]) => { + const sign = val >= 0 ? '+' : '−'; + const abs = Math.abs(val); + return `${sign}${abs} ${key}`; + }); + modBadge = `${parts.join(' ')}`; + } + + const descAttr = cond.description ? ` title="${cond.description.replace(/"/g, '"')}"` : ''; + + condTilesHtml += ` +
+ ${modBadge} + + ${cond.label} +
`; + } + if (!condTilesHtml) { + condTilesHtml = '
No conditions defined.
'; + } + + // --- Active conditions status bar --- + let statusBarHtml = ''; + if (this._allPCsMode) { + statusBarHtml = 'All PCs mode active'; + } else if (selActor) { + const activeConds = SLA_CONDITIONS.filter(c => selActor.statuses?.has(c.id)); + if (activeConds.length) { + statusBarHtml = activeConds.map(c => + ` ${c.label}` + ).join(''); + } else { + statusBarHtml = 'No active conditions'; + } + } else { + statusBarHtml = 'No PC selected'; + } + + const selectedLabel = this._allPCsMode + ? 'All PCs' + : (selActor ? selActor.name : '—'); + + const html = ` +
+
+
+ Target: + ${selectedLabel} +
+
+ + +
+
+ +
+
+
PC Roster
+
+ ${pcRowsHtml} +
+
+
+
+ ${condTilesHtml} +
+
+
+ +
+ Active: +
+ ${statusBarHtml} +
+
+
`; + + return $(html); + } + + async _render(force, options) { + await super._render(force, options); + if (!this._hooks.length) { + const refresh = () => { if (this.rendered) this.render(false); }; + this._hooks.push( + Hooks.on('updateActor', refresh), + Hooks.on('createActiveEffect', refresh), + Hooks.on('deleteActiveEffect', refresh), + Hooks.on('updateActiveEffect', refresh) + ); + } + } + + async close(options) { + const evts = ['updateActor', 'createActiveEffect', 'deleteActiveEffect', 'updateActiveEffect']; + this._hooks.forEach((id, i) => Hooks.off(evts[i], id)); + this._hooks = []; + SLAConditionApplicator._inst = null; + return super.close(options); + } + + activateListeners(html) { + super.activateListeners(html); + + // PC row click — select actor + html.find('.cap-pc-row').on('click', (ev) => { + const actorId = ev.currentTarget.dataset.actorId; + if (actorId) { + this._selectedActorId = actorId; + this._allPCsMode = false; + this.render(false); + } + }); + + // Condition tile click — toggle condition + html.find('.cap-cond-tile').on('click', async (ev) => { + const condId = ev.currentTarget.dataset.condId; + const isActive = ev.currentTarget.dataset.active === 'true'; + const pcs = this._pcActors(); + + if (this._allPCsMode) { + for (const actor of pcs) { + try { + await actor.toggleStatusEffect(condId, { active: !isActive }); + } catch (err) { + console.error(`SLAConditionApplicator | Failed to toggle ${condId} on ${actor.name}:`, err); + ui.notifications.warn(`Could not toggle condition on ${actor.name}.`); + } + } + const verb = isActive ? 'Removed' : 'Applied'; + const condLabel = SLA_CONDITIONS.find(c => c.id === condId)?.label ?? condId; + ui.notifications.info(`${verb} "${condLabel}" on all PCs.`); + } else { + const selActor = pcs.find(a => a.id === this._selectedActorId); + if (!selActor) { + ui.notifications.warn('No PC selected.'); + return; + } + try { + await selActor.toggleStatusEffect(condId, { active: !isActive }); + const verb = isActive ? 'Removed' : 'Applied'; + const condLabel = SLA_CONDITIONS.find(c => c.id === condId)?.label ?? condId; + ui.notifications.info(`${verb} "${condLabel}" on ${selActor.name}.`); + } catch (err) { + console.error(`SLAConditionApplicator | Failed to toggle ${condId} on ${selActor.name}:`, err); + ui.notifications.warn(`Could not toggle condition on ${selActor.name}.`); + } + } + }); + + // Toggle All PCs mode + html.find('[data-action="toggle-all-pcs"]').on('click', () => { + this._allPCsMode = !this._allPCsMode; + this.render(false); + }); + + // Clear All conditions + html.find('[data-action="clear-all"]').on('click', async () => { + const pcs = this._pcActors(); + const targets = this._allPCsMode + ? pcs + : pcs.filter(a => a.id === this._selectedActorId); + + if (!targets.length) { + ui.notifications.warn('No PC selected.'); + return; + } + + for (const actor of targets) { + for (const cond of SLA_CONDITIONS) { + if (actor.statuses?.has(cond.id)) { + try { + await actor.toggleStatusEffect(cond.id, { active: false }); + } catch (err) { + console.error(`SLAConditionApplicator | Failed to clear ${cond.id} on ${actor.name}:`, err); + } + } + } + } + + const targetLabel = this._allPCsMode ? 'all PCs' : (targets[0]?.name ?? 'selected PC'); + ui.notifications.info(`Cleared all conditions from ${targetLabel}.`); + }); + } +} + +SLAConditionApplicator._inst = null; return super.close(options); } @@ -7124,6 +8721,25 @@ Hooks.on('renderActorDirectory', (app, html) => { game.slaGroupNPCTool.render(true); }); actions.append(grpBtn); + + // ── SLA GM Tool buttons ───────────────────────────────────────────────── + const gmToolDefs = [ + { cls: 'sla-dir-status-btn', icon: 'fas fa-clipboard-list', label: 'PC Status', color: '#00d4ff', fn: () => SLAGMStatusWindow.open() }, + { cls: 'sla-dir-credit-btn', icon: 'fas fa-credit-card', label: 'Credits', color: '#00d4ff', fn: () => SLACreditDistributionTool.open() }, + { cls: 'sla-dir-ledger-btn', icon: 'fas fa-book-open', label: 'Ledger', color: '#aa88ff', fn: () => SLAShiftLedger.open() }, + { cls: 'sla-dir-threat-btn', icon: 'fas fa-skull', label: 'Threats', color: '#ff4444', fn: () => SLANPCThreatBoard.open() }, + { cls: 'sla-dir-bpn-btn', icon: 'fas fa-clipboard-check', label: 'BPN', color: '#ffcc00', fn: () => SLABPNTracker.open() }, + { cls: 'sla-dir-condapp-btn', icon: 'fas fa-bolt', label: 'Conditions', color: '#ff6b35', fn: () => SLAConditionApplicator.open() }, + ]; + for (const def of gmToolDefs) { + if (actions.querySelector(`.${def.cls}`)) continue; + const btn = document.createElement('button'); + btn.className = def.cls; + btn.innerHTML = ` ${def.label}`; + btn.style.cssText = `font-size:11px;padding:2px 6px;margin-left:3px;background:rgba(0,0,0,0.3);border:1px solid ${def.color};color:${def.color};border-radius:3px;cursor:pointer;`; + btn.addEventListener('click', def.fn); + actions.append(btn); + } } actions.append(pcBtn, npcBtn);