Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17c64ec0de | ||
|
|
64bef08fe0 | ||
|
|
d81034f6f4 | ||
|
|
f66ac84c0b | ||
|
|
24f513baf8 | ||
|
|
9269348bf4 | ||
|
|
2758be6096 | ||
|
|
b1cfb636df | ||
|
|
0d77741a41 | ||
|
|
6508a7e883 | ||
|
|
8cbea7c237 | ||
|
|
d89671e707 | ||
|
|
d0d907bd98 | ||
|
|
f3264d60e2 | ||
|
|
63da87f372 | ||
|
|
4a5561a82f | ||
|
|
9fb7867069 |
@@ -1645,7 +1645,7 @@ p {
|
||||
|
||||
.mosh .headergrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 1.15fr 1fr 0.9fr 0.95fr 0.95fr 0.95fr 0.9fr;
|
||||
grid-template-columns: 1fr 0.9fr 1fr 1fr 1fr 0.85fr 0.85fr 0.85fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 0.35rem 0.5rem;
|
||||
padding: 1.45em 1em 1em;
|
||||
@@ -1654,7 +1654,8 @@ p {
|
||||
}
|
||||
|
||||
.mosh .sla-sheet-wordmark {
|
||||
grid-column: 1 / -1;
|
||||
grid-column: 1 / 7;
|
||||
grid-row: 1;
|
||||
font-family: "Futura PT Cond Bold", "Josefin Sans", "Roboto", sans-serif;
|
||||
font-size: 1.85rem;
|
||||
font-weight: 700;
|
||||
@@ -1712,13 +1713,11 @@ p {
|
||||
}
|
||||
|
||||
.mosh .header-field-credits {
|
||||
grid-column: 4;
|
||||
grid-row: 3;
|
||||
/* positioned by flex parent .sla-header-bottom-row */
|
||||
}
|
||||
|
||||
.mosh .header-field-lad {
|
||||
grid-column: 1 / span 2;
|
||||
grid-row: 3;
|
||||
/* positioned by flex parent .sla-header-bottom-row */
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
@@ -1733,27 +1732,47 @@ p {
|
||||
}
|
||||
|
||||
.mosh .header-field-sla-rank {
|
||||
grid-column: 5;
|
||||
grid-row: 3;
|
||||
/* positioned by flex parent .sla-header-bottom-row */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mosh .header-field-height {
|
||||
grid-column: 6;
|
||||
grid-row: 3;
|
||||
/* positioned by flex parent .sla-header-bottom-row */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mosh .header-field-weight {
|
||||
grid-column: 7;
|
||||
grid-row: 3;
|
||||
/* positioned by flex parent .sla-header-bottom-row */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mosh .header-field-rest {
|
||||
grid-column: 3;
|
||||
grid-column: 7 / -1;
|
||||
grid-row: 1;
|
||||
align-self: center;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.mosh .sla-header-bottom-row {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
align-self: start;
|
||||
justify-self: start;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.mosh .sla-header-bottom-row .header-field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mosh .sla-header-bottom-row .header-field-lad .sla-header-checkbox {
|
||||
width: 100%;
|
||||
max-width: 78px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mosh .sla-header-checkbox {
|
||||
@@ -1791,13 +1810,16 @@ p {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-self: start;
|
||||
align-self: center;
|
||||
background: linear-gradient(180deg, #4aa8ff 0%, #1b64d8 100%) !important;
|
||||
color: #f4fbff !important;
|
||||
border: 1px solid rgba(134, 209, 255, 0.9) !important;
|
||||
box-shadow: 0 0 0 1px rgba(9, 29, 56, 0.45), 0 0 14px rgba(58, 152, 255, 0.28);
|
||||
padding: 0.28rem 0.55rem !important;
|
||||
font-size: 0.86rem !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
font-size: 1rem !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0.08em !important;
|
||||
text-transform: uppercase !important;
|
||||
}
|
||||
|
||||
.mosh .header-field-compact .headerinputfield {
|
||||
@@ -1805,12 +1827,93 @@ p {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* ── Vevaphon Morph Tab ─────────────────────────────────── */
|
||||
.mosh .sla-morph-track-bar {
|
||||
position: relative;
|
||||
height: 20px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mosh .sla-morph-track-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
background: #3a8a3a;
|
||||
}
|
||||
.mosh .sla-morph-track-fill.instability-elevated { background: #c99d23; }
|
||||
.mosh .sla-morph-track-fill.instability-critical { background: #c0392b; }
|
||||
.mosh .sla-morph-track-label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.7);
|
||||
}
|
||||
.mosh .sla-morph-btn-instab {
|
||||
flex: 1;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
background: linear-gradient(180deg, #c99d23 0%, #a07c12 100%) !important;
|
||||
color: #fff !important;
|
||||
border: 1px solid rgba(255,220,80,0.5) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.mosh .sla-morph-btn-instab-down {
|
||||
background: linear-gradient(180deg, #5a5a5a 0%, #333 100%) !important;
|
||||
border-color: rgba(180,180,180,0.3) !important;
|
||||
}
|
||||
.mosh .sla-morph-form-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 8px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.1) !important;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.mosh .sla-morph-form-btn:hover { background: rgba(255,255,255,0.09); }
|
||||
.mosh .sla-morph-form-btn.sla-morph-active {
|
||||
background: rgba(58,138,58,0.25) !important;
|
||||
border-color: rgba(80,200,80,0.5) !important;
|
||||
}
|
||||
.mosh .sla-morph-form-name {
|
||||
display: block;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mosh .sla-morph-active .sla-morph-form-name { color: #7ddb7d; }
|
||||
.mosh .sla-morph-form-summary {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255,255,255,0.55);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.mosh .sla-tag.instability-elevated { background: #c99d23; color: #fff; }
|
||||
.mosh .sla-tag.instability-critical { background: #c0392b; color: #fff; }
|
||||
/* ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.mosh .header-field-compact .headerinputfield input {
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mosh .header-field-compact .headerinputtext {
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mosh .headerrankgrid {
|
||||
@@ -2235,6 +2338,26 @@ p {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Skill picker scroll list — thin scrollbar, no overflow on the dialog */
|
||||
.macro-popup-dialog .sla-skill-scroll-list {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #aaa #e6e6e6;
|
||||
}
|
||||
.macro-popup-dialog .sla-skill-scroll-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.macro-popup-dialog .sla-skill-scroll-list::-webkit-scrollbar-track {
|
||||
background: #e6e6e6;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.macro-popup-dialog .sla-skill-scroll-list::-webkit-scrollbar-thumb {
|
||||
background: #aaa;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.macro-popup-dialog .sla-skill-scroll-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #888;
|
||||
}
|
||||
|
||||
.mosh .sla-panel {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
@@ -2891,12 +3014,253 @@ p {
|
||||
|
||||
.mosh .sla-generator input,
|
||||
.mosh .sla-generator select,
|
||||
.mosh .sla-generator textarea,
|
||||
.mosh .sla-world-tools input,
|
||||
.mosh .sla-world-tools select {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #101417;
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator textarea,
|
||||
.mosh .sla-creature-dossier input {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #101417;
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator .sla-card {
|
||||
background: linear-gradient(180deg, rgba(243, 240, 232, 0.98), rgba(225, 220, 207, 0.96));
|
||||
border: 1px solid rgba(26, 35, 44, 0.18);
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator .sla-card-copy,
|
||||
.mosh .sla-npc-generator .resource-label {
|
||||
color: #11161b;
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(20, 27, 33, 0.18);
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
background: var(--sla-npc-accent, linear-gradient(90deg, #74a7d3, #d7e4f2));
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-heading {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-badge {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex: 0 0 56px;
|
||||
border-radius: 16px;
|
||||
background: rgba(18, 24, 30, 0.9);
|
||||
color: #f3f6f8;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 8px 20px rgba(0,0,0,0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-badge::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
background: var(--sla-npc-accent, linear-gradient(90deg, #74a7d3, #d7e4f2));
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
opacity: 0.96;
|
||||
filter: drop-shadow(0 1px 0 rgba(255,255,255,0.08));
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-badge span {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 4px;
|
||||
font-size: 0.52rem;
|
||||
font-family: "Josefin Sans", "Roboto", sans-serif;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 700;
|
||||
color: rgba(243, 246, 248, 0.82);
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 88px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(17, 22, 28, 0.08);
|
||||
border: 1px solid rgba(17, 22, 28, 0.14);
|
||||
color: #11161b;
|
||||
font-family: "Josefin Sans", "Roboto", sans-serif;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-block {
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.48);
|
||||
border: 1px solid rgba(18, 24, 30, 0.08);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator .sla-tag,
|
||||
.mosh .sla-npc-generator .sla-note-pill {
|
||||
background: rgba(18, 24, 30, 0.06);
|
||||
border-color: rgba(18, 24, 30, 0.12);
|
||||
color: #151a1f;
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator.sla-npc-accent-shiver {
|
||||
--sla-npc-accent: linear-gradient(90deg, #5ca3d9, #d9edf9);
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator.sla-npc-accent-darknight {
|
||||
--sla-npc-accent: linear-gradient(90deg, #7a2238, #d59baa);
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator.sla-npc-accent-tek {
|
||||
--sla-npc-accent: linear-gradient(90deg, #4ea5b7, #d0f0f4);
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator.sla-npc-accent-ebb {
|
||||
--sla-npc-accent: linear-gradient(90deg, #6f63d9, #d9d7ff);
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator.sla-npc-accent-media {
|
||||
--sla-npc-accent: linear-gradient(90deg, #d09a34, #f6e2b0);
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator.sla-npc-accent-internal {
|
||||
--sla-npc-accent: linear-gradient(90deg, #9b2f2f, #f1b7b7);
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator.sla-npc-accent-gang {
|
||||
--sla-npc-accent: linear-gradient(90deg, #b04c34, #f0c2a8);
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator.sla-npc-accent-predator {
|
||||
--sla-npc-accent: linear-gradient(90deg, #7a8b38, #dfe8b1);
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator.sla-npc-accent-civilian {
|
||||
--sla-npc-accent: linear-gradient(90deg, #7f8b93, #dde3e8);
|
||||
}
|
||||
|
||||
.mosh .sla-npc-generator.sla-npc-accent-corp {
|
||||
--sla-npc-accent: linear-gradient(90deg, #6c8aa6, #d8e4ef);
|
||||
}
|
||||
|
||||
.mosh .sla-creature-dossier {
|
||||
margin: 10px 12px 14px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(14, 20, 28, 0.82);
|
||||
border: 1px solid rgba(118, 164, 206, 0.18);
|
||||
}
|
||||
|
||||
.mosh .sla-creature-dossier-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mosh .sla-creature-dossier .resource {
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mosh .sla-creature-dossier .resource-label {
|
||||
color: #d7e5ef;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.72rem;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mosh .sla-creature-dossier input {
|
||||
min-height: 32px;
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mosh .sla-creature-dossier-wide {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.mosh .sla-npc-preview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mosh .sla-npc-preview-wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.mosh .sla-creature-dossier-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.mosh .sla-creature-dossier-wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mosh .sla-creature-dossier-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mosh .sla-creature-dossier-wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
.mosh .headerinputtext,
|
||||
.mosh .headerinputfield input,
|
||||
.mosh .charname input,
|
||||
@@ -5075,3 +5439,190 @@ p {
|
||||
background: #1a120c;
|
||||
border: 1px solid #8a5a31;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
ACTIVE CONDITIONS SUMMARY (below stats)
|
||||
========================================== */
|
||||
.sla-active-conditions-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
margin-top: 4px;
|
||||
background: rgba(180, 0, 0, 0.12);
|
||||
border: 1px solid #8b0000;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sla-conditions-label {
|
||||
font-size: 0.72em;
|
||||
font-weight: 700;
|
||||
color: #ff4444;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sla-active-condition {
|
||||
font-size: 0.78em;
|
||||
font-weight: 700;
|
||||
color: #ff4444;
|
||||
text-shadow: 0 0 4px rgba(255, 68, 68, 0.45);
|
||||
padding: 1px 6px;
|
||||
border: 1px solid #8b0000;
|
||||
border-radius: 3px;
|
||||
background: rgba(139, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
SLA Industries — Sidebar Tabs and Scene Controls Modern Overhaul
|
||||
============================================================================= */
|
||||
|
||||
/* ── Scene Controls (Left Panel) ────────────────────────────────────────── */
|
||||
#controls {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
#controls ol.main-controls,
|
||||
#controls ol.sub-controls {
|
||||
pointer-events: auto !important;
|
||||
background: rgba(10, 12, 16, 0.88) !important;
|
||||
border: 1px solid rgba(0, 229, 255, 0.2) !important;
|
||||
border-radius: 10px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.65), 0 0 14px rgba(0, 229, 255, 0.08) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
padding: 5px !important;
|
||||
gap: 5px !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
#controls li.scene-control,
|
||||
#controls li.control-tool {
|
||||
position: relative !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06) !important;
|
||||
background: rgba(255, 255, 255, 0.02) !important;
|
||||
color: #8fa4b8 !important; /* Premium muted steel-blue */
|
||||
border-radius: 8px !important;
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
cursor: pointer !important;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02) !important;
|
||||
}
|
||||
|
||||
/* Hover State */
|
||||
#controls li.scene-control:hover,
|
||||
#controls li.control-tool:hover {
|
||||
color: #00e5ff !important; /* Vibrant Neon Cyan */
|
||||
background: rgba(0, 229, 255, 0.1) !important;
|
||||
border-color: rgba(0, 229, 255, 0.5) !important;
|
||||
box-shadow: 0 0 14px rgba(0, 229, 255, 0.35), inset 0 1px 0 rgba(0, 229, 255, 0.15) !important;
|
||||
transform: translateY(-1px) scale(1.04) !important;
|
||||
}
|
||||
|
||||
/* Active State */
|
||||
#controls li.scene-control.active,
|
||||
#controls li.control-tool.active {
|
||||
color: #00e5ff !important;
|
||||
background: rgba(0, 229, 255, 0.16) !important;
|
||||
border-color: #00e5ff !important;
|
||||
box-shadow: 0 0 20px rgba(0, 229, 255, 0.5), inset 0 1px 0 rgba(0, 229, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
/* Active side indicator bar */
|
||||
#controls li.scene-control.active::before,
|
||||
#controls li.control-tool.active::before {
|
||||
content: "" !important;
|
||||
position: absolute !important;
|
||||
left: -2px !important;
|
||||
top: 15% !important;
|
||||
height: 70% !important;
|
||||
width: 2px !important;
|
||||
background: #00e5ff !important;
|
||||
box-shadow: 0 0 8px #00e5ff, 0 0 15px #00e5ff !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
|
||||
/* Sub-controls offset container glow adjustment */
|
||||
#controls ol.sub-controls {
|
||||
border-color: rgba(0, 229, 255, 0.14) !important;
|
||||
background: rgba(14, 18, 24, 0.82) !important;
|
||||
}
|
||||
|
||||
|
||||
/* ── Right Sidebar Tabs (Navigation Panel) ─────────────────────────────── */
|
||||
#sidebar-tabs {
|
||||
background: rgba(10, 12, 16, 0.88) !important;
|
||||
border-bottom: 2px solid rgba(0, 229, 255, 0.25) !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.05) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
border-radius: 8px 8px 0 0 !important;
|
||||
padding: 3px 5px !important;
|
||||
gap: 4px !important;
|
||||
display: flex !important;
|
||||
margin: 0 !important;
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
#sidebar-tabs button[data-tab],
|
||||
#sidebar-tabs [data-tab].icon,
|
||||
#sidebar-tabs .item {
|
||||
position: relative !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06) !important;
|
||||
background: rgba(255, 255, 255, 0.02) !important;
|
||||
color: #8fa4b8 !important;
|
||||
border-radius: 6px !important;
|
||||
margin: 2px 0 !important;
|
||||
height: 32px !important;
|
||||
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
cursor: pointer !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02) !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
/* Hover State */
|
||||
#sidebar-tabs button[data-tab]:hover,
|
||||
#sidebar-tabs [data-tab].icon:hover,
|
||||
#sidebar-tabs .item:hover {
|
||||
color: #00e5ff !important;
|
||||
background: rgba(0, 229, 255, 0.1) !important;
|
||||
border-color: rgba(0, 229, 255, 0.5) !important;
|
||||
box-shadow: 0 0 14px rgba(0, 229, 255, 0.35), inset 0 1px 0 rgba(0, 229, 255, 0.15) !important;
|
||||
transform: translateY(-1px) scale(1.04) !important;
|
||||
}
|
||||
|
||||
/* Active State */
|
||||
#sidebar-tabs button[data-tab].active,
|
||||
#sidebar-tabs [data-tab].icon.active,
|
||||
#sidebar-tabs .item.active {
|
||||
color: #00e5ff !important;
|
||||
background: rgba(0, 229, 255, 0.16) !important;
|
||||
border-color: #00e5ff !important;
|
||||
box-shadow: 0 0 18px rgba(0, 229, 255, 0.45), inset 0 1px 0 rgba(0, 229, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
/* Glowing bottom line indicator on active tabs */
|
||||
#sidebar-tabs button[data-tab].active::after,
|
||||
#sidebar-tabs [data-tab].icon.active::after,
|
||||
#sidebar-tabs .item.active::after {
|
||||
content: "" !important;
|
||||
position: absolute !important;
|
||||
bottom: -3px !important;
|
||||
left: 15% !important;
|
||||
width: 70% !important;
|
||||
height: 2px !important;
|
||||
background: #00e5ff !important;
|
||||
box-shadow: 0 0 8px #00e5ff, 0 0 15px #00e5ff !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect x="14" y="12" width="36" height="40" rx="6" stroke="#F4F7FA" stroke-width="4"/>
|
||||
<circle cx="24" cy="27" r="5" stroke="#F4F7FA" stroke-width="4"/>
|
||||
<path d="M19 40c2-4 5-6 9-6s7 2 9 6M36 24h8M36 32h8M36 40h8" stroke="#F4F7FA" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<path d="M20 50V18l12-8 12 8v32" stroke="#F4F7FA" stroke-width="4" stroke-linejoin="round"/>
|
||||
<path d="M26 24h3M35 24h3M26 31h3M35 31h3M26 38h3M35 38h3" stroke="#F4F7FA" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M16 50h32" stroke="#F4F7FA" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<path d="M39 10c-8 4-13 12-13 22 0 10 5 18 13 22-13-1.5-24-11.8-24-27S26 11.5 39 10Z" fill="#F4F7FA"/>
|
||||
<path d="m40 21 8 11-8 11" stroke="#F4F7FA" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<path d="M10 32c5.7-9 13.1-13.5 22-13.5S48.3 23 54 32c-5.7 9-13.1 13.5-22 13.5S15.7 41 10 32Z" stroke="#F4F7FA" stroke-width="4" stroke-linejoin="round"/>
|
||||
<circle cx="32" cy="32" r="7" stroke="#F4F7FA" stroke-width="4"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#F4F7FA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 356 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<path d="M23 18h18l5 8-3 15H21l-3-15 5-8Z" stroke="#F4F7FA" stroke-width="4" stroke-linejoin="round"/>
|
||||
<circle cx="26" cy="29" r="2.5" fill="#F4F7FA"/>
|
||||
<circle cx="38" cy="29" r="2.5" fill="#F4F7FA"/>
|
||||
<path d="M26 37c2 2 4 3 6 3s4-1 6-3" stroke="#F4F7FA" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<path d="M18 12h20l8 8v28a4 4 0 0 1-4 4H18a4 4 0 0 1-4-4V16a4 4 0 0 1 4-4Z" stroke="#F4F7FA" stroke-width="4" stroke-linejoin="round"/>
|
||||
<path d="M38 12v10h10M24 30h12M24 38h16" stroke="#F4F7FA" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 320 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<path d="M32 16v28M24 44h16" stroke="#F4F7FA" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M22 22c2.5-3.5 6-5 10-5s7.5 1.5 10 5M16 16c4.3-5.3 9.6-8 16-8s11.7 2.7 16 8M16 28c4.3 5.3 9.6 8 16 8s11.7-2.7 16-8" stroke="#F4F7FA" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 356 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<path d="M18 48 28 16M30 50l8-26M42 48l6-18" stroke="#F4F7FA" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="M18 48c2-4 6-7 10-8M30 50c2-4 5-7 8-8M42 48c1.5-3 4-5.5 7-6.5" stroke="#F4F7FA" stroke-width="3.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<path d="M32 8 48 14v14c0 11-6.4 20.8-16 28-9.6-7.2-16-17-16-28V14L32 8Z" stroke="#F4F7FA" stroke-width="4" stroke-linejoin="round"/>
|
||||
<path d="M32 18v24M22 30h20" stroke="#F4F7FA" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<path d="M24 10h16l8 14-8 14H24l-8-14 8-14Z" stroke="#F4F7FA" stroke-width="4" stroke-linejoin="round"/>
|
||||
<circle cx="32" cy="24" r="4" fill="#F4F7FA"/>
|
||||
<path d="M32 28v18M32 38h10M32 46H22" stroke="#F4F7FA" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 335 B |
@@ -306,6 +306,19 @@ export class MothershipActorSheet extends foundry.appv1.sheets.ActorSheet {
|
||||
data.data.vevaphonInstabilityElevated = instab >= 6;
|
||||
data.data.vevaphonInstabilityCritical = instab >= 10;
|
||||
data.data.vevaphonInstabilityClass = instab >= 10 ? "instability-critical" : instab >= 6 ? "instability-elevated" : "instability-nominal";
|
||||
data.data.vevaphonInstabilityDesc = instab >= 10
|
||||
? "Instability critical. Full reversion risk on every shift."
|
||||
: instab >= 6
|
||||
? "Instability elevated. Sanity saves required when morphing mid-scene."
|
||||
: "Instability nominal. Morphing is available without additional risk.";
|
||||
data.data.vevaphonMorphPanic = [
|
||||
{ roll: 1, label: "Cascade Shift", desc: "Random morph form, +1 Instability." },
|
||||
{ roll: 2, label: "Identity Bleed", desc: "Forgets name and callsign until end of scene." },
|
||||
{ roll: 3, label: "Partial Lock", desc: "One limb freezes. −10 physical rolls using that limb." },
|
||||
{ roll: 4, label: "Tissue Rejection", desc: "1d5 damage, armour does not apply." },
|
||||
{ roll: 5, label: "Threat Imprint", desc: "Locked on nearest visible target." },
|
||||
{ roll: 6, label: "Full Reversion", desc: "Base form, lose morph bonuses, −1 Instability." }
|
||||
];
|
||||
}
|
||||
data.data.slaSpeciesTraitList = String(superData.sla.speciesNotes?.value ?? "").split(/\n+/).map((entry) => entry.trim()).filter(Boolean);
|
||||
data.data.slaPackageSkillList = String(superData.sla.packageSkills?.value ?? "").split(/\s*,\s*/).map((entry) => entry.trim()).filter(Boolean);
|
||||
@@ -444,6 +457,7 @@ export class MothershipActorSheet extends foundry.appv1.sheets.ActorSheet {
|
||||
const armors = [];
|
||||
const conditions = [];
|
||||
const abilities = [];
|
||||
const morphForms = [];
|
||||
const persTraits = [];
|
||||
|
||||
let curWeight = 0;
|
||||
@@ -550,7 +564,11 @@ export class MothershipActorSheet extends foundry.appv1.sheets.ActorSheet {
|
||||
|
||||
conditions.push(i);
|
||||
} else if (i.type === 'ability') {
|
||||
abilities.push(i);
|
||||
if (i.system?.sla?.morphForm) {
|
||||
morphForms.push(i);
|
||||
} else {
|
||||
abilities.push(i);
|
||||
}
|
||||
} else if (i.type === 'persTrait') {
|
||||
persTraits.push(i);
|
||||
}
|
||||
@@ -587,6 +605,9 @@ export class MothershipActorSheet extends foundry.appv1.sheets.ActorSheet {
|
||||
actorData.weapons = weapons;
|
||||
actorData.conditions = conditions;
|
||||
actorData.abilities = abilities;
|
||||
const activeMorphFormName = String(actorData.system?.sla?.morphForm?.value ?? "").trim();
|
||||
morphForms.forEach(f => { f.slaIsActiveMorphForm = f.name === activeMorphFormName; });
|
||||
actorData.slaMorphForms = morphForms;
|
||||
actorData.persTraits = persTraits.sort((left, right) => String(left?.name ?? "").localeCompare(String(right?.name ?? "")));
|
||||
|
||||
}
|
||||
@@ -805,7 +826,7 @@ export class MothershipActorSheet extends foundry.appv1.sheets.ActorSheet {
|
||||
var item;
|
||||
item = foundry.utils.duplicate(this.actor.getEmbeddedDocument("Item", li.dataset.itemId));
|
||||
|
||||
this.actor.rollCheck(null, null, null, item.name, item.system.bonus, null);
|
||||
this.actor.rollCheck(null, null, null, item.name, item.system.bonus, null, null, { promptModifier: true });
|
||||
});
|
||||
|
||||
html.find('.sla-ebb-skill-roll').click(ev => {
|
||||
@@ -837,7 +858,7 @@ export class MothershipActorSheet extends foundry.appv1.sheets.ActorSheet {
|
||||
//const item = duplicate(this.actor.getEmbeddedDocument("Item", li.dataset.itemId));
|
||||
var item;
|
||||
item = foundry.utils.duplicate(this.actor.getEmbeddedDocument("Item", li.dataset.itemId));
|
||||
this.actor.rollWeaponAttack(item);
|
||||
this.actor.rollWeaponAttack(item, { promptModifier: true });
|
||||
});
|
||||
|
||||
// Rollable Damage
|
||||
|
||||
@@ -4,6 +4,21 @@ import { SLADrugSystem } from "../sla-drug-system.js";
|
||||
import { SLA_SPECIES_BALANCE_VERSION, getSlaSpeciesStatAdjustments } from "../sla-species-balance.js";
|
||||
import { buildSlaTraumaResponseText } from "../sla-trauma.js";
|
||||
|
||||
// Global event delegation to handle interactive range slider value updates
|
||||
// in a way that is fully compatible with Foundry VTT's HTML sanitization.
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("input", (event) => {
|
||||
const target = event.target;
|
||||
if (target && target.tagName === "INPUT" && target.name === "rollModifier") {
|
||||
const value = Number(target.value) || 0;
|
||||
const label = target.closest(".resource")?.querySelector(".slider-value");
|
||||
if (label) {
|
||||
label.innerText = (value >= 0 ? "+" : "") + value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const SLA_AMMO_TYPES = {
|
||||
STD: { label: "Standard", multiplier: 1, damageBonus: "", woundEffect: "", summary: "Standard ammunition." },
|
||||
AP: { label: "AP", multiplier: 2, damageBonus: "-1", woundEffect: "Gunshot [-]", summary: "Armour-piercing ammunition. Treat target armour as halved and reduce damage by 1." },
|
||||
@@ -1729,8 +1744,6 @@ export class MothershipActor extends Actor {
|
||||
let skillList = ``;
|
||||
//create skill counter
|
||||
let skillCount = 0;
|
||||
//create dialog pixel counter
|
||||
let dialogHeight = 232;
|
||||
//loop through and create skill rows
|
||||
const filteredSkills = playerItems.filter((item) => item.type === "skill" && this.isSkillRelevantForContext(item, context));
|
||||
const skillSource = filteredSkills.length
|
||||
@@ -1758,13 +1771,9 @@ export class MothershipActor extends Actor {
|
||||
skillList = skillList + tempRow;
|
||||
//increment skill count
|
||||
skillCount++;
|
||||
//increment pixel counter
|
||||
dialogHeight = dialogHeight + 77;
|
||||
}
|
||||
//check if there are no skills, and adjust prompt height accordingly
|
||||
//check if there are no skills
|
||||
if (skillCount === 0) {
|
||||
//set window height
|
||||
dialogHeight = 170;
|
||||
skillList = `<div class="macro_window"><div class="macro_desc" style="padding: 12px 14px;">${requiresCombatSkills ? "No combat-tagged skills are available for this action." : "No matching skills are available for this action."}</div></div>`;
|
||||
}
|
||||
//create button header if needed
|
||||
@@ -1773,12 +1782,24 @@ export class MothershipActor extends Actor {
|
||||
} else {
|
||||
buttonDesc = ``;
|
||||
}
|
||||
//wrap skill list in a scrollable container so long lists don't push buttons off-screen
|
||||
const scrollableSkillList = `<div class="sla-skill-scroll-list" style="max-height: 340px; overflow-y: auto; padding-right: 4px; margin-bottom: 4px;">${skillList}</div>`;
|
||||
//create final dialog data
|
||||
const showModifier = !foundry.utils.hasProperty(context.attackContext ?? {}, "rollModifier");
|
||||
const modifierHtml = showModifier ? `
|
||||
<div class="resource" style="margin-top: 15px; margin-bottom: 10px; border-top: 1px dashed rgba(255,255,255,0.15); padding-top: 10px; padding-left: 10px; padding-right: 10px;">
|
||||
<label class="resource-label" style="display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 8px;">
|
||||
<span>Roll Modifier</span>
|
||||
<span class="slider-value" style="font-weight: bold; color: #cc2828;">0</span>
|
||||
</label>
|
||||
<input type="range" name="rollModifier" min="-30" max="30" step="10" value="0" style="width: 100%; cursor: pointer; accent-color: #cc2828;" oninput="this.previousElementSibling.querySelector('.slider-value').innerText = (this.value >= 0 ? '+' : '') + this.value">
|
||||
</div>
|
||||
` : "";
|
||||
const dialogData = {
|
||||
window: {title: dlgTitle},
|
||||
classes: ["macro-popup-dialog"],
|
||||
position: {width: 600},
|
||||
content: skillHeader + skillList + buttonDesc,
|
||||
content: skillHeader + scrollableSkillList + modifierHtml + buttonDesc,
|
||||
buttons: []
|
||||
};
|
||||
//add adv/normal/dis buttons if we need a rollString
|
||||
@@ -1792,8 +1813,9 @@ export class MothershipActor extends Actor {
|
||||
rollString = `1d100 [+]`;
|
||||
skill = button.form.querySelector("input[name='skill']:checked")?.getAttribute("id");
|
||||
skillValue = button.form.querySelector("input[name='skill']:checked")?.getAttribute("value");
|
||||
resolve([rollString, skill, skillValue]);
|
||||
slaDebug(`User left the chooseSkill dialog with: rollString:${rollString}, skill:${skill}, skillValue:${skillValue}`);
|
||||
const rollModifier = showModifier ? (Number(button.form.querySelector("input[name='rollModifier']")?.value ?? 0) || 0) : (Number(context.attackContext?.rollModifier ?? 0) || 0);
|
||||
resolve([rollString, skill, skillValue, rollModifier]);
|
||||
slaDebug(`User left the chooseSkill dialog with: rollString:${rollString}, skill:${skill}, skillValue:${skillValue}, modifier:${rollModifier}`);
|
||||
},
|
||||
icon: `fas fa-angle-double-up`
|
||||
},
|
||||
@@ -1804,8 +1826,9 @@ export class MothershipActor extends Actor {
|
||||
rollString = `1d100`;
|
||||
skill = button.form.querySelector("input[name='skill']:checked")?.getAttribute("id");
|
||||
skillValue = button.form.querySelector("input[name='skill']:checked")?.getAttribute("value");
|
||||
resolve([rollString, skill, skillValue]);
|
||||
slaDebug(`User left the chooseSkill dialog with: rollString:${rollString}, skill:${skill}, skillValue:${skillValue}`);
|
||||
const rollModifier = showModifier ? (Number(button.form.querySelector("input[name='rollModifier']")?.value ?? 0) || 0) : (Number(context.attackContext?.rollModifier ?? 0) || 0);
|
||||
resolve([rollString, skill, skillValue, rollModifier]);
|
||||
slaDebug(`User left the chooseSkill dialog with: rollString:${rollString}, skill:${skill}, skillValue:${skillValue}, modifier:${rollModifier}`);
|
||||
},
|
||||
icon: `fas fa-minus`
|
||||
},
|
||||
@@ -1816,8 +1839,9 @@ export class MothershipActor extends Actor {
|
||||
rollString = `1d100 [-]`;
|
||||
skill = button.form.querySelector("input[name='skill']:checked")?.getAttribute("id");
|
||||
skillValue = button.form.querySelector("input[name='skill']:checked")?.getAttribute("value");
|
||||
resolve([rollString, skill, skillValue]);
|
||||
slaDebug(`User left the chooseSkill dialog with: rollString:${rollString}, skill:${skill}, skillValue:${skillValue}`);
|
||||
const rollModifier = showModifier ? (Number(button.form.querySelector("input[name='rollModifier']")?.value ?? 0) || 0) : (Number(context.attackContext?.rollModifier ?? 0) || 0);
|
||||
resolve([rollString, skill, skillValue, rollModifier]);
|
||||
slaDebug(`User left the chooseSkill dialog with: rollString:${rollString}, skill:${skill}, skillValue:${skillValue}, modifier:${rollModifier}`);
|
||||
},
|
||||
icon: `fas fa-angle-double-down`
|
||||
}
|
||||
@@ -1831,8 +1855,9 @@ export class MothershipActor extends Actor {
|
||||
callback: (event, button, dialog) => {
|
||||
skill = button.form.querySelector("input[name='skill']:checked")?.getAttribute("id");
|
||||
skillValue = button.form.querySelector("input[name='skill']:checked")?.getAttribute("value");
|
||||
resolve([rollString, skill, skillValue]);
|
||||
slaDebug(`User left the chooseSkill dialog with: rollString:${rollString}, skill:${skill}, skillValue:${skillValue}`);
|
||||
const rollModifier = showModifier ? (Number(button.form.querySelector("input[name='rollModifier']")?.value ?? 0) || 0) : (Number(context.attackContext?.rollModifier ?? 0) || 0);
|
||||
resolve([rollString, skill, skillValue, rollModifier]);
|
||||
slaDebug(`User left the chooseSkill dialog with: rollString:${rollString}, skill:${skill}, skillValue:${skillValue}, modifier:${rollModifier}`);
|
||||
},
|
||||
icon: `fas fa-chevron-circle-right`
|
||||
}
|
||||
@@ -1853,19 +1878,29 @@ export class MothershipActor extends Actor {
|
||||
let dieAdv = die + ` [+]`;
|
||||
let dieDis = die + ` [-]`;
|
||||
//create final dialog data
|
||||
const modifierHtml = `
|
||||
<div class="resource" style="margin-top: 15px; margin-bottom: 10px; border-top: 1px dashed rgba(255,255,255,0.15); padding-top: 10px; padding-left: 10px; padding-right: 10px;">
|
||||
<label class="resource-label" style="display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 8px;">
|
||||
<span>Roll Modifier</span>
|
||||
<span class="slider-value" style="font-weight: bold; color: #cc2828;">0</span>
|
||||
</label>
|
||||
<input type="range" name="rollModifier" min="-30" max="30" step="10" value="0" style="width: 100%; cursor: pointer; accent-color: #cc2828;" oninput="this.previousElementSibling.querySelector('.slider-value').innerText = (this.value >= 0 ? '+' : '') + this.value">
|
||||
</div>
|
||||
`;
|
||||
const dialogData = {
|
||||
window: {title: dlgTitle},
|
||||
classes: ["macro-popup-dialog"],
|
||||
position: {width: 600},
|
||||
content: `<div class="macro_prompt">` + game.i18n.localize("Mosh.SelectYourRollType") + `:</div>`,
|
||||
content: `<form style="padding: 5px 10px;"><div class="macro_prompt" style="margin-bottom:10px;">` + game.i18n.localize("Mosh.SelectYourRollType") + `:</div>` + modifierHtml + `</form>`,
|
||||
buttons: [
|
||||
{
|
||||
label: game.i18n.localize("Mosh.Advantage"),
|
||||
action: `action_advantage`,
|
||||
callback: (event, button, dialog) => {
|
||||
rollString = dieAdv;
|
||||
resolve([rollString]);
|
||||
slaDebug(`User left the chooseAdvantage dialog with: rollString:${rollString}`);
|
||||
const rollModifier = Number(button.form.querySelector("input[name='rollModifier']")?.value ?? 0) || 0;
|
||||
resolve([rollString, rollModifier]);
|
||||
slaDebug(`User left the chooseAdvantage dialog with: rollString:${rollString}, modifier:${rollModifier}`);
|
||||
},
|
||||
icon: `fas fa-angle-double-up`
|
||||
},
|
||||
@@ -1874,8 +1909,9 @@ export class MothershipActor extends Actor {
|
||||
action: `action_normal`,
|
||||
callback: (event, button, dialog) => {
|
||||
rollString = die;
|
||||
resolve([rollString]);
|
||||
slaDebug(`User left the chooseAdvantage dialog with: rollString:${rollString}`);
|
||||
const rollModifier = Number(button.form.querySelector("input[name='rollModifier']")?.value ?? 0) || 0;
|
||||
resolve([rollString, rollModifier]);
|
||||
slaDebug(`User left the chooseAdvantage dialog with: rollString:${rollString}, modifier:${rollModifier}`);
|
||||
},
|
||||
icon: `fas fa-minus`
|
||||
},
|
||||
@@ -1884,8 +1920,9 @@ export class MothershipActor extends Actor {
|
||||
action: `action_disadvantage`,
|
||||
callback: (event, button, dialog) => {
|
||||
rollString = dieDis;
|
||||
resolve([rollString]);
|
||||
slaDebug(`User left the chooseAdvantage dialog with: rollString:${rollString}`);
|
||||
const rollModifier = Number(button.form.querySelector("input[name='rollModifier']")?.value ?? 0) || 0;
|
||||
resolve([rollString, rollModifier]);
|
||||
slaDebug(`User left the chooseAdvantage dialog with: rollString:${rollString}, modifier:${rollModifier}`);
|
||||
},
|
||||
icon: `fas fa-angle-double-down`
|
||||
}
|
||||
@@ -2478,6 +2515,9 @@ export class MothershipActor extends Actor {
|
||||
rollString = chosenSkills[0];
|
||||
skill = chosenSkills[1];
|
||||
skillValue = chosenSkills[2];
|
||||
if (chosenSkills[3] !== undefined) {
|
||||
attackContext.rollModifier = chosenSkills[3];
|
||||
}
|
||||
}
|
||||
//if rollString is STILL blank, redirect player to choose the roll
|
||||
if (!rollString) {
|
||||
@@ -2485,6 +2525,9 @@ export class MothershipActor extends Actor {
|
||||
let chosenRollType = await this.chooseAdvantage(this.system.stats[attribute].rollLabel, '1d100');
|
||||
//set variables
|
||||
rollString = chosenRollType[0];
|
||||
if (chosenRollType[1] !== undefined) {
|
||||
attackContext.rollModifier = chosenRollType[1];
|
||||
}
|
||||
}
|
||||
//if this is a weapon roll
|
||||
if (weapon) {
|
||||
@@ -2639,6 +2682,18 @@ export class MothershipActor extends Actor {
|
||||
} else {
|
||||
rollTarget = rollTargetOverride;
|
||||
}
|
||||
const modifier = Number(attackContext?.rollModifier ?? 0) || 0;
|
||||
if (modifier) {
|
||||
rollTarget += modifier;
|
||||
if (effectiveRollBreakdown) {
|
||||
effectiveRollBreakdown.parts.push({
|
||||
label: "Roll Modifier",
|
||||
value: modifier
|
||||
});
|
||||
effectiveRollBreakdown.total += modifier;
|
||||
effectiveRollBreakdown.formulaText += ` ${modifier >= 0 ? "+" : ""}${modifier}`;
|
||||
}
|
||||
}
|
||||
rollTarget = this.clampSlaRollTarget(rollTarget, rollString);
|
||||
if (effectiveRollBreakdown && (String(rollString ?? "").includes("1d100"))) {
|
||||
effectiveRollBreakdown = {
|
||||
@@ -4009,10 +4064,58 @@ export class MothershipActor extends Actor {
|
||||
return { total, parts };
|
||||
}
|
||||
|
||||
async chooseWeaponAttackOptions(weapon) {
|
||||
async promptRollModifier(title = "Roll Modifier") {
|
||||
return new Promise((resolve) => {
|
||||
const dialogData = {
|
||||
window: { title },
|
||||
content: `
|
||||
<form style="padding: 5px 10px;">
|
||||
<div class="resource" style="margin-bottom: 15px;">
|
||||
<label class="resource-label" style="display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 8px;">
|
||||
<span>Modifier Value</span>
|
||||
<span class="slider-value" style="font-weight: bold; color: #cc2828;">0</span>
|
||||
</label>
|
||||
<input type="range" name="rollModifier" min="-30" max="30" step="10" value="0" style="width: 100%; cursor: pointer; accent-color: #cc2828;">
|
||||
<div class="slider-ticks" style="display: flex; justify-content: space-between; margin-top: 5px; font-size: 0.85em; color: rgba(255,255,255,0.6); padding: 0 8px;">
|
||||
<span>-30</span>
|
||||
<span>-20</span>
|
||||
<span>-10</span>
|
||||
<span style="color: #cc2828; font-weight: bold;">0</span>
|
||||
<span>+10</span>
|
||||
<span>+20</span>
|
||||
<span>+30</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
buttons: [
|
||||
{
|
||||
label: "Roll",
|
||||
action: "roll",
|
||||
icon: "fas fa-dice-d20",
|
||||
callback: (_event, button) => {
|
||||
const modifier = Number(button.form.querySelector("input[name='rollModifier']")?.value ?? 0) || 0;
|
||||
resolve(modifier);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Cancel",
|
||||
action: "cancel",
|
||||
icon: "fas fa-times",
|
||||
callback: () => resolve(null)
|
||||
}
|
||||
],
|
||||
close: () => resolve(null)
|
||||
};
|
||||
new foundry.applications.api.DialogV2(dialogData).render({ force: true });
|
||||
});
|
||||
}
|
||||
|
||||
async chooseWeaponAttackOptions(weapon, attackContext = {}) {
|
||||
const fireModes = this.getWeaponFireModes(weapon);
|
||||
const activeFireMode = this.getActiveWeaponFireMode(weapon);
|
||||
const ammoChoices = this.getWeaponAmmoChoices(weapon);
|
||||
const showModifier = Boolean(attackContext?.promptModifier);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const fireOptions = fireModes.map((mode) => `
|
||||
@@ -4022,20 +4125,40 @@ export class MothershipActor extends Actor {
|
||||
<option value="${choice.tag}" ${choice.selected ? "selected" : ""}>${choice.label}</option>
|
||||
`).join("");
|
||||
|
||||
const modifierHtml = showModifier ? `
|
||||
<div class="resource" style="margin-top: 15px; margin-bottom: 10px; border-top: 1px dashed rgba(255,255,255,0.15); padding-top: 10px;">
|
||||
<label class="resource-label" style="display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 8px;">
|
||||
<span>Roll Modifier</span>
|
||||
<span class="slider-value" style="font-weight: bold; color: #cc2828;">0</span>
|
||||
</label>
|
||||
<input type="range" name="rollModifier" min="-30" max="30" step="10" value="0" style="width: 100%; cursor: pointer; accent-color: #cc2828;">
|
||||
<div class="slider-ticks" style="display: flex; justify-content: space-between; margin-top: 5px; font-size: 0.85em; color: rgba(255,255,255,0.6); padding: 0 8px;">
|
||||
<span>-30</span>
|
||||
<span>-20</span>
|
||||
<span>-10</span>
|
||||
<span style="color: #cc2828; font-weight: bold;">0</span>
|
||||
<span>+10</span>
|
||||
<span>+20</span>
|
||||
<span>+30</span>
|
||||
</div>
|
||||
</div>
|
||||
` : "";
|
||||
|
||||
const dialogData = {
|
||||
window: {
|
||||
title: `${weapon.name} Attack Setup`
|
||||
},
|
||||
content: `
|
||||
<form>
|
||||
<form style="padding: 5px 10px;">
|
||||
<div class="resource" style="margin-bottom: 10px;">
|
||||
<label class="resource-label">Fire Mode</label>
|
||||
<select name="fireMode">${fireOptions}</select>
|
||||
<label class="resource-label" style="font-weight: bold; margin-bottom: 5px; display: block;">Fire Mode</label>
|
||||
<select name="fireMode" style="width: 100%;">${fireOptions}</select>
|
||||
</div>
|
||||
<div class="resource" style="margin-bottom: 10px;">
|
||||
<label class="resource-label">Ammo Type</label>
|
||||
<select name="ammoTag">${ammoOptions}</select>
|
||||
<label class="resource-label" style="font-weight: bold; margin-bottom: 5px; display: block;">Ammo Type</label>
|
||||
<select name="ammoTag" style="width: 100%;">${ammoOptions}</select>
|
||||
</div>
|
||||
${modifierHtml}
|
||||
</form>
|
||||
`,
|
||||
buttons: [
|
||||
@@ -4047,10 +4170,12 @@ export class MothershipActor extends Actor {
|
||||
const fireLabel = button.form.querySelector("select[name='fireMode']")?.value ?? activeFireMode.label;
|
||||
const fireMode = fireModes.find((mode) => mode.label === fireLabel) ?? activeFireMode;
|
||||
const ammoTag = button.form.querySelector("select[name='ammoTag']")?.value ?? ammoChoices[0]?.tag ?? "STD";
|
||||
const rollModifier = showModifier ? (Number(button.form.querySelector("input[name='rollModifier']")?.value ?? 0) || 0) : 0;
|
||||
resolve({
|
||||
fireMode: fireMode.label,
|
||||
shotsPerFire: fireMode.shots,
|
||||
ammoTag
|
||||
ammoTag,
|
||||
rollModifier
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -4067,10 +4192,20 @@ export class MothershipActor extends Actor {
|
||||
});
|
||||
}
|
||||
|
||||
async rollWeaponAttack(weapon) {
|
||||
const attackContext = weapon?.system?.useAmmo ? await this.chooseWeaponAttackOptions(weapon) : {};
|
||||
if (weapon?.system?.useAmmo && !attackContext) return;
|
||||
return this.rollCheck(null, 'low', 'combat', null, null, weapon, null, attackContext ?? {});
|
||||
async rollWeaponAttack(weapon, attackContext = {}) {
|
||||
let context = { ...attackContext };
|
||||
if (weapon?.system?.useAmmo) {
|
||||
const ammoContext = await this.chooseWeaponAttackOptions(weapon, context);
|
||||
if (!ammoContext) return null;
|
||||
context = { ...context, ...ammoContext };
|
||||
} else {
|
||||
if (context.promptModifier) {
|
||||
const rollMod = await this.promptRollModifier(`${weapon.name} Attack Roll`);
|
||||
if (rollMod === null) return null;
|
||||
context.rollModifier = rollMod;
|
||||
}
|
||||
}
|
||||
return this.rollCheck(null, 'low', 'combat', null, null, weapon, null, context);
|
||||
}
|
||||
|
||||
async cycleWeaponFireMode(itemId, direction = 1) {
|
||||
@@ -5013,11 +5148,33 @@ export class MothershipActor extends Actor {
|
||||
return ["Ebon", "Brain Waster"].includes(species);
|
||||
}
|
||||
|
||||
isSlaVevaphon() {
|
||||
return String(this.system?.sla?.species?.value ?? "").trim() === "Stormer Vevaphon";
|
||||
}
|
||||
|
||||
async setSlaVevaphonMorphForm(formName) {
|
||||
if (!formName) return;
|
||||
await this.update({ "system.sla.morphForm.value": String(formName) });
|
||||
}
|
||||
|
||||
async adjustSlaVevaphonInstability(delta) {
|
||||
const current = Math.max(0, Math.min(12, Number(this.system?.sla?.vevaphonInstability?.value ?? 0) || 0));
|
||||
const next = Math.max(0, Math.min(12, current + (Number(delta) || 0)));
|
||||
await this.update({ "system.sla.vevaphonInstability.value": next });
|
||||
}
|
||||
|
||||
getSlaRestrictedEbbItemIds() {
|
||||
return this.items
|
||||
.filter((item) => {
|
||||
const name = String(item.name ?? "").trim();
|
||||
if (item.type === "ability") return true;
|
||||
const sla = item.system?.sla ?? {};
|
||||
if (item.type === "ability") {
|
||||
// Only flag abilities that are explicitly Ebb abilities.
|
||||
// Morph forms (Vevaphon) get abilityType "morph" from the generator and are never removed.
|
||||
// morphForm flag is a belt-and-suspenders guard in case abilityType is missing.
|
||||
if (sla.morphForm || String(sla.abilityType ?? "ebb").trim() !== "ebb") return false;
|
||||
return true;
|
||||
}
|
||||
if (item.type !== "skill") return false;
|
||||
return name === "Formulate" || name === "Biofeedback" || name === "Ebb (Core)" || name.startsWith("Ebb ");
|
||||
})
|
||||
@@ -5036,13 +5193,20 @@ export class MothershipActor extends Actor {
|
||||
"system.sla.ebbRating.value": 0
|
||||
};
|
||||
await this.update(updateData);
|
||||
if (restrictedIds.length) {
|
||||
await this.deleteEmbeddedDocuments("Item", restrictedIds);
|
||||
const validIds = restrictedIds.filter(id => this.items.get(id) !== undefined);
|
||||
let removed = 0;
|
||||
if (validIds.length) {
|
||||
try {
|
||||
await this.deleteEmbeddedDocuments("Item", validIds);
|
||||
removed = validIds.length;
|
||||
} catch (err) {
|
||||
// Concurrent enforcement calls may have already deleted these items — safe to ignore
|
||||
}
|
||||
}
|
||||
if (notify && restrictedIds.length) {
|
||||
if (notify && removed) {
|
||||
ui.notifications.warn(`${this.name} is not an Ebb user. Restricted Ebb skills and abilities were removed.`);
|
||||
}
|
||||
return { removed: restrictedIds.length };
|
||||
return { removed };
|
||||
}
|
||||
|
||||
const currentMax = Math.max(0, Number(this.system?.sla?.flux?.max ?? 0) || 0);
|
||||
|
||||
@@ -79,6 +79,9 @@ export class MothershipCreatureSheet extends foundry.appv1.sheets.ActorSheet {
|
||||
if (data.data.system.settings == null) {
|
||||
data.data.system.settings = {};
|
||||
}
|
||||
if (data.data.system.slaNpc == null) {
|
||||
data.data.system.slaNpc = {};
|
||||
}
|
||||
data.data.system.settings.useCalm = game.settings.get("sla-mothership", "useCalm");
|
||||
data.data.system.settings.hideWeight = game.settings.get("sla-mothership", "hideWeight");
|
||||
data.data.system.settings.firstEdition = game.settings.get("sla-mothership", "firstEdition");
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MothershipSkillSheet } from "./item/skill-sheet.js";
|
||||
import { SLADrugSystem } from "./sla-drug-system.js";
|
||||
import { slaDebug } from "./logger.js";
|
||||
import { SLAMothershipGenerator } from "./windows/sla-character-generator.js";
|
||||
import { SLANpcGeneratorApp, generateSlaNpc } from "./windows/sla-npc-generator.js";
|
||||
import { SLAWorldToolsApp, installSlaSidebarButtons } from "./windows/sla-world-tools.js";
|
||||
|
||||
import {
|
||||
@@ -50,6 +51,8 @@ Hooks.once('init', async function () {
|
||||
closeDrug: (actor, itemId, drug = "") => SLADrugSystem.closeDrug({ actor, itemId, drug }),
|
||||
restartDrugReminders: () => SLADrugSystem.startReminderLoop(),
|
||||
openGenerator: (actor = null, options = {}) => SLAMothershipGenerator.open(actor, options),
|
||||
openNpcDesk: (options = {}) => SLANpcGeneratorApp.open(options),
|
||||
createNpc: (state = {}) => generateSlaNpc(state),
|
||||
createRandomOperative: (options = {}) => SLAMothershipGenerator.createRandomOperative(options),
|
||||
createStarterSquad: (options = {}) => SLAMothershipGenerator.createStarterSquad(options),
|
||||
openTools: () => SLAWorldToolsApp.open(),
|
||||
@@ -144,11 +147,20 @@ Hooks.once('init', async function () {
|
||||
await SLADrugSystem.handleEffectDeleted(effect, options);
|
||||
});
|
||||
|
||||
Hooks.on("createItem", async (item) => {
|
||||
// Debounce map: actorId → pending timeout handle.
|
||||
// Prevents enforceSlaEbbEligibility from being called once per item when the
|
||||
// character generator creates many items in rapid succession (each createItem
|
||||
// hook call would otherwise race to delete the same Ebb items concurrently).
|
||||
const _pendingEbbEnforcement = new Map();
|
||||
Hooks.on("createItem", (item) => {
|
||||
const actor = item.parent;
|
||||
if (!actor || actor.type !== "character" || typeof actor.enforceSlaEbbEligibility !== "function") return;
|
||||
if (actor.isSlaEbbUser?.()) return;
|
||||
await actor.enforceSlaEbbEligibility({ notify: true, refillFlux: false });
|
||||
if (_pendingEbbEnforcement.has(actor.id)) clearTimeout(_pendingEbbEnforcement.get(actor.id));
|
||||
_pendingEbbEnforcement.set(actor.id, setTimeout(async () => {
|
||||
_pendingEbbEnforcement.delete(actor.id);
|
||||
await actor.enforceSlaEbbEligibility({ notify: true, refillFlux: false });
|
||||
}, 300));
|
||||
});
|
||||
|
||||
Hooks.on("updateActor", async (actor, changes, options) => {
|
||||
@@ -269,9 +281,61 @@ Hooks.once("ready", async function () {
|
||||
|
||||
patchSlaInitiativeSystem();
|
||||
if (!game.user?.isGM) hideSlaRestrictedSidebarTabs();
|
||||
|
||||
// GM Conditions Summary — whisper to all GMs every 30 minutes
|
||||
if (game.user?.isGM) {
|
||||
setInterval(_sendGMConditionsSummary, 30 * 60 * 1000);
|
||||
console.log("SLA MOTHERSHIP | GM conditions summary timer started (every 30 minutes).");
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* Scans all actors for active condition items and whispers a summary to GM(s).
|
||||
* Fires every 30 minutes when logged in as GM.
|
||||
*/
|
||||
function _sendGMConditionsSummary() {
|
||||
if (!game.user?.isGM) return;
|
||||
|
||||
const rows = [];
|
||||
for (const actor of game.actors) {
|
||||
const conditions = actor.items?.filter(i => i.type === "condition") ?? [];
|
||||
if (conditions.length === 0) continue;
|
||||
const conditionList = conditions.map(c => {
|
||||
const sev = c.system?.severity ?? "";
|
||||
return `<span style="color:#ff4444;font-weight:bold;">${c.name}${sev ? ` (${sev})` : ""}</span>`;
|
||||
}).join(", ");
|
||||
rows.push(`<tr><td style="padding:2px 8px;font-weight:bold;color:#eee;">${actor.name}</td><td style="padding:2px 8px;">${conditionList}</td></tr>`);
|
||||
}
|
||||
|
||||
let content;
|
||||
if (rows.length === 0) {
|
||||
content = `<div style="font-family:monospace;color:#888;padding:4px 8px;">
|
||||
<strong style="color:#ff4444;">⚕ CONDITIONS REPORT</strong> — No active conditions.
|
||||
</div>`;
|
||||
} else {
|
||||
content = `<div style="font-family:monospace;padding:4px 0;">
|
||||
<div style="background:#1a0000;border:1px solid #8b0000;border-radius:4px;padding:4px 8px;margin-bottom:6px;">
|
||||
<strong style="color:#ff4444;font-size:1em;">⚕ CONDITIONS REPORT</strong>
|
||||
<span style="color:#888;font-size:0.8em;margin-left:8px;">${new Date().toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<thead><tr style="border-bottom:1px solid #8b0000;">
|
||||
<th style="padding:2px 8px;color:#aaa;text-align:left;font-size:0.8em;">ACTOR</th>
|
||||
<th style="padding:2px 8px;color:#aaa;text-align:left;font-size:0.8em;">CONDITIONS</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.join("")}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
ChatMessage.create({
|
||||
content,
|
||||
whisper: ChatMessage.getWhisperRecipients("GM"),
|
||||
speaker: { alias: "SLA System" }
|
||||
});
|
||||
}
|
||||
|
||||
Hooks.on("renderChatMessageHTML", (app, html) => {
|
||||
const root = html?.[0] ?? html;
|
||||
if (!root?.querySelectorAll) return;
|
||||
|
||||
@@ -904,6 +904,13 @@ export class SLAMothershipGenerator extends FormApplication {
|
||||
const worldItem = findWorldItem(morph.name, "ability");
|
||||
if (worldItem) {
|
||||
const embedded = toEmbeddedData(worldItem);
|
||||
// abilityType "morph" is schema-defined and guaranteed to be stored —
|
||||
// this prevents the Ebb enforcement sweep from ever deleting morph forms.
|
||||
embedded.system ??= {};
|
||||
embedded.system.sla ??= {};
|
||||
embedded.system.sla.abilityType = "morph";
|
||||
embedded.system.sla.morphForm = true;
|
||||
embedded.system.sla.isStartingForm = morph.name === startingMorphForm;
|
||||
items.push(embedded);
|
||||
} else {
|
||||
// Build a minimal ability document so the character isn't empty-handed.
|
||||
@@ -916,6 +923,7 @@ export class SLAMothershipGenerator extends FormApplication {
|
||||
summary: morph.summary,
|
||||
special: morph.special,
|
||||
sla: {
|
||||
abilityType: "morph",
|
||||
morphForm: true,
|
||||
instabilityCost: morph.instabilityCost,
|
||||
statMods: morph.statMods ?? {},
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
import { SLAMothershipGenerator } from "./sla-character-generator.js";
|
||||
|
||||
const SYSTEM_ID = "sla-mothership";
|
||||
const ROOT_ACTOR_FOLDER = "SLA Mothership";
|
||||
const NPC_FOLDER = "NPCs";
|
||||
|
||||
const NPC_ARCHETYPES = {
|
||||
"Civilian Witness": {
|
||||
summary: "Clerks, residents, dock workers, minor officials, and bystanders with enough identity to question or endanger.",
|
||||
stats: { combat: 18, instinct: 32, speed: 24, loyalty: 46, sanity: 36, armor: 0, damageReduction: 0, health: 8, hits: 1 },
|
||||
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: false },
|
||||
skills: [
|
||||
{ name: "Persuade", bonus: 20 },
|
||||
{ name: "Spot Hidden", bonus: 25 },
|
||||
{ name: "Drive (Civilian)", bonus: 20 },
|
||||
{ name: "Bureaucracy", bonus: 15 }
|
||||
],
|
||||
loadouts: {
|
||||
None: [],
|
||||
Civilian: ["Street Clothes / Leather Jacket", "UV / Multi-Spectrum Torch", "Oyster Card (ITB Card)"]
|
||||
},
|
||||
defaultLoadout: "Civilian",
|
||||
defaultRole: "Witness / Local",
|
||||
defaultAffiliation: "Mort Civilian",
|
||||
defaultDepartment: "Local District"
|
||||
},
|
||||
"Street Ganger": {
|
||||
summary: "Street muscle, lookouts, turf enforcers, and volatile crew members from Mort's lower tiers.",
|
||||
stats: { combat: 34, instinct: 34, speed: 30, loyalty: 24, sanity: 24, armor: 0, damageReduction: 0, health: 10, hits: 2 },
|
||||
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: false, armor: false },
|
||||
skills: [
|
||||
{ name: "Brawl", bonus: 25 },
|
||||
{ name: "Streetwise", bonus: 25 },
|
||||
{ name: "Intimidate", bonus: 20 },
|
||||
{ name: "Dodge", bonus: 20 },
|
||||
{ name: "Firearm (Pistol)", bonus: 20 }
|
||||
],
|
||||
loadouts: {
|
||||
None: [],
|
||||
"Street Melee": ["Collapsible Baton", "Standard Combat Knife", "Street Clothes / Leather Jacket"],
|
||||
Sidearm: ["FEN 603 Auto-Pistol", "Standard Combat Knife", "Street Clothes / Leather Jacket"]
|
||||
},
|
||||
defaultLoadout: "Sidearm",
|
||||
defaultRole: "Street Gang Enforcer",
|
||||
defaultAffiliation: "Local Street Gang",
|
||||
defaultDepartment: "Turf Crew"
|
||||
},
|
||||
"Shiver Patrol": {
|
||||
summary: "Street cops, precinct enforcers, and cordon teams good for patrol scenes, arrests, and fast escalation.",
|
||||
stats: { combat: 44, instinct: 38, speed: 34, loyalty: 58, sanity: 36, armor: 3, damageReduction: 1, health: 12, hits: 2 },
|
||||
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: true },
|
||||
skills: [
|
||||
{ name: "Firearm (Pistol)", bonus: 30 },
|
||||
{ name: "Firearm (SMG)", bonus: 25 },
|
||||
{ name: "Athletics", bonus: 20 },
|
||||
{ name: "Command", bonus: 20 },
|
||||
{ name: "Spot Hidden", bonus: 25 }
|
||||
],
|
||||
loadouts: {
|
||||
None: [],
|
||||
Patrol: ["FEN 401 Shiver Pistol", "Collapsible Baton", "Shiver Flak Vest"],
|
||||
"SMG Team": ["FEN 209 Machine Pistol", "Collapsible Baton", "Full Shiver Armour"]
|
||||
},
|
||||
defaultLoadout: "Patrol",
|
||||
defaultRole: "Shiver Patrol Officer",
|
||||
defaultAffiliation: "SLA Industries",
|
||||
defaultDepartment: "Shiver Precinct"
|
||||
},
|
||||
"Corp Operative": {
|
||||
summary: "Standard corporate assets, contract staff, or in-house operatives for Mort departments and sponsor interests.",
|
||||
stats: { combat: 40, instinct: 40, speed: 32, loyalty: 50, sanity: 40, armor: 2, damageReduction: 0, health: 11, hits: 2 },
|
||||
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: true },
|
||||
skills: [
|
||||
{ name: "Firearm (Pistol)", bonus: 25 },
|
||||
{ name: "Command", bonus: 20 },
|
||||
{ name: "Spot Hidden", bonus: 20 },
|
||||
{ name: "Bureaucracy", bonus: 20 },
|
||||
{ name: "Persuade", bonus: 15 }
|
||||
],
|
||||
loadouts: {
|
||||
None: [],
|
||||
Sidearm: ["FEN 603 Auto-Pistol", "Street Clothes / Leather Jacket"],
|
||||
"Field Agent": ["FEN 209 Machine Pistol", "Shiver Flak Vest", "Klippo Multi-Band Communicator"]
|
||||
},
|
||||
defaultLoadout: "Field Agent",
|
||||
defaultAffiliationType: "Corporation",
|
||||
defaultRole: "Corporate Operative",
|
||||
defaultAffiliation: "SLA Industries",
|
||||
defaultDepartment: "Operations",
|
||||
defaultSector: "Mort City"
|
||||
},
|
||||
Media: {
|
||||
summary: "Reporters, propaganda staff, image handlers, and camera teams who shape the public story while staying very close to the danger.",
|
||||
stats: { combat: 20, instinct: 42, speed: 28, loyalty: 44, sanity: 42, armor: 1, damageReduction: 0, health: 9, hits: 1 },
|
||||
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: true },
|
||||
skills: [
|
||||
{ name: "Persuade", bonus: 30 },
|
||||
{ name: "Fast Talk", bonus: 25 },
|
||||
{ name: "SLA Info", bonus: 25 },
|
||||
{ name: "Spot Hidden", bonus: 20 },
|
||||
{ name: "Intimidate", bonus: 15 }
|
||||
],
|
||||
loadouts: {
|
||||
None: [],
|
||||
Press: ["Klippo Multi-Band Communicator", "UV / Multi-Spectrum Torch", "Street Clothes / Leather Jacket"],
|
||||
"Embedded Crew": ["FEN 603 Auto-Pistol", "Klippo Multi-Band Communicator", "Street Clothes / Leather Jacket"]
|
||||
},
|
||||
defaultLoadout: "Press",
|
||||
defaultAffiliationType: "Corporation",
|
||||
defaultRole: "Media Correspondent",
|
||||
defaultAffiliation: "SLA Media",
|
||||
defaultDepartment: "Editorial / Field Unit",
|
||||
defaultSector: "Downtown Mort"
|
||||
},
|
||||
"Internal Affairs": {
|
||||
summary: "Cold, suspicious investigators and auditors used for leak hunts, corruption probes, and quiet pressure on operatives or departments.",
|
||||
stats: { combat: 34, instinct: 48, speed: 28, loyalty: 56, sanity: 44, armor: 2, damageReduction: 0, health: 10, hits: 2 },
|
||||
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: true },
|
||||
skills: [
|
||||
{ name: "Bureaucracy", bonus: 30 },
|
||||
{ name: "Psychology", bonus: 25 },
|
||||
{ name: "Intimidate", bonus: 25 },
|
||||
{ name: "Command", bonus: 20 },
|
||||
{ name: "Firearm (Pistol)", bonus: 20 }
|
||||
],
|
||||
loadouts: {
|
||||
None: [],
|
||||
Audit: ["FEN 603 Auto-Pistol", "Klippo Multi-Band Communicator", "Street Clothes / Leather Jacket"],
|
||||
Pressure: ["FEN 401 Shiver Pistol", "Shiver Flak Vest", "Klippo Multi-Band Communicator"]
|
||||
},
|
||||
defaultLoadout: "Audit",
|
||||
defaultAffiliationType: "Department",
|
||||
defaultRole: "Internal Affairs Investigator",
|
||||
defaultAffiliation: "SLA Industries",
|
||||
defaultDepartment: "Internal Affairs",
|
||||
defaultSector: "Cannibal Sectors"
|
||||
},
|
||||
"DarkNight Agent": {
|
||||
summary: "Hard, covert, and unpleasant professionals for pressure scenes, black-bag work, and very bad conversations.",
|
||||
stats: { combat: 52, instinct: 48, speed: 42, loyalty: 42, sanity: 44, armor: 4, damageReduction: 2, health: 13, hits: 2 },
|
||||
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: true },
|
||||
skills: [
|
||||
{ name: "Stealth", bonus: 30 },
|
||||
{ name: "Dodge", bonus: 25 },
|
||||
{ name: "Spot Hidden", bonus: 30 },
|
||||
{ name: "Firearm (SMG)", bonus: 30 },
|
||||
{ name: "Intimidate", bonus: 25 }
|
||||
],
|
||||
loadouts: {
|
||||
None: [],
|
||||
"Strike Agent": ["FEN 209 Machine Pistol", "Standard Combat Knife", "Stealth Suit"],
|
||||
Breach: ["FEN AR Assault Rifle", "Fragmentation Grenade", "Crackshot Armour"]
|
||||
},
|
||||
defaultLoadout: "Strike Agent",
|
||||
defaultAffiliationType: "Squad",
|
||||
defaultRole: "DarkNight Agent",
|
||||
defaultAffiliation: "DarkNight",
|
||||
defaultDepartment: "Black Unit",
|
||||
defaultSector: "Cannibal Sectors"
|
||||
},
|
||||
"Tek Specialist": {
|
||||
summary: "Engineers, locks-and-systems staff, data handlers, and machine-facing support assets.",
|
||||
stats: { combat: 26, instinct: 42, speed: 28, loyalty: 46, sanity: 40, armor: 1, damageReduction: 0, health: 9, hits: 1 },
|
||||
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: true },
|
||||
skills: [
|
||||
{ name: "Computer Systems", bonus: 30 },
|
||||
{ name: "Tech (Computers & AI)", bonus: 25 },
|
||||
{ name: "Tech (Electronics)", bonus: 25 },
|
||||
{ name: "Spot Hidden", bonus: 20 },
|
||||
{ name: "Firearm (Pistol)", bonus: 15 }
|
||||
],
|
||||
loadouts: {
|
||||
None: [],
|
||||
Utility: ["FEN 603 Auto-Pistol", "Klippo Multi-Band Communicator", "UV / Multi-Spectrum Torch"],
|
||||
"Field Tek": ["FEN 603 Auto-Pistol", "BOOPA Medical Kit (Standard)", "Klippo Multi-Band Communicator"]
|
||||
},
|
||||
defaultLoadout: "Utility",
|
||||
defaultAffiliationType: "Department",
|
||||
defaultRole: "Tek Specialist",
|
||||
defaultAffiliation: "Tek Division",
|
||||
defaultDepartment: "Systems",
|
||||
defaultSector: "Industrial Edge"
|
||||
},
|
||||
"Ebb Operative": {
|
||||
summary: "Psychic intervention assets, occult handlers, and dangerous minds kept near the edge of tolerable doctrine.",
|
||||
stats: { combat: 30, instinct: 46, speed: 24, loyalty: 30, sanity: 46, armor: 0, damageReduction: 0, health: 9, hits: 2 },
|
||||
enabled: { combat: true, instinct: true, speed: true, loyalty: false, sanity: true, armor: false },
|
||||
skills: [
|
||||
{ name: "Biofeedback", bonus: 25 },
|
||||
{ name: "Formulate", bonus: 25 },
|
||||
{ name: "Ebb (Core)", bonus: 25 },
|
||||
{ name: "Spot Hidden", bonus: 20 },
|
||||
{ name: "Firearm (Pistol)", bonus: 15 }
|
||||
],
|
||||
loadouts: {
|
||||
None: [],
|
||||
Focus: ["FEN 603 Auto-Pistol", "BOOPA Medical Kit (Standard)", "Klippo Multi-Band Communicator"]
|
||||
},
|
||||
defaultLoadout: "Focus",
|
||||
defaultAffiliationType: "Department",
|
||||
defaultRole: "Ebb Handler",
|
||||
defaultAffiliation: "SLA Industries",
|
||||
defaultDepartment: "Ebb / Black",
|
||||
defaultSector: "Mort City"
|
||||
},
|
||||
"Predator / Beast": {
|
||||
summary: "Mutants, monsters, feral biogen threats, or engineered horrors that need only enough structure to fight and stalk.",
|
||||
stats: { combat: 46, instinct: 56, speed: 40, loyalty: 12, sanity: 10, armor: 2, damageReduction: 1, health: 14, hits: 3 },
|
||||
enabled: { combat: true, instinct: true, speed: true, loyalty: false, sanity: false, armor: true },
|
||||
skills: [
|
||||
{ name: "Brawl", bonus: 30 },
|
||||
{ name: "Athletics", bonus: 25 },
|
||||
{ name: "Dodge", bonus: 20 },
|
||||
{ name: "Spot Hidden", bonus: 25 }
|
||||
],
|
||||
loadouts: {
|
||||
None: []
|
||||
},
|
||||
defaultLoadout: "None",
|
||||
defaultAffiliationType: "Independent",
|
||||
defaultRole: "Hunting Threat",
|
||||
defaultAffiliation: "Hostile Specimen",
|
||||
defaultDepartment: "Wild / Uncontrolled",
|
||||
defaultSector: "Waste District"
|
||||
}
|
||||
};
|
||||
|
||||
const QUALITY_MODS = {
|
||||
Rabble: { stat: -8, skill: -10, health: -2, hits: 0 },
|
||||
Standard: { stat: 0, skill: 0, health: 0, hits: 0 },
|
||||
Veteran: { stat: 6, skill: 5, health: 1, hits: 0 },
|
||||
Elite: { stat: 10, skill: 10, health: 2, hits: 1 },
|
||||
Boss: { stat: 14, skill: 15, health: 4, hits: 1 }
|
||||
};
|
||||
|
||||
const DISPOSITIONS = ["Neutral", "Professional", "Hostile", "Nervous", "Fanatical", "Cornered", "Corrupt", "Helpful"];
|
||||
const THREAT_TIERS = ["Low", "Moderate", "High", "Extreme"];
|
||||
const AFFILIATION_TYPES = ["Department", "Gang", "Corporation", "Cult", "Squad", "Civic", "Independent"];
|
||||
|
||||
const ROLE_SNIPPETS = {
|
||||
"Civilian Witness": ["Transit clerk", "Dock handler", "Apartment resident", "Witness under pressure", "Small trader"],
|
||||
"Street Ganger": ["Lookout", "Enforcer", "Runner", "Debt collector", "Back-alley bruiser"],
|
||||
"Shiver Patrol": ["Patrol officer", "Checkpoint lead", "Riot response cop", "Beat sergeant", "Scene controller"],
|
||||
"Corp Operative": ["Case handler", "Recovery agent", "Sponsor fixer", "Quiet escort", "Internal security operative"],
|
||||
Media: ["Field correspondent", "Embedded camera lead", "Image handler", "Narrative producer", "Live scene reporter"],
|
||||
"Internal Affairs": ["Audit investigator", "Leak hunter", "Corruption examiner", "Interrogation lead", "Compliance agent"],
|
||||
"DarkNight Agent": ["Pressure asset", "Black-bag specialist", "Interdiction hunter", "Silent breach lead", "Cleanup professional"],
|
||||
"Tek Specialist": ["Systems breach tech", "Maintenance lead", "Field engineer", "Data recovery tech", "Signals operator"],
|
||||
"Ebb Operative": ["Control asset", "Psi observer", "Occult investigator", "Flux-sensitive handler", "Discreet interventionist"],
|
||||
"Predator / Beast": ["Tunnel stalker", "Waste-zone hunter", "Escaped specimen", "Territory alpha", "Feral ambusher"]
|
||||
};
|
||||
|
||||
const AFFILIATION_SNIPPETS = {
|
||||
Department: ["Mort Department", "Ops Con", "Shiver Precinct", "Tek Division", "Cannibal Sector Authority"],
|
||||
Gang: ["Chrome Jacks", "Sump Dogs", "Red Wire Crew", "Murder Mile Kings", "Ash-Sector Runners"],
|
||||
Corporation: ["SLA Industries", "FEN", "Wraithen Security", "MediCore", "Tek Division"],
|
||||
Cult: ["The True Noise", "Church of White Fire", "The Bent Halo", "The Last Broadcast", "Ash Choir"],
|
||||
Squad: ["Blue BPN Hold Team", "Black Transit Unit", "Sweep Detail", "Strike Cell Seven", "Special Recovery Team"],
|
||||
Civic: ["Transit Maintenance", "Block Committee", "Waste Processing", "Public Utility", "Relocation Office"],
|
||||
Independent: ["Freelance crew", "Mercenary pair", "Broker network", "Private fixer", "Unaffiliated contact"]
|
||||
};
|
||||
|
||||
const DEPARTMENT_SNIPPETS = {
|
||||
"Civilian Witness": ["Hab Block", "Transit", "Utility", "Street Market", "Dock Quarter"],
|
||||
"Street Ganger": ["East Cut", "Underline", "Recycling Stack", "Canal Row", "Tunnel Nine"],
|
||||
"Shiver Patrol": ["Precinct 88", "Riot Control", "Street Division", "Transit Policing", "Evidence Escort"],
|
||||
"Corp Operative": ["Internal Security", "Investigations", "Sponsor Liaison", "Logistics Recovery", "Operations"],
|
||||
Media: ["Editorial Desk", "Live Scene Unit", "Mort Broadcast", "Image Control", "Public Feed Team"],
|
||||
"Internal Affairs": ["Compliance Review", "Leak Office", "Audit Section", "Disciplinary Unit", "Special Oversight"],
|
||||
"DarkNight Agent": ["Silent Entry", "Black Archive", "Pressure Cell", "Removal Team", "Interdiction"],
|
||||
"Tek Specialist": ["Signals", "Maintenance", "Surveillance", "Field Systems", "Machine Control"],
|
||||
"Ebb Operative": ["Occult Oversight", "Black Psi", "Flux Watch", "Intervention Cell", "Containment"],
|
||||
"Predator / Beast": ["Den", "Nest", "Territory", "Spore Cluster", "Hunting Ground"]
|
||||
};
|
||||
|
||||
export class SLANpcGeneratorApp extends FormApplication {
|
||||
constructor(options = {}) {
|
||||
super(options);
|
||||
this.formState = null;
|
||||
}
|
||||
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: "sla-npc-generator",
|
||||
classes: ["mosh", "sheet", "sla-generator", "sla-npc-generator"],
|
||||
template: "systems/sla-mothership/templates/dialogs/sla-npc-generator.html",
|
||||
width: 780,
|
||||
height: "auto",
|
||||
submitOnChange: false,
|
||||
closeOnSubmit: false
|
||||
});
|
||||
}
|
||||
|
||||
get title() {
|
||||
return "SLA NPC Desk";
|
||||
}
|
||||
|
||||
async getData() {
|
||||
await SLAMothershipGenerator.ensureSeedData();
|
||||
const seed = await SLAMothershipGenerator.loadSeedData();
|
||||
const speciesOptions = seed.species.species.map((entry) => entry.name);
|
||||
const state = this.formState ?? this.defaultState(speciesOptions);
|
||||
this.formState = state;
|
||||
|
||||
const archetype = NPC_ARCHETYPES[state.archetype] ?? NPC_ARCHETYPES["Street Ganger"];
|
||||
const loadoutOptions = Object.keys(archetype.loadouts ?? { None: [] });
|
||||
|
||||
return {
|
||||
state,
|
||||
accentKey: getArchetypeAccentKey(state.archetype),
|
||||
archetypes: Object.entries(NPC_ARCHETYPES).map(([name, entry]) => ({ name, summary: entry.summary })),
|
||||
speciesOptions,
|
||||
qualityOptions: Object.keys(QUALITY_MODS),
|
||||
dispositionOptions: DISPOSITIONS,
|
||||
threatOptions: THREAT_TIERS,
|
||||
affiliationTypes: AFFILIATION_TYPES,
|
||||
loadoutOptions,
|
||||
summary: archetype.summary,
|
||||
preview: buildNpcPreview(state, archetype)
|
||||
};
|
||||
}
|
||||
|
||||
defaultState(speciesOptions = []) {
|
||||
const archetype = "Street Ganger";
|
||||
const config = NPC_ARCHETYPES[archetype];
|
||||
return {
|
||||
name: SLAMothershipGenerator.randomName(),
|
||||
archetype,
|
||||
quality: "Standard",
|
||||
species: speciesOptions.includes("Human") ? "Human" : (speciesOptions[0] ?? "Human"),
|
||||
affiliationType: config.defaultAffiliationType ?? "Gang",
|
||||
affiliation: config.defaultAffiliation,
|
||||
department: config.defaultDepartment,
|
||||
role: config.defaultRole,
|
||||
sector: config.defaultSector ?? "Mort City",
|
||||
disposition: "Hostile",
|
||||
threat: "Moderate",
|
||||
loadout: config.defaultLoadout ?? "None",
|
||||
assignPortrait: true,
|
||||
openSheet: true,
|
||||
notes: ""
|
||||
};
|
||||
}
|
||||
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
html.find(".sla-npc-random-name").on("click", () => {
|
||||
html.find("[name='name']").val(SLAMothershipGenerator.randomName());
|
||||
});
|
||||
|
||||
html.find(".sla-npc-randomize").on("click", async () => {
|
||||
const state = this.collectState(html);
|
||||
const randomized = this.randomizeState(state);
|
||||
this.formState = randomized;
|
||||
await this.render(false);
|
||||
});
|
||||
|
||||
html.find("[name='archetype']").on("change", async () => {
|
||||
const current = this.collectState(html);
|
||||
this.formState = this.rethemeStateForArchetype(current, current.archetype);
|
||||
await this.render(false);
|
||||
});
|
||||
|
||||
const rerenderSelectors = [
|
||||
"[name='quality']",
|
||||
"[name='species']",
|
||||
"[name='disposition']",
|
||||
"[name='threat']",
|
||||
"[name='loadout']",
|
||||
"[name='affiliationType']"
|
||||
];
|
||||
|
||||
for (const selector of rerenderSelectors) {
|
||||
html.find(selector).on("change", async () => {
|
||||
this.formState = this.collectState(html);
|
||||
await this.render(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
collectState(html) {
|
||||
return {
|
||||
name: String(html.find("[name='name']").val() ?? "").trim(),
|
||||
archetype: String(html.find("[name='archetype']").val() ?? "Street Ganger"),
|
||||
quality: String(html.find("[name='quality']").val() ?? "Standard"),
|
||||
species: String(html.find("[name='species']").val() ?? "Human"),
|
||||
affiliationType: String(html.find("[name='affiliationType']").val() ?? "Department"),
|
||||
affiliation: String(html.find("[name='affiliation']").val() ?? "").trim(),
|
||||
department: String(html.find("[name='department']").val() ?? "").trim(),
|
||||
role: String(html.find("[name='role']").val() ?? "").trim(),
|
||||
sector: String(html.find("[name='sector']").val() ?? "").trim(),
|
||||
disposition: String(html.find("[name='disposition']").val() ?? "Neutral"),
|
||||
threat: String(html.find("[name='threat']").val() ?? "Moderate"),
|
||||
loadout: String(html.find("[name='loadout']").val() ?? "None"),
|
||||
assignPortrait: Boolean(html.find("[name='assignPortrait']").prop("checked")),
|
||||
openSheet: Boolean(html.find("[name='openSheet']").prop("checked")),
|
||||
notes: String(html.find("[name='notes']").val() ?? "").trim()
|
||||
};
|
||||
}
|
||||
|
||||
randomizeState(current) {
|
||||
const state = { ...current };
|
||||
const archetypeName = state.archetype || randomFrom(Object.keys(NPC_ARCHETYPES)) || "Street Ganger";
|
||||
const archetype = NPC_ARCHETYPES[archetypeName] ?? NPC_ARCHETYPES["Street Ganger"];
|
||||
const themed = this.rethemeStateForArchetype(state, archetypeName);
|
||||
themed.affiliation = randomFrom(AFFILIATION_SNIPPETS[themed.affiliationType] ?? [archetype.defaultAffiliation]) ?? archetype.defaultAffiliation;
|
||||
themed.department = randomFrom(DEPARTMENT_SNIPPETS[archetypeName] ?? [archetype.defaultDepartment]) ?? archetype.defaultDepartment;
|
||||
themed.role = randomFrom(ROLE_SNIPPETS[archetypeName] ?? [archetype.defaultRole]) ?? archetype.defaultRole;
|
||||
themed.sector = randomFrom(getSectorOptionsForArchetype(archetypeName, archetype.defaultSector)) ?? (archetype.defaultSector ?? "Mort City");
|
||||
themed.disposition = randomFrom(DISPOSITIONS) ?? "Neutral";
|
||||
themed.threat = randomFrom(THREAT_TIERS) ?? "Moderate";
|
||||
themed.loadout = randomFrom(Object.keys(archetype.loadouts ?? { None: [] })) ?? "None";
|
||||
themed.name = SLAMothershipGenerator.randomName();
|
||||
return themed;
|
||||
}
|
||||
|
||||
rethemeStateForArchetype(current, archetypeName) {
|
||||
const archetype = NPC_ARCHETYPES[archetypeName] ?? NPC_ARCHETYPES["Street Ganger"];
|
||||
const next = { ...current, archetype: archetypeName };
|
||||
next.affiliationType = archetype.defaultAffiliationType ?? current.affiliationType ?? "Department";
|
||||
next.affiliation = archetype.defaultAffiliation ?? "";
|
||||
next.department = archetype.defaultDepartment ?? "";
|
||||
next.role = archetype.defaultRole ?? "";
|
||||
next.sector = archetype.defaultSector ?? "Mort City";
|
||||
next.loadout = archetype.defaultLoadout ?? "None";
|
||||
next.disposition = getDefaultDispositionForArchetype(archetypeName);
|
||||
next.threat = getDefaultThreatForArchetype(archetypeName);
|
||||
next.species = getDefaultSpeciesForArchetype(archetypeName, current.species);
|
||||
return next;
|
||||
}
|
||||
|
||||
async _updateObject(_event, formData) {
|
||||
const expanded = foundry.utils.expandObject(formData);
|
||||
const state = {
|
||||
name: String(expanded.name ?? "").trim(),
|
||||
archetype: String(expanded.archetype ?? "Street Ganger"),
|
||||
quality: String(expanded.quality ?? "Standard"),
|
||||
species: String(expanded.species ?? "Human"),
|
||||
affiliationType: String(expanded.affiliationType ?? "Department"),
|
||||
affiliation: String(expanded.affiliation ?? "").trim(),
|
||||
department: String(expanded.department ?? "").trim(),
|
||||
role: String(expanded.role ?? "").trim(),
|
||||
sector: String(expanded.sector ?? "").trim(),
|
||||
disposition: String(expanded.disposition ?? "Neutral"),
|
||||
threat: String(expanded.threat ?? "Moderate"),
|
||||
loadout: String(expanded.loadout ?? "None"),
|
||||
assignPortrait: Boolean(expanded.assignPortrait),
|
||||
openSheet: Boolean(expanded.openSheet),
|
||||
notes: String(expanded.notes ?? "").trim()
|
||||
};
|
||||
|
||||
this.formState = state;
|
||||
const result = await generateSlaNpc(state);
|
||||
if (result?.actor && state.openSheet) {
|
||||
result.actor.sheet?.render(true);
|
||||
}
|
||||
ui.notifications.info(`SLA NPC ready: ${result.actor.name}`);
|
||||
this.render(false);
|
||||
}
|
||||
|
||||
static open(options = {}) {
|
||||
return new SLANpcGeneratorApp(options).render({ force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateSlaNpc(state = {}) {
|
||||
await SLAMothershipGenerator.ensureSeedData();
|
||||
const seed = await SLAMothershipGenerator.loadSeedData();
|
||||
|
||||
const archetype = NPC_ARCHETYPES[state.archetype] ?? NPC_ARCHETYPES["Street Ganger"];
|
||||
const quality = QUALITY_MODS[state.quality] ?? QUALITY_MODS.Standard;
|
||||
const speciesName = String(state.species ?? "Human").trim() || "Human";
|
||||
const resolvedName = String(state.name ?? "").trim() || SLAMothershipGenerator.randomName();
|
||||
const folder = await ensureNpcFolder();
|
||||
|
||||
const actor = await Actor.create({
|
||||
name: resolvedName,
|
||||
type: "creature",
|
||||
folder: folder.id
|
||||
});
|
||||
|
||||
const stats = applyQualityToStats(archetype.stats, quality);
|
||||
const enabled = archetype.enabled ?? {};
|
||||
const description = buildNpcDescription({ state, archetype, speciesName });
|
||||
const biography = buildNpcBiography({ state, archetype, speciesName });
|
||||
|
||||
await actor.update({
|
||||
img: SLAMothershipGenerator.resolveSpeciesImage(speciesName),
|
||||
"system.description": description,
|
||||
"system.biography": biography,
|
||||
"system.notes": String(state.notes ?? "").trim(),
|
||||
"system.health.value": stats.health,
|
||||
"system.health.max": stats.health,
|
||||
"system.hits.value": 0,
|
||||
"system.hits.max": stats.hits,
|
||||
"system.stats.combat.value": stats.combat,
|
||||
"system.stats.instinct.value": stats.instinct,
|
||||
"system.stats.speed.value": stats.speed,
|
||||
"system.stats.loyalty.value": stats.loyalty,
|
||||
"system.stats.sanity.value": stats.sanity,
|
||||
"system.stats.armor.mod": stats.armor,
|
||||
"system.stats.armor.damageReduction": stats.damageReduction,
|
||||
"system.stats.combat.enabled": Boolean(enabled.combat),
|
||||
"system.stats.instinct.enabled": Boolean(enabled.instinct),
|
||||
"system.stats.speed.enabled": Boolean(enabled.speed),
|
||||
"system.stats.loyalty.enabled": Boolean(enabled.loyalty),
|
||||
"system.stats.sanity.enabled": Boolean(enabled.sanity),
|
||||
"system.stats.armor.enabled": Boolean(enabled.armor),
|
||||
"system.slaNpc.archetype.value": state.archetype,
|
||||
"system.slaNpc.species.value": speciesName,
|
||||
"system.slaNpc.affiliationType.value": state.affiliationType,
|
||||
"system.slaNpc.affiliation.value": String(state.affiliation ?? "").trim(),
|
||||
"system.slaNpc.department.value": String(state.department ?? "").trim(),
|
||||
"system.slaNpc.role.value": String(state.role ?? "").trim(),
|
||||
"system.slaNpc.sector.value": String(state.sector ?? "").trim(),
|
||||
"system.slaNpc.disposition.value": String(state.disposition ?? "").trim(),
|
||||
"system.slaNpc.threat.value": String(state.threat ?? "").trim(),
|
||||
"system.slaNpc.summary.value": archetype.summary
|
||||
});
|
||||
|
||||
const itemPayloads = [];
|
||||
itemPayloads.push(...buildNpcSkillItems(archetype.skills ?? [], quality, seed.skills.skills));
|
||||
itemPayloads.push(...buildLoadoutItems(archetype, state.loadout));
|
||||
|
||||
if (state.archetype === "Ebb Operative" && SLAMothershipGenerator.isEbbSpecies(speciesName)) {
|
||||
itemPayloads.push(...SLAMothershipGenerator.buildEbbSkillItems(speciesName));
|
||||
itemPayloads.push(...SLAMothershipGenerator.buildEbbItems(speciesName));
|
||||
}
|
||||
|
||||
if (state.archetype === "Predator / Beast") {
|
||||
itemPayloads.push(...SLAMothershipGenerator.buildSpeciesItems(speciesName));
|
||||
}
|
||||
|
||||
const uniquePayloads = uniqueEmbeddedItems(itemPayloads);
|
||||
if (uniquePayloads.length) {
|
||||
await actor.createEmbeddedDocuments("Item", uniquePayloads);
|
||||
}
|
||||
|
||||
if (state.assignPortrait && game.npcPortraitPack?.assignPortraitToActor) {
|
||||
await game.npcPortraitPack.assignPortraitToActor(actor);
|
||||
}
|
||||
|
||||
return { actor };
|
||||
}
|
||||
|
||||
async function ensureNpcFolder() {
|
||||
let root = game.folders.find((folder) => folder.type === "Actor" && folder.name === ROOT_ACTOR_FOLDER && !folder.folder);
|
||||
if (!root) {
|
||||
root = await Folder.create({ name: ROOT_ACTOR_FOLDER, type: "Actor", color: "#163447" });
|
||||
}
|
||||
|
||||
let folder = game.folders.find((entry) => entry.type === "Actor" && entry.name === NPC_FOLDER && entry.folder?.id === root.id);
|
||||
if (!folder) {
|
||||
folder = await Folder.create({ name: NPC_FOLDER, type: "Actor", folder: root.id, color: "#5b3349" });
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
function applyQualityToStats(baseStats, quality) {
|
||||
return {
|
||||
combat: clamp98((baseStats.combat ?? 10) + (quality.stat ?? 0)),
|
||||
instinct: clamp98((baseStats.instinct ?? 10) + (quality.stat ?? 0)),
|
||||
speed: clamp98((baseStats.speed ?? 10) + (quality.stat ?? 0)),
|
||||
loyalty: clamp98((baseStats.loyalty ?? 10) + (quality.stat ?? 0)),
|
||||
sanity: clamp98((baseStats.sanity ?? 10) + (quality.stat ?? 0)),
|
||||
armor: Math.max(0, Number(baseStats.armor ?? 0)),
|
||||
damageReduction: Math.max(0, Number(baseStats.damageReduction ?? 0)),
|
||||
health: Math.max(1, Number(baseStats.health ?? 8) + Number(quality.health ?? 0)),
|
||||
hits: Math.max(1, Number(baseStats.hits ?? 1) + Number(quality.hits ?? 0))
|
||||
};
|
||||
}
|
||||
|
||||
function buildNpcSkillItems(skillDefs, quality, allSkillData = []) {
|
||||
return (skillDefs ?? [])
|
||||
.map((entry) => {
|
||||
const baseBonus = Number(entry.bonus ?? 10) + Number(quality.skill ?? 0);
|
||||
const worldItem = findWorldItem(entry.name, "skill");
|
||||
if (worldItem) {
|
||||
const embedded = toEmbeddedData(worldItem);
|
||||
embedded.system.bonus = clamp98(baseBonus);
|
||||
embedded.system.rank = rankFromBonus(embedded.system.bonus);
|
||||
embedded.system.sla ??= {};
|
||||
embedded.system.sla.baseBonus = Number(worldItem.system?.bonus ?? 0) || 0;
|
||||
embedded.system.sla.packageBonus = Math.max(0, embedded.system.bonus - Number(embedded.system.sla.baseBonus ?? 0));
|
||||
return embedded;
|
||||
}
|
||||
|
||||
const data = allSkillData.find((skill) => normalizeText(skill.name) === normalizeText(entry.name)) ?? {};
|
||||
return {
|
||||
name: entry.name,
|
||||
type: "skill",
|
||||
img: "icons/svg/upgrade.svg",
|
||||
system: {
|
||||
description: "",
|
||||
rank: rankFromBonus(baseBonus),
|
||||
bonus: clamp98(baseBonus),
|
||||
prerequisite_ids: [],
|
||||
sla: {
|
||||
category: data.categoryRef ?? "",
|
||||
skillFamily: data.categoryRef ?? "",
|
||||
source: "SLA NPC Desk"
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildLoadoutItems(archetype, loadoutName) {
|
||||
const loadout = archetype.loadouts?.[loadoutName] ?? [];
|
||||
return loadout
|
||||
.map((name) => findWorldItem(name))
|
||||
.filter(Boolean)
|
||||
.map((item) => toEmbeddedData(item));
|
||||
}
|
||||
|
||||
function buildNpcDescription({ state, archetype, speciesName }) {
|
||||
return [
|
||||
`${state.role || archetype.defaultRole}.`,
|
||||
`${state.affiliation || archetype.defaultAffiliation}${state.department ? ` / ${state.department}` : ""}.`,
|
||||
`${state.disposition || "Neutral"} presence with ${state.threat || "Moderate"} threat pressure.`,
|
||||
`${speciesName} profile.`
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function buildNpcBiography({ state, archetype, speciesName }) {
|
||||
return `
|
||||
<p><strong>Role:</strong> ${escapeHtml(state.role || archetype.defaultRole)}</p>
|
||||
<p><strong>Affiliation:</strong> ${escapeHtml(state.affiliation || archetype.defaultAffiliation)}</p>
|
||||
<p><strong>Department / Cell:</strong> ${escapeHtml(state.department || archetype.defaultDepartment)}</p>
|
||||
<p><strong>Species:</strong> ${escapeHtml(speciesName)}</p>
|
||||
<p><strong>Disposition:</strong> ${escapeHtml(state.disposition || "Neutral")} | <strong>Threat:</strong> ${escapeHtml(state.threat || "Moderate")}</p>
|
||||
<p><strong>Sector:</strong> ${escapeHtml(state.sector || "Mort City")}</p>
|
||||
<p>${escapeHtml(archetype.summary)}</p>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function buildNpcPreview(state, archetype) {
|
||||
const quality = QUALITY_MODS[state.quality] ?? QUALITY_MODS.Standard;
|
||||
const loadoutNames = archetype.loadouts?.[state.loadout] ?? [];
|
||||
const statline = applyQualityToStats(archetype.stats, quality);
|
||||
return {
|
||||
iconPath: getArchetypeIconPath(state.archetype),
|
||||
iconLabel: getArchetypeBadgeShort(state.archetype),
|
||||
badge: getArchetypeBadge(state.archetype),
|
||||
strap: getArchetypeStrapline(state.archetype),
|
||||
identity: `${state.role || archetype.defaultRole} / ${state.species}`,
|
||||
brief: `${state.affiliation || archetype.defaultAffiliation}${state.department ? ` / ${state.department}` : ""} operating in ${state.sector || archetype.defaultSector || "Mort City"}.`,
|
||||
posture: `${state.disposition || "Neutral"} disposition with ${state.threat || "Moderate"} threat expectations.`,
|
||||
statline: [
|
||||
`Combat ${statline.combat}`,
|
||||
`Instinct ${statline.instinct}`,
|
||||
`Speed ${statline.speed}`,
|
||||
`Health ${statline.health}`,
|
||||
`Wounds ${statline.hits}`
|
||||
],
|
||||
loadout: loadoutNames.length ? loadoutNames : ["No preset issue kit selected."],
|
||||
notes: archetype.summary
|
||||
};
|
||||
}
|
||||
|
||||
function clamp98(value) {
|
||||
return Math.max(1, Math.min(98, Number(value ?? 0) || 0));
|
||||
}
|
||||
|
||||
function rankFromBonus(bonus) {
|
||||
if (bonus >= 40) return "Master";
|
||||
if (bonus >= 25) return "Expert";
|
||||
return "Trained";
|
||||
}
|
||||
|
||||
function findWorldItem(name, type = null) {
|
||||
return game.items.find((item) => normalizeText(item.name) === normalizeText(name) && (!type || item.type === type)) ?? null;
|
||||
}
|
||||
|
||||
function toEmbeddedData(document) {
|
||||
return foundry.utils.duplicate(document.toObject());
|
||||
}
|
||||
|
||||
function uniqueEmbeddedItems(items = []) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const item of items) {
|
||||
const key = `${item.type}::${normalizeText(item.name)}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function randomFrom(list = []) {
|
||||
if (!list.length) return null;
|
||||
return list[Math.floor(Math.random() * list.length)] ?? null;
|
||||
}
|
||||
|
||||
function getDefaultSpeciesForArchetype(archetypeName, currentSpecies = "Human") {
|
||||
if (archetypeName === "Ebb Operative") return "Ebon";
|
||||
if (archetypeName === "Predator / Beast") return "Advanced Carrien";
|
||||
return currentSpecies || "Human";
|
||||
}
|
||||
|
||||
function getDefaultDispositionForArchetype(archetypeName) {
|
||||
switch (archetypeName) {
|
||||
case "Civilian Witness": return "Nervous";
|
||||
case "Street Ganger": return "Hostile";
|
||||
case "Shiver Patrol": return "Professional";
|
||||
case "Corp Operative": return "Professional";
|
||||
case "Media": return "Corrupt";
|
||||
case "Internal Affairs": return "Professional";
|
||||
case "DarkNight Agent": return "Hostile";
|
||||
case "Tek Specialist": return "Professional";
|
||||
case "Ebb Operative": return "Cornered";
|
||||
case "Predator / Beast": return "Fanatical";
|
||||
default: return "Neutral";
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultThreatForArchetype(archetypeName) {
|
||||
switch (archetypeName) {
|
||||
case "Civilian Witness": return "Low";
|
||||
case "Street Ganger": return "Moderate";
|
||||
case "Shiver Patrol": return "Moderate";
|
||||
case "Corp Operative": return "Moderate";
|
||||
case "Media": return "Low";
|
||||
case "Internal Affairs": return "Moderate";
|
||||
case "DarkNight Agent": return "High";
|
||||
case "Tek Specialist": return "Moderate";
|
||||
case "Ebb Operative": return "High";
|
||||
case "Predator / Beast": return "Extreme";
|
||||
default: return "Moderate";
|
||||
}
|
||||
}
|
||||
|
||||
function getSectorOptionsForArchetype(archetypeName, fallback = "Mort City") {
|
||||
switch (archetypeName) {
|
||||
case "Street Ganger":
|
||||
return ["Cannibal Sectors", "Transit Underlevels", "Waste District", "Mort City"];
|
||||
case "Shiver Patrol":
|
||||
return ["Mort City", "Downtown Mort", "Transit Underlevels", "Cannibal Sectors"];
|
||||
case "Corp Operative":
|
||||
return ["Mort City", "Downtown Mort", "Industrial Edge", "Cannibal Sectors"];
|
||||
case "Media":
|
||||
return ["Downtown Mort", "Mort City", "Cannibal Sectors", "Transit Underlevels"];
|
||||
case "Internal Affairs":
|
||||
return ["Cannibal Sectors", "Mort City", "Downtown Mort", "Industrial Edge"];
|
||||
case "DarkNight Agent":
|
||||
return ["Cannibal Sectors", "Transit Underlevels", "Mort City", "Waste District"];
|
||||
case "Tek Specialist":
|
||||
return ["Industrial Edge", "Mort City", "Transit Underlevels", "Downtown Mort"];
|
||||
case "Ebb Operative":
|
||||
return ["Mort City", "Cannibal Sectors", "Downtown Mort"];
|
||||
case "Predator / Beast":
|
||||
return ["Waste District", "Cannibal Sectors", "Transit Underlevels", "Industrial Edge"];
|
||||
default:
|
||||
return [fallback, "Mort City", "Downtown Mort"];
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function getArchetypeAccentKey(archetypeName) {
|
||||
switch (archetypeName) {
|
||||
case "Shiver Patrol": return "shiver";
|
||||
case "DarkNight Agent": return "darknight";
|
||||
case "Tek Specialist": return "tek";
|
||||
case "Ebb Operative": return "ebb";
|
||||
case "Media": return "media";
|
||||
case "Internal Affairs": return "internal";
|
||||
case "Street Ganger": return "gang";
|
||||
case "Predator / Beast": return "predator";
|
||||
case "Civilian Witness": return "civilian";
|
||||
case "Corp Operative": return "corp";
|
||||
default: return "corp";
|
||||
}
|
||||
}
|
||||
|
||||
function getArchetypeBadge(archetypeName) {
|
||||
switch (archetypeName) {
|
||||
case "Shiver Patrol": return "Shiver Patrol";
|
||||
case "DarkNight Agent": return "DarkNight";
|
||||
case "Tek Specialist": return "Tek Division";
|
||||
case "Ebb Operative": return "Ebb Control";
|
||||
case "Media": return "Media Feed";
|
||||
case "Internal Affairs": return "Internal Affairs";
|
||||
case "Street Ganger": return "Street Threat";
|
||||
case "Predator / Beast": return "Hostile Specimen";
|
||||
case "Civilian Witness": return "Civilian Contact";
|
||||
case "Corp Operative": return "Corporate Asset";
|
||||
default: return archetypeName;
|
||||
}
|
||||
}
|
||||
|
||||
function getArchetypeBadgeShort(archetypeName) {
|
||||
switch (archetypeName) {
|
||||
case "Shiver Patrol": return "SV";
|
||||
case "DarkNight Agent": return "DN";
|
||||
case "Tek Specialist": return "TK";
|
||||
case "Ebb Operative": return "EB";
|
||||
case "Media": return "MD";
|
||||
case "Internal Affairs": return "IA";
|
||||
case "Street Ganger": return "SG";
|
||||
case "Predator / Beast": return "HX";
|
||||
case "Civilian Witness": return "CV";
|
||||
case "Corp Operative": return "CO";
|
||||
default: return "NPC";
|
||||
}
|
||||
}
|
||||
|
||||
function getArchetypeIconPath(archetypeName) {
|
||||
const root = "systems/sla-mothership/icons/ui/npc-badges";
|
||||
switch (archetypeName) {
|
||||
case "Shiver Patrol": return `${root}/shiver.svg`;
|
||||
case "DarkNight Agent": return `${root}/darknight.svg`;
|
||||
case "Tek Specialist": return `${root}/tek.svg`;
|
||||
case "Ebb Operative": return `${root}/ebb.svg`;
|
||||
case "Media": return `${root}/media.svg`;
|
||||
case "Internal Affairs": return `${root}/internal.svg`;
|
||||
case "Street Ganger": return `${root}/gang.svg`;
|
||||
case "Predator / Beast": return `${root}/predator.svg`;
|
||||
case "Civilian Witness": return `${root}/civilian.svg`;
|
||||
case "Corp Operative": return `${root}/corp.svg`;
|
||||
default: return `${root}/corp.svg`;
|
||||
}
|
||||
}
|
||||
|
||||
function getArchetypeStrapline(archetypeName) {
|
||||
switch (archetypeName) {
|
||||
case "Shiver Patrol": return "Precinct issue / patrol doctrine / fast response";
|
||||
case "DarkNight Agent": return "Black operations / pressure asset / covert response";
|
||||
case "Tek Specialist": return "Systems breach / maintenance / machine-facing support";
|
||||
case "Ebb Operative": return "Occult intervention / flux risk / psychic control";
|
||||
case "Media": return "Image control / live coverage / narrative pressure";
|
||||
case "Internal Affairs": return "Audit pressure / disciplinary review / quiet threat";
|
||||
case "Street Ganger": return "Turf violence / local intimidation / disposable muscle";
|
||||
case "Predator / Beast": return "Ferality / pursuit / close-quarters threat";
|
||||
case "Civilian Witness": return "Collateral witness / local knowledge / brittle pressure";
|
||||
case "Corp Operative": return "Corporate recovery / sponsor discipline / field oversight";
|
||||
default: return "Operational contact / generated dossier";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SLAMothershipGenerator } from "./sla-character-generator.js";
|
||||
import { SLANpcGeneratorApp } from "./sla-npc-generator.js";
|
||||
|
||||
const SYSTEM_ID = "sla-mothership";
|
||||
|
||||
@@ -22,6 +23,7 @@ export class SLAWorldToolsApp extends FormApplication {
|
||||
async getData() {
|
||||
const seededItems = game.items.filter((item) => item.flags?.[SYSTEM_ID]?.slaSeed?.key);
|
||||
const actors = game.actors.filter((actor) => actor.type === "character");
|
||||
const creatures = game.actors.filter((actor) => actor.type === "creature");
|
||||
const ebbActors = actors.filter((actor) => ["Ebon", "Brain Waster"].includes(String(actor.system?.sla?.species?.value ?? "").trim()));
|
||||
|
||||
return {
|
||||
@@ -30,6 +32,7 @@ export class SLAWorldToolsApp extends FormApplication {
|
||||
weaponCount: seededItems.filter((item) => item.type === "weapon").length,
|
||||
gearCount: seededItems.filter((item) => item.flags?.[SYSTEM_ID]?.slaSeed?.kind === "gear").length,
|
||||
actorCount: actors.length,
|
||||
creatureCount: creatures.length,
|
||||
starterCount: actors.filter((actor) => actor.flags?.[SYSTEM_ID]?.slaStarter).length,
|
||||
ebbActorCount: ebbActors.length,
|
||||
quickBattleMapBuilderReady: Boolean(game.modules?.get("quick-battlemap-builder")?.active && game.quickBattleMapBuilder?.open)
|
||||
@@ -53,6 +56,10 @@ export class SLAWorldToolsApp extends FormApplication {
|
||||
game.slaMothership.openGenerator();
|
||||
});
|
||||
|
||||
html.find(".open-npc-desk").on("click", () => {
|
||||
game.slaMothership.openNpcDesk();
|
||||
});
|
||||
|
||||
html.find(".generate-random").on("click", async () => {
|
||||
await game.slaMothership.createRandomOperative({ openSheet: true });
|
||||
this.render(false);
|
||||
@@ -127,7 +134,14 @@ export function installSlaSidebarButtons(app, html) {
|
||||
await game.slaMothership.seedWorldContent({ overwrite: true, notify: true });
|
||||
});
|
||||
|
||||
toolContainer.append(randomButton, createButton, toolsButton, refreshButton);
|
||||
const npcButton = $(`
|
||||
<button type="button" class="sla-open-npc-desk">
|
||||
<i class="fas fa-id-badge"></i> NPC Desk
|
||||
</button>
|
||||
`);
|
||||
npcButton.on("click", () => SLANpcGeneratorApp.open());
|
||||
|
||||
toolContainer.append(randomButton, createButton, npcButton, toolsButton, refreshButton);
|
||||
}
|
||||
|
||||
function findToolContainer(root) {
|
||||
|
||||
@@ -1 +1 @@
|
||||
MANIFEST-000075
|
||||
MANIFEST-000085
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
2026/04/14-21:44:03.701190 170dcb000 Recovering log #73
|
||||
2026/04/14-21:44:03.701635 170dcb000 Delete type=3 #71
|
||||
2026/04/14-21:44:03.701777 170dcb000 Delete type=0 #73
|
||||
2026/05/19-23:19:40.611150 173b87000 Recovering log #84
|
||||
2026/05/19-23:19:40.611608 173b87000 Delete type=3 #83
|
||||
2026/05/19-23:19:40.611656 173b87000 Delete type=0 #84
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
2026/04/14-14:29:42.528096 1725ef000 Recovering log #70
|
||||
2026/04/14-14:29:42.528525 1725ef000 Delete type=0 #70
|
||||
2026/04/14-14:29:42.528564 1725ef000 Delete type=3 #69
|
||||
2026/04/14-21:41:04.230515 172707000 Level-0 table #74: started
|
||||
2026/04/14-21:41:04.230654 172707000 Level-0 table #74: 0 bytes OK
|
||||
2026/04/14-21:41:04.230837 172707000 Delete type=0 #72
|
||||
2026/04/14-21:41:04.231926 172707000 Manual compaction at level-0 from '!items!1r6ruvSdxn2bHHIk' @ 72057594037927935 : 1 .. '!items!zbKI2RCl5h3vowoy' @ 0 : 0; will stop at (end)
|
||||
2026/04/16-22:16:27.606022 16eb7b000 Recovering log #82
|
||||
2026/04/16-22:16:27.612707 16eb7b000 Delete type=3 #81
|
||||
2026/04/16-22:16:27.612921 16eb7b000 Delete type=0 #82
|
||||
|
||||
@@ -1 +1 @@
|
||||
MANIFEST-000075
|
||||
MANIFEST-000085
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
2026/04/14-21:44:03.703209 171de3000 Recovering log #73
|
||||
2026/04/14-21:44:03.703506 171de3000 Delete type=3 #71
|
||||
2026/04/14-21:44:03.703538 171de3000 Delete type=0 #73
|
||||
2026/05/19-23:19:40.613309 172363000 Recovering log #84
|
||||
2026/05/19-23:19:40.613576 172363000 Delete type=3 #83
|
||||
2026/05/19-23:19:40.613610 172363000 Delete type=0 #84
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
2026/04/14-14:29:42.530946 171de3000 Recovering log #70
|
||||
2026/04/14-14:29:42.531303 171de3000 Delete type=0 #70
|
||||
2026/04/14-14:29:42.531327 171de3000 Delete type=3 #69
|
||||
2026/04/14-21:41:04.231427 172707000 Level-0 table #74: started
|
||||
2026/04/14-21:41:04.231573 172707000 Level-0 table #74: 0 bytes OK
|
||||
2026/04/14-21:41:04.231822 172707000 Delete type=0 #72
|
||||
2026/04/14-21:41:04.231960 172707000 Manual compaction at level-0 from '!items!0ZAwhewvLBwX3opf' @ 72057594037927935 : 1 .. '!items!z0avrplKe6M0RcXk' @ 0 : 0; will stop at (end)
|
||||
2026/04/16-22:16:27.615553 16f387000 Recovering log #82
|
||||
2026/04/16-22:16:27.616188 16f387000 Delete type=3 #81
|
||||
2026/04/16-22:16:27.616243 16f387000 Delete type=0 #82
|
||||
|
||||
@@ -1 +1 @@
|
||||
MANIFEST-000075
|
||||
MANIFEST-000085
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
2026/04/14-21:44:03.705381 1725ef000 Recovering log #73
|
||||
2026/04/14-21:44:03.705808 1725ef000 Delete type=3 #71
|
||||
2026/04/14-21:44:03.705977 1725ef000 Delete type=0 #73
|
||||
2026/05/19-23:19:40.614698 172b6f000 Recovering log #84
|
||||
2026/05/19-23:19:40.614916 172b6f000 Delete type=3 #83
|
||||
2026/05/19-23:19:40.614942 172b6f000 Delete type=0 #84
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
2026/04/14-14:29:42.533049 170dcb000 Recovering log #70
|
||||
2026/04/14-14:29:42.533339 170dcb000 Delete type=0 #70
|
||||
2026/04/14-14:29:42.533362 170dcb000 Delete type=3 #69
|
||||
2026/04/14-21:41:04.229609 172707000 Level-0 table #74: started
|
||||
2026/04/14-21:41:04.229909 172707000 Level-0 table #74: 0 bytes OK
|
||||
2026/04/14-21:41:04.230444 172707000 Delete type=0 #72
|
||||
2026/04/14-21:41:04.231906 172707000 Manual compaction at level-0 from '!items!1HNvL7XEUcMQbLps' @ 72057594037927935 : 1 .. '!items!zRzb3Laiuw4ac6p0' @ 0 : 0; will stop at (end)
|
||||
2026/04/16-22:16:27.618035 16fb93000 Recovering log #82
|
||||
2026/04/16-22:16:27.618455 16fb93000 Delete type=3 #81
|
||||
2026/04/16-22:16:27.618488 16fb93000 Delete type=0 #82
|
||||
|
||||
@@ -1 +1 @@
|
||||
MANIFEST-000075
|
||||
MANIFEST-000085
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
2026/04/14-21:44:03.707592 170dcb000 Recovering log #73
|
||||
2026/04/14-21:44:03.708035 170dcb000 Delete type=3 #71
|
||||
2026/04/14-21:44:03.708118 170dcb000 Delete type=0 #73
|
||||
2026/05/19-23:19:40.615928 17337b000 Recovering log #84
|
||||
2026/05/19-23:19:40.616184 17337b000 Delete type=3 #83
|
||||
2026/05/19-23:19:40.616214 17337b000 Delete type=0 #84
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
2026/04/14-14:29:42.534696 1725ef000 Recovering log #70
|
||||
2026/04/14-14:29:42.534932 1725ef000 Delete type=0 #70
|
||||
2026/04/14-14:29:42.534950 1725ef000 Delete type=3 #69
|
||||
2026/04/14-21:41:04.230890 172707000 Level-0 table #74: started
|
||||
2026/04/14-21:41:04.231026 172707000 Level-0 table #74: 0 bytes OK
|
||||
2026/04/14-21:41:04.231365 172707000 Delete type=0 #72
|
||||
2026/04/14-21:41:04.231944 172707000 Manual compaction at level-0 from '!macros!2xRvRAixuocjCm40' @ 72057594037927935 : 1 .. '!macros!ilBclUmPwcd7aNQK' @ 0 : 0; will stop at (end)
|
||||
2026/04/16-22:16:27.619986 16e36f000 Recovering log #82
|
||||
2026/04/16-22:16:27.620339 16e36f000 Delete type=3 #81
|
||||
2026/04/16-22:16:27.620370 16e36f000 Delete type=0 #82
|
||||
|
||||
@@ -1 +1 @@
|
||||
MANIFEST-000075
|
||||
MANIFEST-000085
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
2026/04/14-21:44:03.709360 171de3000 Recovering log #73
|
||||
2026/04/14-21:44:03.709675 171de3000 Delete type=3 #71
|
||||
2026/04/14-21:44:03.709706 171de3000 Delete type=0 #73
|
||||
2026/05/19-23:19:40.617145 17337b000 Recovering log #84
|
||||
2026/05/19-23:19:40.617442 17337b000 Delete type=3 #83
|
||||
2026/05/19-23:19:40.617486 17337b000 Delete type=0 #84
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
2026/04/14-14:29:42.536078 171de3000 Recovering log #70
|
||||
2026/04/14-14:29:42.536336 171de3000 Delete type=0 #70
|
||||
2026/04/14-14:29:42.536359 171de3000 Delete type=3 #69
|
||||
2026/04/14-21:41:04.232630 172707000 Level-0 table #74: started
|
||||
2026/04/14-21:41:04.232740 172707000 Level-0 table #74: 0 bytes OK
|
||||
2026/04/14-21:41:04.232976 172707000 Delete type=0 #72
|
||||
2026/04/14-21:41:04.234457 172707000 Manual compaction at level-0 from '!macros!2u4a5gEogtLd3XQz' @ 72057594037927935 : 1 .. '!macros!trU0DtBJvJCPddsQ' @ 0 : 0; will stop at (end)
|
||||
2026/04/16-22:16:27.621457 16e36f000 Recovering log #82
|
||||
2026/04/16-22:16:27.621716 16e36f000 Delete type=3 #81
|
||||
2026/04/16-22:16:27.621743 16e36f000 Delete type=0 #82
|
||||
|
||||
@@ -1 +1 @@
|
||||
MANIFEST-000075
|
||||
MANIFEST-000085
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
2026/04/14-21:44:03.710794 170dcb000 Recovering log #73
|
||||
2026/04/14-21:44:03.711256 170dcb000 Delete type=3 #71
|
||||
2026/04/14-21:44:03.711288 170dcb000 Delete type=0 #73
|
||||
2026/05/19-23:19:40.618390 17337b000 Recovering log #84
|
||||
2026/05/19-23:19:40.618663 17337b000 Delete type=3 #83
|
||||
2026/05/19-23:19:40.618694 17337b000 Delete type=0 #84
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
2026/04/14-14:29:42.537344 1715d7000 Recovering log #70
|
||||
2026/04/14-14:29:42.537597 1715d7000 Delete type=0 #70
|
||||
2026/04/14-14:29:42.537618 1715d7000 Delete type=3 #69
|
||||
2026/04/14-21:41:04.232083 172707000 Level-0 table #74: started
|
||||
2026/04/14-21:41:04.232272 172707000 Level-0 table #74: 0 bytes OK
|
||||
2026/04/14-21:41:04.232554 172707000 Delete type=0 #72
|
||||
2026/04/14-21:41:04.234426 172707000 Manual compaction at level-0 from '!macros!0Yw4QWSAeOuUzRqB' @ 72057594037927935 : 1 .. '!macros!z5V92LyXHjfMxnRQ' @ 0 : 0; will stop at (end)
|
||||
2026/04/16-22:16:27.622897 16e36f000 Recovering log #82
|
||||
2026/04/16-22:16:27.623174 16e36f000 Delete type=3 #81
|
||||
2026/04/16-22:16:27.623240 16e36f000 Delete type=0 #82
|
||||
|
||||
@@ -1 +1 @@
|
||||
MANIFEST-000077
|
||||
MANIFEST-000087
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
2026/04/14-21:44:03.713901 1725ef000 Recovering log #75
|
||||
2026/04/14-21:44:03.714351 1725ef000 Delete type=0 #75
|
||||
2026/04/14-21:44:03.714384 1725ef000 Delete type=3 #73
|
||||
2026/05/19-23:19:40.619688 17337b000 Recovering log #86
|
||||
2026/05/19-23:19:40.620003 17337b000 Delete type=3 #85
|
||||
2026/05/19-23:19:40.620026 17337b000 Delete type=0 #86
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
2026/04/14-14:29:42.538470 1725ef000 Recovering log #72
|
||||
2026/04/14-14:29:42.538719 1725ef000 Delete type=3 #71
|
||||
2026/04/14-14:29:42.538747 1725ef000 Delete type=0 #72
|
||||
2026/04/14-21:41:04.233774 172707000 Level-0 table #76: started
|
||||
2026/04/14-21:41:04.233930 172707000 Level-0 table #76: 0 bytes OK
|
||||
2026/04/14-21:41:04.234304 172707000 Delete type=0 #74
|
||||
2026/04/14-21:41:04.234513 172707000 Manual compaction at level-0 from '!macros!06NSvDYTtmkeUtLq' @ 72057594037927935 : 1 .. '!macros!zLRsweb35Lw0NMev' @ 0 : 0; will stop at (end)
|
||||
2026/04/16-22:16:27.624570 16e36f000 Recovering log #84
|
||||
2026/04/16-22:16:27.625146 16e36f000 Delete type=3 #83
|
||||
2026/04/16-22:16:27.625212 16e36f000 Delete type=0 #84
|
||||
|
||||
@@ -1 +1 @@
|
||||
MANIFEST-000075
|
||||
MANIFEST-000085
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
2026/04/14-21:44:03.716230 1715d7000 Recovering log #73
|
||||
2026/04/14-21:44:03.716588 1715d7000 Delete type=3 #71
|
||||
2026/04/14-21:44:03.716645 1715d7000 Delete type=0 #73
|
||||
2026/05/19-23:19:40.621197 17337b000 Recovering log #84
|
||||
2026/05/19-23:19:40.621497 17337b000 Delete type=3 #83
|
||||
2026/05/19-23:19:40.621534 17337b000 Delete type=0 #84
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
2026/04/14-14:29:42.540072 170dcb000 Recovering log #70
|
||||
2026/04/14-14:29:42.540350 170dcb000 Delete type=0 #70
|
||||
2026/04/14-14:29:42.540376 170dcb000 Delete type=3 #69
|
||||
2026/04/14-21:41:04.233064 172707000 Level-0 table #74: started
|
||||
2026/04/14-21:41:04.233246 172707000 Level-0 table #74: 0 bytes OK
|
||||
2026/04/14-21:41:04.233613 172707000 Delete type=0 #72
|
||||
2026/04/14-21:41:04.234486 172707000 Manual compaction at level-0 from '!tables!1vCm4ElRPotQXgNB' @ 72057594037927935 : 1 .. '!tables.results!kqKpQAXyLTEEyz6Z.xdwwswzfoPuVifvw' @ 0 : 0; will stop at (end)
|
||||
2026/04/16-22:16:27.626995 16e36f000 Recovering log #82
|
||||
2026/04/16-22:16:27.627395 16e36f000 Delete type=3 #81
|
||||
2026/04/16-22:16:27.627527 16e36f000 Delete type=0 #82
|
||||
|
||||
@@ -1 +1 @@
|
||||
MANIFEST-000075
|
||||
MANIFEST-000085
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
2026/04/14-21:44:03.718522 170dcb000 Recovering log #73
|
||||
2026/04/14-21:44:03.718938 170dcb000 Delete type=3 #71
|
||||
2026/04/14-21:44:03.718979 170dcb000 Delete type=0 #73
|
||||
2026/05/19-23:19:40.622748 173b87000 Recovering log #84
|
||||
2026/05/19-23:19:40.622969 173b87000 Delete type=3 #83
|
||||
2026/05/19-23:19:40.622990 173b87000 Delete type=0 #84
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
2026/04/14-14:29:42.542090 1715d7000 Recovering log #70
|
||||
2026/04/14-14:29:42.542385 1715d7000 Delete type=0 #70
|
||||
2026/04/14-14:29:42.542403 1715d7000 Delete type=3 #69
|
||||
2026/04/14-21:41:04.234643 172707000 Level-0 table #74: started
|
||||
2026/04/14-21:41:04.234976 172707000 Level-0 table #74: 0 bytes OK
|
||||
2026/04/14-21:41:04.235710 172707000 Delete type=0 #72
|
||||
2026/04/14-21:41:04.240761 172707000 Manual compaction at level-0 from '!tables!31YibfjueXuZdNLb' @ 72057594037927935 : 1 .. '!tables.results!ypcoikqHLhnc9tNs.zJkYrssfrdWBSeRm' @ 0 : 0; will stop at (end)
|
||||
2026/04/16-22:16:27.629071 16fb93000 Recovering log #82
|
||||
2026/04/16-22:16:27.629605 16fb93000 Delete type=3 #81
|
||||
2026/04/16-22:16:27.629821 16fb93000 Delete type=0 #82
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "sla-mothership",
|
||||
"title": "SLA Mothership",
|
||||
"description": "An SLA Industries conversion built on the unofficial Mothership system for Foundry VTT, preserving Mothership's core mechanics while adding SLA flux, Ebb, BPN, and equipment support.",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.5",
|
||||
"esmodules": [
|
||||
"module/mosh.js"
|
||||
],
|
||||
|
||||
@@ -246,6 +246,38 @@
|
||||
"value": 1,
|
||||
"html": ""
|
||||
},
|
||||
"slaNpc": {
|
||||
"archetype": {
|
||||
"value": ""
|
||||
},
|
||||
"species": {
|
||||
"value": ""
|
||||
},
|
||||
"affiliationType": {
|
||||
"value": ""
|
||||
},
|
||||
"affiliation": {
|
||||
"value": ""
|
||||
},
|
||||
"department": {
|
||||
"value": ""
|
||||
},
|
||||
"role": {
|
||||
"value": ""
|
||||
},
|
||||
"sector": {
|
||||
"value": ""
|
||||
},
|
||||
"disposition": {
|
||||
"value": ""
|
||||
},
|
||||
"threat": {
|
||||
"value": ""
|
||||
},
|
||||
"summary": {
|
||||
"value": ""
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"combat": {
|
||||
"value": 10,
|
||||
@@ -717,4 +749,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,30 +17,19 @@
|
||||
|
||||
<div class="headergrid">
|
||||
<div class="sla-sheet-wordmark">SLA Industries</div>
|
||||
<!-- <img class="profile-img" src="{{actor.img}}" data-edit="img" title="{{actor.name}}" height="150" width="150"/> -->
|
||||
|
||||
{{!-- REST button — top-right, above Race --}}
|
||||
<div class="header-field header-field-rest">
|
||||
<button type="button" class="sla-sheet-action sla-rest-button sla-header-rest-pill" title="Take a short or long rest">REST</button>
|
||||
</div>
|
||||
|
||||
{{!-- ROW 2: Name / Class / Race --}}
|
||||
<!-- PLAYER NAME -->
|
||||
<div class="headernamegrid header-field header-field-name">
|
||||
<div class="headerinputtext">{{ localize 'Mosh.Name' }}</div>
|
||||
<div class="headerinputfield sla-header-input charname"><input name="name" class="noborder" type="text"
|
||||
value="{{name}}" placeholder="{{ localize 'Mosh.Name' }}" /></div>
|
||||
</div>
|
||||
<div class="header-field header-field-lad">
|
||||
<div class="headerinputtext">L.A.D.</div>
|
||||
<label class="sla-header-checkbox" title="Life After Death">
|
||||
<input type="checkbox" name="system.sla.lad" data-dtype="Boolean" {{checked system.sla.lad}} />
|
||||
<span>L.A.D.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-field header-field-rest">
|
||||
<div class="headerinputtext"> </div>
|
||||
<button type="button" class="sla-sheet-action sla-rest-button sla-header-rest-pill" title="Take a short or long rest">Rest</button>
|
||||
</div>
|
||||
<div class="header-field header-field-credits">
|
||||
<div class="headerinputtext">{{ localize 'Mosh.Credits' }}</div>
|
||||
<div class="headerinputfield sla-header-input charname"><input name="system.credits.value" class="noborder" type="text"
|
||||
value="{{system.credits.value}}" placeholder="" /></div>
|
||||
</div>
|
||||
|
||||
<!-- CLASS -->
|
||||
<div class="header-field header-field-class">
|
||||
@@ -49,28 +38,42 @@
|
||||
value="{{system.class.value}}" placeholder="" /></div>
|
||||
</div>
|
||||
|
||||
<!-- RANK -->
|
||||
<!-- RACE -->
|
||||
<div class="header-field header-field-race">
|
||||
<div class="headerinputtext">Race</div>
|
||||
<div class="headerinputfield sla-header-input charname"><input name="system.rank.value" class="noborder" type="text"
|
||||
value="{{system.rank.value}}" placeholder="" /></div>
|
||||
</div>
|
||||
|
||||
<div class="header-field header-field-sla-rank header-field-compact">
|
||||
<div class="headerinputtext">S.C.L.</div>
|
||||
<div class="headerinputfield sla-header-input charname"><input name="system.attributes.level.value" class="noborder"
|
||||
type="text" value="{{system.attributes.level.value}}" placeholder="" /></div>
|
||||
</div>
|
||||
|
||||
<div class="header-field header-field-height header-field-compact">
|
||||
<div class="headerinputtext">Height</div>
|
||||
<div class="headerinputfield sla-header-input charname"><input name="system.sla.height.value" class="noborder" type="text"
|
||||
value="{{system.sla.height.value}}" placeholder="" /></div>
|
||||
</div>
|
||||
<div class="header-field header-field-weight header-field-compact">
|
||||
<div class="headerinputtext">Weight</div>
|
||||
<div class="headerinputfield sla-header-input charname"><input name="system.sla.weightText.value" class="noborder" type="text"
|
||||
value="{{system.sla.weightText.value}}" placeholder="" /></div>
|
||||
{{!-- ROW 3: Bottom row — all equal width, same font --}}
|
||||
<div class="sla-header-bottom-row">
|
||||
<div class="header-field header-field-lad">
|
||||
<div class="headerinputtext">L.A.D.</div>
|
||||
<label class="sla-header-checkbox" title="Life After Death">
|
||||
<input type="checkbox" name="system.sla.lad" data-dtype="Boolean" {{checked system.sla.lad}} />
|
||||
<span>L.A.D.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-field header-field-credits">
|
||||
<div class="headerinputtext">{{ localize 'Mosh.Credits' }}</div>
|
||||
<div class="headerinputfield sla-header-input charname"><input name="system.credits.value" class="noborder" type="text"
|
||||
value="{{system.credits.value}}" placeholder="" /></div>
|
||||
</div>
|
||||
<div class="header-field header-field-sla-rank">
|
||||
<div class="headerinputtext">S.C.L.</div>
|
||||
<div class="headerinputfield sla-header-input charname"><input name="system.attributes.level.value" class="noborder"
|
||||
type="text" value="{{system.attributes.level.value}}" placeholder="" /></div>
|
||||
</div>
|
||||
<div class="header-field header-field-height">
|
||||
<div class="headerinputtext">Height</div>
|
||||
<div class="headerinputfield sla-header-input charname"><input name="system.sla.height.value" class="noborder" type="text"
|
||||
value="{{system.sla.height.value}}" placeholder="" /></div>
|
||||
</div>
|
||||
<div class="header-field header-field-weight">
|
||||
<div class="headerinputtext">Weight</div>
|
||||
<div class="headerinputfield sla-header-input charname"><input name="system.sla.weightText.value" class="noborder" type="text"
|
||||
value="{{system.sla.weightText.value}}" placeholder="" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,6 +318,14 @@
|
||||
<div class="sla-mainstat-action-strip">
|
||||
<button type="button" class="sla-sheet-action sla-wound-button">Wound</button>
|
||||
</div>
|
||||
{{#if conditions.length}}
|
||||
<div class="sla-active-conditions-summary">
|
||||
<span class="sla-conditions-label">CONDITIONS:</span>
|
||||
{{#each conditions as |condition|}}
|
||||
<span class="sla-active-condition">{{condition.name}}{{#if condition.system.severity}} {{condition.system.severity}}{{/if}}</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<!-- SAVES -->
|
||||
{{#if system.settings.firstEdition}}
|
||||
@@ -391,6 +402,7 @@
|
||||
<a class="tab-select" data-tab="sla">SLA</a>
|
||||
<a class="tab-select" data-tab="traits">Traits</a>
|
||||
<a class="tab-select" data-tab="special-rules">Special Rules</a>
|
||||
{{#if isVevaphon}}<a class="tab-select" data-tab="morph">Morph</a>{{/if}}
|
||||
{{#if isEbbUser}}<a class="tab-select" data-tab="ebb">Ebb</a>{{/if}}
|
||||
<a class="tab-select" data-tab="description">{{ localize 'Mosh.Bio' }}</a>
|
||||
<a class="tab-select" data-tab="notes">{{ localize 'Mosh.Notes' }}</a>
|
||||
@@ -724,8 +736,76 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{{#if isVevaphon}}
|
||||
<div class="tab items" data-group="primary" data-tab="morph">
|
||||
<div class="sla-panel">
|
||||
<div class="sla-banner">
|
||||
<div>
|
||||
<div class="sla-kicker">Stormer Vevaphon</div>
|
||||
<div class="sla-title">Polymorphic Form Control</div>
|
||||
</div>
|
||||
<div class="sla-tags">
|
||||
<span class="sla-tag">Form: {{system.sla.morphForm.value}}</span>
|
||||
<span class="sla-tag {{vevaphonInstabilityClass}}">Instability {{system.sla.vevaphonInstability.value}} / 12</span>
|
||||
<span class="sla-tag">Sanity {{system.stats.sanity.value}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2col sla-card-grid">
|
||||
{{!-- Instability Track --}}
|
||||
<div class="sla-card">
|
||||
<div class="sla-card-label">Instability Track</div>
|
||||
<div class="sla-morph-track-bar">
|
||||
<div class="sla-morph-track-fill {{vevaphonInstabilityClass}}" style="width: {{vevaphonInstabilityPercent}}%;"></div>
|
||||
<span class="sla-morph-track-label">{{data.sla.vevaphonInstability.value}} / 12</span>
|
||||
</div>
|
||||
<div class="sla-card-copy">{{vevaphonInstabilityDesc}}</div>
|
||||
<div class="sla-action-row" style="margin-top: 10px;">
|
||||
<button type="button" class="sla-instability-increase sla-morph-btn-instab">+1 Instability</button>
|
||||
<button type="button" class="sla-instability-decrease sla-morph-btn-instab sla-morph-btn-instab-down">−1 Instability</button>
|
||||
</div>
|
||||
<div class="sla-card-copy" style="margin-top: 12px; font-style: italic;">At 12, the Vevaphon is lost — immediate retirement or death.</div>
|
||||
</div>
|
||||
|
||||
{{!-- Active Morph Form --}}
|
||||
<div class="sla-card">
|
||||
<div class="sla-card-label">Active Morph Form</div>
|
||||
<div class="sla-card-copy" style="margin-bottom: 10px;">Each morph shift costs 1 Instability. Changing form mid-scene requires a Sanity save at Instability 6+.</div>
|
||||
{{#each slaMorphForms as |form|}}
|
||||
<button type="button"
|
||||
class="sla-morph-shift sla-morph-form-btn {{#if form.slaIsActiveMorphForm}}sla-morph-active{{/if}}"
|
||||
data-form="{{form.name}}">
|
||||
<strong class="sla-morph-form-name">{{form.name}}</strong>
|
||||
{{#if form.system.summary}}<div class="sla-morph-form-summary">{{form.system.summary}}</div>{{/if}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2col sla-card-grid" style="margin-top: 12px;">
|
||||
{{!-- Morph Panic Reference --}}
|
||||
<div class="sla-card">
|
||||
<div class="sla-card-label">Morph Panic Reference</div>
|
||||
<div class="sla-card-copy">Triggered at Instability 10+ on a failed Sanity save. Roll 1d6:</div>
|
||||
<div class="sla-note-list" style="margin-top: 8px;">
|
||||
{{#each vevaphonMorphPanic as |entry|}}
|
||||
<div class="sla-note-pill sla-note-pill-wide">{{entry.roll}}. {{entry.label}} — {{entry.desc}}</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Morph Notes --}}
|
||||
<div class="sla-card">
|
||||
<div class="sla-card-label">Morph Notes</div>
|
||||
<textarea name="system.sla.morphNotes.value" rows="10" class="textarea-input" data-target="system.sla.morphNotes.value">{{system.sla.morphNotes.value}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if isEbbUser}}
|
||||
<div class="tab items" data-group="primary" data-tab="ebb">
|
||||
<div class="sla-panel">
|
||||
|
||||
@@ -97,6 +97,46 @@
|
||||
</div>
|
||||
<div class="whiteline"></div>
|
||||
</header>
|
||||
<section class="sla-creature-dossier">
|
||||
<div class="sla-creature-dossier-grid">
|
||||
<div class="resource">
|
||||
<label class="resource-label">Role</label>
|
||||
<input type="text" name="system.slaNpc.role.value" value="{{system.slaNpc.role.value}}" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Archetype</label>
|
||||
<input type="text" name="system.slaNpc.archetype.value" value="{{system.slaNpc.archetype.value}}" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Species</label>
|
||||
<input type="text" name="system.slaNpc.species.value" value="{{system.slaNpc.species.value}}" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Affiliation</label>
|
||||
<input type="text" name="system.slaNpc.affiliation.value" value="{{system.slaNpc.affiliation.value}}" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Department / Cell</label>
|
||||
<input type="text" name="system.slaNpc.department.value" value="{{system.slaNpc.department.value}}" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Sector</label>
|
||||
<input type="text" name="system.slaNpc.sector.value" value="{{system.slaNpc.sector.value}}" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Disposition</label>
|
||||
<input type="text" name="system.slaNpc.disposition.value" value="{{system.slaNpc.disposition.value}}" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Threat</label>
|
||||
<input type="text" name="system.slaNpc.threat.value" value="{{system.slaNpc.threat.value}}" />
|
||||
</div>
|
||||
<div class="resource sla-creature-dossier-wide">
|
||||
<label class="resource-label">Snapshot</label>
|
||||
<input type="text" name="system.slaNpc.summary.value" value="{{system.slaNpc.summary.value}}" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="creature-description-grid">
|
||||
<div class="creaturedescription">
|
||||
<div class="grid grid-3col">
|
||||
|
||||
@@ -70,6 +70,13 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#unless isSlaEbbSkill}}
|
||||
{{#if rollBreakdown}}
|
||||
<div class="sla-chat-section sla-chat-section-meta" style="margin-top: 5px; border-top: 1px dashed rgba(255,255,255,0.15); padding-top: 5px;">
|
||||
<div class="body"><strong>Target Number:</strong> {{#each rollBreakdown.parts as |part index|}}{{#if index}} + {{/if}}{{part.label}} {{part.value}}{{/each}} = <strong>{{rollBreakdown.total}}</strong></div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
|
||||
{{#if (compare 'str' specialRoll '===' 'damage')}}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<form class="{{cssClass}} sla-npc-generator sla-npc-accent-{{accentKey}}" autocomplete="off">
|
||||
<p>Create lightweight SLA NPCs with enough identity for play, then open the creature sheet to adjust portrait, items, weapons, skills, and notes.</p>
|
||||
|
||||
<div class="sla-card sla-npc-brief-card" style="padding: 12px; margin-bottom: 12px;">
|
||||
<div class="grid grid-2col" style="gap: 10px;">
|
||||
<div class="resource">
|
||||
<label class="resource-label">Name</label>
|
||||
<div class="flexrow" style="gap: 8px;">
|
||||
<input type="text" name="name" value="{{state.name}}" />
|
||||
<button type="button" class="sla-npc-random-name"><i class="fas fa-dice"></i> Name</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Archetype</label>
|
||||
<select name="archetype">
|
||||
{{#each archetypes}}
|
||||
<option value="{{this.name}}" {{#if (compare "str" ../state.archetype "===" this.name)}}selected{{/if}}>{{this.name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Species</label>
|
||||
<select name="species">
|
||||
{{#each speciesOptions}}
|
||||
<option value="{{this}}" {{#if (compare "str" ../state.species "===" this)}}selected{{/if}}>{{this}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Quality</label>
|
||||
<select name="quality">
|
||||
{{#each qualityOptions}}
|
||||
<option value="{{this}}" {{#if (compare "str" ../state.quality "===" this)}}selected{{/if}}>{{this}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sla-card-copy" style="margin-top: 10px;">{{summary}}</div>
|
||||
</div>
|
||||
|
||||
<section class="sla-card sla-npc-preview-panel" style="margin-bottom: 12px;">
|
||||
<div class="sla-npc-preview-header">
|
||||
<div class="sla-npc-preview-heading">
|
||||
<div class="sla-npc-preview-badge" aria-hidden="true">
|
||||
<img class="sla-npc-preview-icon" src="{{preview.iconPath}}" alt="" />
|
||||
<span>{{preview.iconLabel}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sla-kicker">{{preview.badge}}</div>
|
||||
<div class="sla-title">{{state.name}}</div>
|
||||
<div class="sla-card-copy">{{preview.strap}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sla-npc-preview-pill">{{state.quality}}</div>
|
||||
</div>
|
||||
<div class="sla-npc-preview-grid">
|
||||
<div class="sla-npc-preview-block">
|
||||
<div class="sla-card-label">Profile</div>
|
||||
<div class="sla-card-copy"><strong>{{preview.identity}}</strong></div>
|
||||
<div class="sla-card-copy">{{preview.brief}}</div>
|
||||
<div class="sla-card-copy">{{preview.posture}}</div>
|
||||
</div>
|
||||
<div class="sla-npc-preview-block">
|
||||
<div class="sla-card-label">Field Readout</div>
|
||||
<div class="sla-tags">
|
||||
{{#each preview.statline}}
|
||||
<span class="sla-tag">{{this}}</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sla-npc-preview-block sla-npc-preview-wide">
|
||||
<div class="sla-card-label">Issue Kit</div>
|
||||
<div class="sla-note-list">
|
||||
{{#each preview.loadout}}
|
||||
<span class="sla-note-pill">{{this}}</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid grid-2col" style="gap: 10px;">
|
||||
<div class="resource">
|
||||
<label class="resource-label">Affiliation Type</label>
|
||||
<select name="affiliationType">
|
||||
{{#each affiliationTypes}}
|
||||
<option value="{{this}}" {{#if (compare "str" ../state.affiliationType "===" this)}}selected{{/if}}>{{this}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Affiliation</label>
|
||||
<input type="text" name="affiliation" value="{{state.affiliation}}" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Department / Cell / Turf</label>
|
||||
<input type="text" name="department" value="{{state.department}}" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Role / Title</label>
|
||||
<input type="text" name="role" value="{{state.role}}" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Sector / Location</label>
|
||||
<input type="text" name="sector" value="{{state.sector}}" />
|
||||
</div>
|
||||
<div class="grid grid-2col" style="gap: 10px;">
|
||||
<div class="resource">
|
||||
<label class="resource-label">Disposition</label>
|
||||
<select name="disposition">
|
||||
{{#each dispositionOptions}}
|
||||
<option value="{{this}}" {{#if (compare "str" ../state.disposition "===" this)}}selected{{/if}}>{{this}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Threat</label>
|
||||
<select name="threat">
|
||||
{{#each threatOptions}}
|
||||
<option value="{{this}}" {{#if (compare "str" ../state.threat "===" this)}}selected{{/if}}>{{this}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Loadout Preset</label>
|
||||
<select name="loadout">
|
||||
{{#each loadoutOptions}}
|
||||
<option value="{{this}}" {{#if (compare "str" ../state.loadout "===" this)}}selected{{/if}}>{{this}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Generator Notes</label>
|
||||
<textarea name="notes" rows="4">{{state.notes}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sla-action-row" style="margin-top: 12px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
|
||||
<label class="checkbox" style="display:inline-flex; gap:6px; align-items:center;">
|
||||
<input type="checkbox" name="assignPortrait" {{checked state.assignPortrait}} />
|
||||
Assign portrait if available
|
||||
</label>
|
||||
<label class="checkbox" style="display:inline-flex; gap:6px; align-items:center;">
|
||||
<input type="checkbox" name="openSheet" {{checked state.openSheet}} />
|
||||
Open sheet after creation
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="sla-action-row" style="margin-top: 12px; display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap;">
|
||||
<button type="button" class="sla-npc-randomize"><i class="fas fa-shuffle"></i> Randomize Brief</button>
|
||||
<button type="submit"><i class="fas fa-user-plus"></i> Create NPC</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -10,6 +10,10 @@
|
||||
<label class="resource-label">Character Actors</label>
|
||||
<div>{{actorCount}}</div>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Creature NPCs</label>
|
||||
<div>{{creatureCount}}</div>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label class="resource-label">Skills / Weapons</label>
|
||||
<div>{{skillCount}} / {{weaponCount}}</div>
|
||||
@@ -28,6 +32,7 @@
|
||||
<button type="button" class="seed-world"><i class="fas fa-box-open"></i> Seed SLA Content</button>
|
||||
<button type="button" class="refresh-world"><i class="fas fa-sync"></i> Refresh Seeded Content</button>
|
||||
<button type="button" class="open-generator"><i class="fas fa-user-plus"></i> Open Generator</button>
|
||||
<button type="button" class="open-npc-desk"><i class="fas fa-id-badge"></i> Open NPC Desk</button>
|
||||
<button type="button" class="generate-random"><i class="fas fa-dice"></i> Create Random Operative</button>
|
||||
<button type="button" class="generate-random-standard"><i class="fas fa-user"></i> Create Standard Operative</button>
|
||||
<button type="button" class="generate-random-ebb"><i class="fas fa-brain"></i> Create Random Ebb User</button>
|
||||
|
||||