18 Commits
Author SHA1 Message Date
tevans 6557ef82ad Enhance combat: add Unarmed Combat item support, interactive Critical Hit chat buttons, clickable critical success options, and severity text contrast visual overhaul 2026-05-27 18:45:18 +01:00
tevans 17c64ec0de style: premium scene control and sidebar UI overhaul v0.6.5 2026-05-25 14:27:14 +01:00
tevans 64bef08fe0 chore: bump version to 0.6.4 2026-05-24 21:02:11 +01:00
tevans d81034f6f4 feat: add interactive combat roll modifier slider snappable UI and visual ticks 2026-05-24 21:01:06 +01:00
tevans f66ac84c0b Skill roll dialog: scrollable list replaces fixed pixel height
- Remove manual dialogHeight pixel counter (was error-prone)
- Wrap skill list in scrollable div (max-height 340px, overflow-y auto)
- Long skill lists no longer push action buttons off-screen
2026-05-19 22:20:00 +01:00
tevans 24f513baf8 NPC Desk: archetype accents, live preview card, badge icons, custom SVG emblems
- sla-npc-generator.js: archetype-specific accent styling for Shiver, DarkNight, Tek, Ebb, Media, Internal Affairs, gangs, civilians, predators, corp assets; live preview card (identity, strapline, disposition/threat posture, stat readout, loadout kit) updating on archetype/quality/species/disposition/threat/loadout/affiliation change; badge icon helper switched from Font Awesome classes to custom SVG path mapping
- sla-npc-generator.html: preview card markup with badge header; SVG badge renderer replacing icon-class approach
- mosh.css: archetype accent strip styles; preview card layout; image-based badge styling; badge set rules under npc-badges
- icons/ui/npc-badges/: 10 new custom SVGs — shiver, darknight, tek, ebb, internal, media, gang, predator, civilian, corp
2026-04-15 18:27:24 +01:00
tevans 9269348bf4 Fix NPC dossier layout: move block below header, proper responsive grid
- templates/actor/creature-sheet.html: moved dossier section out of the header into the sheet body so it no longer squashes the stat/HP area; switched inner grid to new sla-creature-dossier-grid class; Snapshot field now spans full width with sla-creature-dossier-wide
- css/mosh.css: added sla-creature-dossier-grid (3-col responsive grid collapsing to 2-col at 900px, 1-col at 640px); labels get display:block + bottom margin; inputs get width:100% and box-sizing; added sla-creature-dossier-wide span-3 helper; fixed dossier margin
2026-04-15 17:20:34 +01:00
tevans 2758be6096 Add NPC Desk: generator window, creature dossier, world tools integration
New files:
- module/windows/sla-npc-generator.js — NPC generator window; archetype, species, quality, affiliation, dept/cell, role, sector, disposition, threat, loadout preset; auto-creates creature actors into SLA Mothership/NPCs folder; auto-assigns portrait if portrait pack active; seeds basic skills, weapons, armor and gear
- templates/dialogs/sla-npc-generator.html — form template for the NPC Desk window

Updated files:
- template.json — extended creature actor data with NPC dossier fields (role, affiliation, department, sector, threat, summary)
- templates/actor/creature-sheet.html — editable NPC dossier block on creature sheet
- module/actor/creature-sheet.js — wired dossier fields into sheet data
- module/mosh.js — exposed generator via system API
- module/windows/sla-world-tools.js — sidebar button to open NPC Desk
- templates/dialogs/sla-world-tools.html — Open NPC Desk entry in world tools dialog
- css/mosh.css — styling for NPC Desk and dossier block
2026-04-15 17:12:46 +01:00
tevans b1cfb636df Add active conditions summary below stats + GM 30-min chat report
- templates/actor/actor-sheet.html: red conditions bar shown below Strength/Speed/Intellect/Combat stats whenever any condition items are active; displays condition name and severity
- module/mosh.js: GM-only 30-minute interval timer that whispers a formatted conditions report to all GMs listing every actor with active conditions and their severities
- css/mosh.css: styles for .sla-active-conditions-summary, .sla-conditions-label, .sla-active-condition (red text, dark red border/background)
2026-04-15 16:24:41 +01:00
tevans 0d77741a41 Header redesign: REST top-right above Race, bottom row all equal-width flex
- REST button moves to grid row 1, cols 7-8, right-aligned above Race field
- Wordmark narrows to cols 1-6 to give REST clear space
- LAD / Credits / SCL / Height / Weight wrapped in single flex row (equal width)
- Removed compact-field font overrides — all bottom fields now same size
- Removed inline styles from REST button
2026-04-15 14:11:59 +01:00
tevans 6508a7e883 Header layout: REST next to LAD, wider Credits, consistent compact field fonts
- REST button moves to col 2 (immediately right of LAD)
- LAD drops span-2, takes single col 1
- Credits field now spans 3 columns (much wider, value fully visible)
- SCL/HEIGHT/WEIGHT shift right to cols 6-7-8
- Compact field fonts match other header fields (0.95rem)
- SCL/HEIGHT/WEIGHT labels and inputs centered
2026-04-15 14:04:06 +01:00
tevans 8cbea7c237 Add full Vevaphon MORPH tab: instability track, form selector, panic reference, morph notes
- actor.js: Add isSlaVevaphon(), setSlaVevaphonMorphForm(), adjustSlaVevaphonInstability() methods
- actor.js: Fix getSlaRestrictedEbbItemIds() to skip morph-form abilities (abilityType !== 'ebb')
- actor.js: Fix enforceSlaEbbEligibility() with ID validation + try/catch for concurrent calls
- actor-sheet.js: Split morphForms into separate array, pre-compute slaIsActiveMorphForm flag
- actor-sheet.js: Add vevaphonInstabilityDesc and vevaphonMorphPanic to template data
- actor-sheet.html: Add MORPH tab with instability track, active form buttons, panic reference, notes
- actor-sheet.html: Remove ability-use-trigger class from morph form rows (Vevaphons are not Ebb users)
- mosh.css: Add morph tab CSS (track bar, instability colours, form buttons, active state, tags)
- mosh.css: Enlarge REST button pill (max-width 120px, padding/font overrides)
2026-04-15 13:50:12 +01:00
tevans d89671e707 Fix: remove Ebb trigger from morph form rows; widen REST button container to 120px 2026-04-15 13:43:35 +01:00
tevans d0d907bd98 Fix morph forms: use abilityType=morph (schema-defined) to survive Ebb enforcement; bigger REST button 2026-04-15 11:52:42 +01:00
tevans f3264d60e2 Add Vevaphon morph form display to Special Rules tab; fix morphForm flag on world items; enlarge REST button 2026-04-15 11:39:20 +01:00
tevans 63da87f372 Fix: exempt morphForm abilities from Ebb eligibility sweep — Vevaphons morph, they are not Ebb users 2026-04-15 11:19:04 +01:00
tevans 4a5561a82f Fix v14 Vevaphon: debounce createItem hook + try/catch guard on Ebb enforcement deletes 2026-04-15 11:14:45 +01:00
tevans 9fb7867069 Fix v14 compat: filter embedded item IDs before deleteEmbeddedDocuments in enforceSlaEbbEligibility 2026-04-15 11:10:00 +01:00
71 changed files with 2439 additions and 224 deletions
+599 -34
View File
@@ -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,
@@ -4411,11 +4775,21 @@ p {
.mosh.sla-chat-shell .sla-chat-section-danger.sla-severity-minor {
background: linear-gradient(180deg, rgba(92, 57, 18, 0.96), rgba(55, 34, 12, 0.96));
border-color: rgba(255, 190, 74, 0.5);
color: #ffeacc;
}
.mosh.sla-chat-shell .sla-chat-section-danger.sla-severity-minor strong {
color: #ffffff;
}
.mosh.sla-chat-shell .sla-chat-section-danger.sla-severity-severe {
background: linear-gradient(180deg, rgba(90, 18, 26, 0.97), rgba(52, 10, 15, 0.97));
border-color: rgba(255, 91, 91, 0.52);
color: #ffdada;
}
.mosh.sla-chat-shell .sla-chat-section-danger.sla-severity-severe strong {
color: #ffffff;
}
.mosh.sla-chat-shell .sla-chat-section-danger.sla-severity-critical {
@@ -4424,6 +4798,11 @@ p {
linear-gradient(180deg, rgba(102, 8, 28, 0.98), rgba(45, 8, 21, 0.98));
border-color: rgba(204, 237, 255, 0.84);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.06), 0 0 14px rgba(162, 231, 255, 0.08);
color: #ffffff;
}
.mosh.sla-chat-shell .sla-chat-section-danger.sla-severity-critical strong {
color: #ffffff;
}
.mosh.sla-chat-shell .sla-chat-section-target {
@@ -4619,26 +4998,25 @@ p {
font-weight: 800;
}
.mosh .sla-severity-minor,
.mosh .sla-chat-severity-pill.sla-severity-minor {
background: linear-gradient(180deg, rgba(255, 216, 140, 0.32), rgba(255, 189, 79, 0.18));
border-color: rgba(224, 152, 24, 0.42);
color: #9b5b00;
background: linear-gradient(180deg, #ffc866 0%, #ff9d00 100%);
border-color: #ffbe4a;
color: #1a1000 !important;
text-shadow: none;
}
.mosh .sla-severity-severe,
.mosh .sla-chat-severity-pill.sla-severity-severe {
background: linear-gradient(180deg, rgba(255, 145, 145, 0.24), rgba(233, 63, 63, 0.14));
border-color: rgba(179, 27, 27, 0.34);
color: #9e1717;
background: linear-gradient(180deg, #ff8080 0%, #cc0000 100%);
border-color: #ff5b5b;
color: #ffffff !important;
text-shadow: 0 1px 1px rgba(0,0,0,0.5);
}
.mosh .sla-severity-critical,
.mosh .sla-chat-severity-pill.sla-severity-critical {
background: linear-gradient(180deg, rgba(232, 247, 255, 0.94), rgba(196, 233, 255, 0.82));
border-color: rgba(84, 145, 199, 0.42);
color: #611120;
box-shadow: 0 0 14px rgba(126, 196, 245, 0.16);
background: linear-gradient(180deg, #9ae6ff 0%, #0099ff 100%);
border-color: #54a5d6;
color: #001a33 !important;
text-shadow: none;
}
.mosh .sla-chat-section-target {
@@ -5075,3 +5453,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;
}
+5
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+5
View File
@@ -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

+6
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+5
View File
@@ -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

+24 -3
View File
@@ -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
+230 -46
View File
@@ -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`
}
@@ -1994,19 +2031,29 @@ export class MothershipActor extends Actor {
if (rawFormula === "Str/10") {
rawFormula = `floor((${this.system.stats.strength.value} + ${(Number(this.system.stats.strength.mod) || 0)}) / 10)`;
}
const mult = Number(weapon?.system?.sla?.damageMultiplier ?? weapon?.system?.damageMultiplier ?? 1) || 1;
if (mult > 1) {
if (/^\d+d\d+/i.test(rawFormula)) {
rawFormula = rawFormula.replace(/^(\d+)(d\d+)/i, (match, dice, rest) => {
return `${Number(dice) * mult}${rest}`;
});
} else {
rawFormula = `(${rawFormula}) * ${mult}`;
}
}
const critDamageMode = game.settings.get('sla-mothership', 'critDamage');
if (isCritical) {
if (critDamageMode === 'advantage') {
rawFormula = `{${parsedDamageString},${parsedDamageString}}kh`;
rawFormula = `{${rawFormula},${rawFormula}}kh`;
} else if (critDamageMode === 'doubleDamage') {
rawFormula = `(${parsedDamageString}) * 2`;
rawFormula = `(${rawFormula}) * 2`;
} else if (critDamageMode === 'doubleDice') {
rawFormula = `${parsedDamageString} + ${parsedDamageString}`;
rawFormula = `${rawFormula} + ${rawFormula}`;
} else if (critDamageMode === 'maxDamage') {
rawFormula = parsedDamageString.replaceAll('d', ' * ');
rawFormula = rawFormula.replaceAll('d', ' * ');
} else if (critDamageMode === 'weaponValue') {
rawFormula = `${parsedDamageString} + ${weapon?.system?.critDmg || 0}`;
rawFormula = `${rawFormula} + ${weapon?.system?.critDmg || 0}`;
}
}
@@ -2478,6 +2525,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 +2535,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) {
@@ -2544,7 +2597,15 @@ export class MothershipActor extends Actor {
//if this is a damage roll
if (specialRoll === 'damage') {
//parse the roll string
let damageRollString = weapon.system.damage
let damageRollString = weapon.system.damage;
if (weapon && !overrideDamagaRollString) {
const mult = Number(weapon.system?.sla?.damageMultiplier ?? weapon.system?.damageMultiplier ?? 1) || 1;
if (mult > 1) {
damageRollString = damageRollString.replace(/^(\d+)(d\d+)/i, (match, dice, rest) => {
return `${Number(dice) * mult}${rest}`;
});
}
}
if(overrideDamagaRollString){
damageRollString = overrideDamagaRollString;
}
@@ -2563,8 +2624,10 @@ export class MothershipActor extends Actor {
}
//prepare flavortext
if (weapon.system.damage === "Str/10" && this.type === 'character') {
const mult = Number(weapon.system?.sla?.damageMultiplier ?? weapon.system?.damageMultiplier ?? 1) || 1;
const multStr = mult > 1 ? `${mult} * ` : '';
//determine the damage string
flavorText = 'You strike your target for <strong>[[floor((' + this.system.stats.strength.value + ' + ' + (Number(this.system.stats.strength.mod) || 0) + ')/10)]] damage</strong>.';
flavorText = 'You strike your target for <strong>[[' + multStr + 'floor((' + this.system.stats.strength.value + ' + ' + (Number(this.system.stats.strength.mod) || 0) + ')/10)]] damage</strong>.';
} else {
flavorText = 'You inflict [[' + parsedDamageString + '[' + dsnTheme + ']' + critMod + ']] points of damage.';
}
@@ -2639,6 +2702,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 +4084,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 +4145,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 +4190,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 +4212,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 +5168,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 +5213,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);
+3
View File
@@ -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");
+207 -2
View File
@@ -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,38 @@ 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", async (item, options, userId) => {
// ── Unarmed Combat Auto-Skill Drop ──
const actor = item.parent;
if (actor && actor.documentName === "Actor" && item.type === "weapon" && String(item.name).trim().toLowerCase() === "unarmed combat") {
const hasSkill = actor.items.some(i => i.type === "skill" && String(i.name).trim().toLowerCase() === "unarmed combat");
if (!hasSkill) {
await actor.createEmbeddedDocuments("Item", [{
name: "Unarmed Combat",
type: "skill",
img: "icons/svg/fist.svg",
system: {
rank: "Trained",
bonus: 10,
sla: { category: "combat", skillFamily: "", source: "Unarmed Item Drop" }
}
}]);
ui.notifications.info(`Added 'Unarmed Combat' skill to ${actor.name}.`);
}
}
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 +299,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;
@@ -294,6 +376,129 @@ Hooks.on("renderChatMessageHTML", (app, html) => {
await actor.rollSlaWoundEffect(effectKey, modifier);
});
});
// ── Critical Hit? Button Handler ──
root.querySelectorAll(".sla-crit-hit-btn").forEach((button) => {
button.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
const actorId = button.dataset.actorId;
const itemId = button.dataset.itemId;
const actor = actorId ? game.actors?.get(actorId) : null;
if (!actor) {
ui.notifications.warn("The actor for this critical hit could not be found.");
return;
}
const weapon = itemId ? actor.items.get(itemId) : null;
// Determine wound setting key based on weapon and damage type
let settingKey = 'table1eWoundBluntForce';
let tableLabel = 'Blunt Force';
if (weapon) {
const name = String(weapon.name ?? "").toLowerCase();
const damageType = String(weapon.system?.sla?.damageType ?? "").toLowerCase();
if (damageType.includes("blunt") || damageType.includes("impact")) {
settingKey = 'table1eWoundBluntForce';
tableLabel = 'Blunt Force';
} else if (damageType.includes("penetrating") || damageType.includes("pierce") || damageType.includes("stab") || damageType.includes("bullet") || damageType.includes("shot")) {
settingKey = 'table1eWoundGunshot';
tableLabel = 'Gunshot';
} else if (damageType.includes("slash") || damageType.includes("rend") || damageType.includes("bleed") || damageType.includes("cut")) {
settingKey = 'table1eWoundBleeding';
tableLabel = 'Bleeding';
} else if (damageType.includes("fire") || damageType.includes("explos") || damageType.includes("acid") || damageType.includes("burn")) {
settingKey = 'table1eWoundFireExplosives';
tableLabel = 'Fire & Explosives';
} else if (name.includes("pistol") || name.includes("smg") || name.includes("rifle") || name.includes("shotgun") || name.includes("blunderbuss") || name.includes("gun") || name.includes("harpoon") || name.includes("bullet") || name.includes("projectile") || name.includes("crossbow")) {
settingKey = 'table1eWoundGunshot';
tableLabel = 'Gunshot';
} else if (name.includes("machete") || name.includes("cleaver") || name.includes("whip") || name.includes("blade") || name.includes("knife") || name.includes("sword") || name.includes("claws") || name.includes("bite") || name.includes("razor") || name.includes("teeth")) {
settingKey = 'table1eWoundBleeding';
tableLabel = 'Bleeding';
} else if (name.includes("club") || name.includes("pipe") || name.includes("hammer") || name.includes("prod") || name.includes("unarmed") || name.includes("fist") || name.includes("strike") || name.includes("blunt") || name.includes("brawl") || name.includes("cuff")) {
settingKey = 'table1eWoundBluntForce';
tableLabel = 'Blunt Force';
} else if (name.includes("bomb") || name.includes("acid") || name.includes("spit") || name.includes("fire") || name.includes("flame") || name.includes("grenade") || name.includes("explosive")) {
settingKey = 'table1eWoundFireExplosives';
tableLabel = 'Fire & Explosives';
}
}
const tableId = game.settings.get('sla-mothership', settingKey);
if (!tableId) {
ui.notifications.warn(`No wound table is configured for ${tableLabel}.`);
return;
}
// Resolve the table
let resolved = game.tables.get(tableId) || game.tables.getName(tableId);
if (!resolved) {
for (const pack of game.packs) {
if (pack.metadata.type === "RollTable") {
const index = await pack.getIndex();
const entry = index.find(e => e.id === tableId || e.name === tableId || e.name === `${tableLabel} Wound` || e.name === tableLabel.replace(/&/g, "and"));
if (entry) {
resolved = await pack.getDocument(entry._id);
break;
}
}
}
}
if (!resolved) {
ui.notifications.warn(`The ${tableLabel} wound table could not be found.`);
return;
}
await actor.rollTable(resolved.uuid ?? resolved.id ?? tableId, null, null, null, null, null, null);
});
});
// ── Critical Success Option Button Handler ──
root.querySelectorAll(".sla-crit-option-btn").forEach((button) => {
button.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
const actorId = button.dataset.actorId;
const optionText = button.dataset.optionText;
const actor = actorId ? game.actors?.get(actorId) : null;
const actorName = actor ? actor.name : "An operative";
const chatContent = await foundry.applications.handlebars.renderTemplate(
'systems/sla-mothership/templates/chat/sla-critical-selection.html',
{
actorName: actorName,
img: actor?.img ?? 'icons/svg/mystery-man.svg',
optionText: optionText
}
);
await ChatMessage.create({
user: game.user.id,
content: chatContent,
speaker: ChatMessage.getSpeaker({ actor: actor })
});
// Disable all options in the card
const container = button.closest(".sla-crit-success-options");
if (container) {
container.querySelectorAll(".sla-crit-option-btn").forEach(btn => {
btn.disabled = true;
btn.style.opacity = 0.6;
btn.style.cursor = "default";
if (btn === button) {
btn.style.background = "#0f300f";
btn.style.border = "1px solid #1ebd1e";
}
});
}
});
});
});
//add custom damage dice for MOSH
@@ -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 ?? {},
+853
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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";
}
}
+15 -1
View File
@@ -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
View File
@@ -1 +1 @@
MANIFEST-000075
MANIFEST-000085
+3 -3
View File
@@ -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
+3 -7
View File
@@ -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
View File
@@ -1 +1 @@
MANIFEST-000075
MANIFEST-000085
+3 -3
View File
@@ -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
+3 -7
View File
@@ -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
View File
@@ -1 +1 @@
MANIFEST-000075
MANIFEST-000085
+3 -3
View File
@@ -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
+3 -7
View File
@@ -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
View File
@@ -1 +1 @@
MANIFEST-000075
MANIFEST-000085
+3 -3
View File
@@ -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
+3 -7
View File
@@ -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
View File
@@ -1 +1 @@
MANIFEST-000075
MANIFEST-000085
+3 -3
View File
@@ -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
+3 -7
View File
@@ -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
View File
@@ -1 +1 @@
MANIFEST-000075
MANIFEST-000085
+3 -3
View File
@@ -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
+3 -7
View File
@@ -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
View File
@@ -1 +1 @@
MANIFEST-000077
MANIFEST-000087
+3 -3
View File
@@ -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
+3 -7
View File
@@ -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
View File
@@ -1 +1 @@
MANIFEST-000075
MANIFEST-000085
+3 -3
View File
@@ -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
+3 -7
View File
@@ -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
View File
@@ -1 +1 @@
MANIFEST-000075
MANIFEST-000085
+3 -3
View File
@@ -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
+3 -7
View File
@@ -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
+1 -1
View File
@@ -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.8",
"esmodules": [
"module/mosh.js"
],
+33 -1
View File
@@ -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 @@
}
}
}
}
}
+113 -33
View File
@@ -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">&nbsp;</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">
+40
View File
@@ -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">
+26 -4
View File
@@ -70,10 +70,22 @@
{{/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')}}
<div class="sla-chat-section sla-chat-section-damage">{{{flavorText}}}</div>
<div class="sla-chat-section sla-chat-section-crit-btn" style="margin-top: 6px;">
<button class="sla-crit-hit-btn" data-actor-id="{{actor._id}}" data-item-id="{{weapon._id}}" style="background: linear-gradient(185deg, #a80000 0%, #5c0000 100%); color: #ffffff; border: 1px solid #ff4444; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-family: 'Barlow Condensed', sans-serif; font-size: 11pt; font-weight: bold; width: 100%; text-shadow: 0 1px 2px #000; box-shadow: 0 0 6px rgba(255, 0, 0, 0.4); transition: all 0.2s ease;">
<i class="fas fa-skull-crossbones"></i> CRITICAL HIT?
</button>
</div>
{{#if needsDesc}}
<div class="sla-chat-divider">Description</div>
<div class="description sla-chat-section">
@@ -89,14 +101,24 @@
{{else}}
{{#if parsedRollResult.success}}
{{#if flavorText}}<div class="sla-chat-section sla-chat-section-success">{{{flavorText}}}</div>{{/if}}
{{#if weapon}}
<div class="sla-chat-section sla-chat-section-crit-btn" style="margin-top: 6px; margin-bottom: 6px;">
<button class="sla-crit-hit-btn" data-actor-id="{{actor._id}}" data-item-id="{{weapon._id}}" style="background: linear-gradient(185deg, #a80000 0%, #5c0000 100%); color: #ffffff; border: 1px solid #ff4444; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-family: 'Barlow Condensed', sans-serif; font-size: 11pt; font-weight: bold; width: 100%; text-shadow: 0 1px 2px #000; box-shadow: 0 0 6px rgba(255, 0, 0, 0.4); transition: all 0.2s ease;">
<i class="fas fa-skull-crossbones"></i> CRITICAL HIT?
</button>
</div>
{{/if}}
{{#if criticalSuccessOptions.length}}
<div class="sla-chat-section sla-chat-section-success">
<div class="sla-chat-section-title">Critical Success Options</div>
<ol class="sla-chat-list">
<div class="sla-chat-section-title"><i class="fas fa-crown"></i> Critical Success Options</div>
<div class="sla-crit-success-options" style="margin-top: 5px;">
{{#each criticalSuccessOptions as |option|}}
<li>{{option}}</li>
<button class="sla-crit-option-btn" data-actor-id="{{../actor._id}}" data-option-text="{{option}}" style="background: linear-gradient(180deg, #1f421f 0%, #0c260c 100%); color: #bdf2bd; border: 1px solid #3fa63f; border-radius: 4px; padding: 6px 10px; margin-bottom: 6px; cursor: pointer; font-family: 'Barlow Condensed', sans-serif; font-size: 10.5pt; text-align: left; width: 100%; transition: all 0.2s ease; display: flex; align-items: flex-start; gap: 6px; text-shadow: 0 1px 2px #000;">
<i class="fas fa-chevron-right" style="margin-top: 3px; font-size: 8pt; color: #2ebd2e;"></i>
<span>{{option}}</span>
</button>
{{/each}}
</ol>
</div>
</div>
{{/if}}
{{#if weapon}}
@@ -0,0 +1,19 @@
<div class="mosh sla-chat-shell sla-chat-shell-check sla-chat-shell-success" style="border-left: 4px solid #2ebd2e; padding-left: 8px;">
<div class="rollcontainer">
<div class="sla-chat-hero" style="background: rgba(46, 189, 46, 0.15); display: flex; align-items: center; padding: 6px; border-radius: 4px 4px 0 0;">
<div class="sla-chat-hero-main" style="flex-grow: 1;">
<div class="sla-chat-kicker-row">
<div class="sla-chat-kicker" style="color: #85e085; font-size: 8pt; font-weight: bold; letter-spacing: 0.1em;">TACTICAL DECISION</div>
</div>
<div class="rollweaponh1 sla-chat-hero-title" style="font-size: 14pt; margin: 0; color: #ffffff;">{{{actorName}}}</div>
</div>
<div class="sla-chat-hero-media" style="width: 32px; height: 32px; border-radius: 4px; overflow: hidden; margin-left: 8px; flex-shrink: 0; border: 1px solid #2ebd2e;">
<img class="roll-image" src="{{{img}}}" style="width: 100%; height: 100%; object-fit: cover;" />
</div>
</div>
<div class="sla-chat-section sla-chat-section-success" style="padding: 10px; background: rgba(0,0,0,0.3); border-radius: 0 0 4px 4px; font-family: 'Cormorant Garamond', serif; font-size: 13pt; line-height: 1.3;">
<i class="fas fa-check-circle" style="color: #2ebd2e; margin-right: 4px;"></i> <strong>Combat Critical Choice:</strong>
<div style="margin-top: 6px; color: #e0fde0; font-style: italic;">"{{{optionText}}}"</div>
</div>
</div>
</div>
+154
View File
@@ -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>
+5
View File
@@ -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>