feat: expand ISAC content modeling
This commit is contained in:
+526
@@ -4646,6 +4646,62 @@ a.secondary:active {
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: bold;
|
||||
}
|
||||
.selection-head > .row-action {
|
||||
min-height: 34px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
justify-self: end;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #4c3d26;
|
||||
background: #17140f;
|
||||
color: var(--shd-orange);
|
||||
font-family: var(--shd-font-sans);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.65px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.12s ease,
|
||||
background 0.12s ease,
|
||||
color 0.12s ease,
|
||||
transform 0.08s ease;
|
||||
}
|
||||
.selection-head > .row-action:hover,
|
||||
.selection-head > .row-action:focus-visible {
|
||||
border-color: var(--shd-orange);
|
||||
background: #211a0e;
|
||||
color: var(--shd-orange-hover);
|
||||
}
|
||||
.selection-head > .row-action:focus-visible {
|
||||
outline: 1px solid var(--shd-orange);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.selection-head > .row-action:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
background: #120f0b;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.selection-head > .row-action:disabled {
|
||||
border-color: var(--shd-border);
|
||||
background: #111415;
|
||||
color: var(--shd-text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.selection-head > .row-action {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.selection-head > .row-action {
|
||||
transition: none;
|
||||
}
|
||||
.selection-head > .row-action:active:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.button-link {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -12585,6 +12641,134 @@ a.secondary:active {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mod-secondary-pool-registry {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--shd-border);
|
||||
border-left: 2px solid var(--quality-specialized);
|
||||
background: #0d1011;
|
||||
}
|
||||
.mod-secondary-capacity {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--shd-border);
|
||||
}
|
||||
.mod-secondary-capacity > span {
|
||||
min-height: 58px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 1px 8px;
|
||||
padding: 8px;
|
||||
border-left: 1px solid var(--shd-border);
|
||||
background: #101314;
|
||||
}
|
||||
.mod-secondary-capacity > span:first-child {
|
||||
border-left: 0;
|
||||
}
|
||||
.mod-secondary-capacity b,
|
||||
.mod-secondary-capacity small {
|
||||
font-size: var(--shd-type-telemetry);
|
||||
letter-spacing: 0.55px;
|
||||
}
|
||||
.mod-secondary-capacity small {
|
||||
color: var(--shd-text-muted);
|
||||
}
|
||||
.mod-secondary-capacity strong {
|
||||
grid-row: 1/3;
|
||||
grid-column: 2;
|
||||
color: var(--shd-orange);
|
||||
font-size: 24px;
|
||||
}
|
||||
.mod-secondary-capacity .pool-total {
|
||||
border-left-color: #74552a;
|
||||
background: #18150d;
|
||||
}
|
||||
.mod-secondary-pool-editor {
|
||||
border-top-color: var(--quality-specialized);
|
||||
}
|
||||
.pool-editor-head {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.mod-secondary-pool-options {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
.mod-secondary-pool-option {
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(220px, 0.9fr) repeat(
|
||||
3,
|
||||
minmax(285px, 1fr)
|
||||
) 30px;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: #101314;
|
||||
}
|
||||
.mod-secondary-pool-option > strong {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #171b1c;
|
||||
color: var(--shd-orange);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
}
|
||||
.mod-secondary-quality-range {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 4px 6px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--shd-border);
|
||||
}
|
||||
.mod-secondary-quality-range > b {
|
||||
grid-row: 1/3;
|
||||
font-size: var(--shd-type-telemetry);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.mod-secondary-quality-range label {
|
||||
display: grid;
|
||||
grid-template-columns: 54px 1fr;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.mod-secondary-quality-range label > span {
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 900;
|
||||
}
|
||||
.mod-secondary-quality-range label > div {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 12px 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
.mod-secondary-quality-range input {
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
padding: 0 5px;
|
||||
}
|
||||
.mod-secondary-quality-range i {
|
||||
color: #596164;
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-style: normal;
|
||||
text-align: center;
|
||||
}
|
||||
.mod-secondary-pool-option > button {
|
||||
align-self: end;
|
||||
height: 32px;
|
||||
border: 1px solid #563734;
|
||||
background: #1d1212;
|
||||
color: var(--shd-danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mod-secondary-pool-option > button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.weapon-mod-quality-row,
|
||||
.weapon-mod-quality-row.heading {
|
||||
@@ -12597,8 +12781,156 @@ a.secondary:active {
|
||||
.weapon-mod-secondary-options label:nth-of-type(4) {
|
||||
grid-column: auto;
|
||||
}
|
||||
.mod-secondary-pool-options {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.skill-chip-shared-config {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skill-chip-shared-config .mod-secondary-pool-registry {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.skill-chip-profile-options {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option {
|
||||
min-width: 870px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1.2fr) repeat(4, minmax(130px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: #0e1112;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option.selectable {
|
||||
grid-template-columns: 32px minmax(210px, 1.2fr) repeat(
|
||||
3,
|
||||
minmax(130px, 1fr)
|
||||
) 34px;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > div:first-child,
|
||||
.skill-chip-profile-option > label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid #292f30;
|
||||
background: #121617;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > div:first-child strong,
|
||||
.skill-chip-profile-option > div:first-child small,
|
||||
.skill-chip-profile-option > label > b {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > div:first-child small,
|
||||
.skill-chip-profile-option > label > b {
|
||||
font-size: var(--shd-type-micro);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > label > span {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 8px 6px;
|
||||
border: 1px solid #353c3d;
|
||||
background: #090c0d;
|
||||
color: var(--shd-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > strong {
|
||||
align-self: center;
|
||||
color: var(--shd-orange);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > button:last-child {
|
||||
border: 1px solid #553128;
|
||||
background: #1a1110;
|
||||
color: var(--shd-danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option > button:last-child:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.skill-chip-profile-option input:focus-visible,
|
||||
.skill-chip-profile-option > button:last-child:focus-visible {
|
||||
outline: 2px solid var(--shd-orange);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skill-chip-talent-disclosure,
|
||||
.protocol-talent-disclosure {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.skill-chip-talent-removal,
|
||||
.protocol-talent-removal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid #57332b;
|
||||
background: #1a1110;
|
||||
color: var(--shd-danger);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.skill-chip-shared-config {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.mod-secondary-pool-option {
|
||||
min-width: 1160px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.mod-secondary-capacity {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.mod-secondary-capacity > span,
|
||||
.mod-secondary-capacity > span:first-child {
|
||||
border-top: 1px solid var(--shd-border);
|
||||
border-left: 1px solid var(--shd-border);
|
||||
}
|
||||
.mod-secondary-capacity > span:nth-child(odd) {
|
||||
border-left: 0;
|
||||
}
|
||||
.mod-secondary-capacity .pool-total {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.weapon-mod-secondary-options > div {
|
||||
grid-template-columns: 30px 1fr;
|
||||
}
|
||||
@@ -13088,6 +13420,18 @@ a.secondary:active {
|
||||
border-top: 2px solid #404748;
|
||||
background: #111415;
|
||||
}
|
||||
.database-item-card.protocol-quality-standard {
|
||||
border-top-color: var(--quality-standard);
|
||||
}
|
||||
.database-item-card.protocol-quality-specialized {
|
||||
border-top-color: var(--quality-specialized);
|
||||
}
|
||||
.database-item-card.protocol-quality-superior {
|
||||
border-top-color: var(--quality-superior);
|
||||
}
|
||||
.database-item-card.protocol-quality-highend {
|
||||
border-top-color: var(--quality-high-end);
|
||||
}
|
||||
.database-item-card > header {
|
||||
height: auto;
|
||||
min-height: 78px;
|
||||
@@ -13193,6 +13537,68 @@ a.secondary:active {
|
||||
font-size: var(--shd-type-telemetry);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.database-weapon-mod-slots {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 7px 12px 0;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.database-weapon-mod-slots > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.database-weapon-mod-slots > div span,
|
||||
.database-weapon-mod-slots > p {
|
||||
color: #626a6c;
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.65px;
|
||||
}
|
||||
.database-weapon-mod-slots ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.database-weapon-mod-slots li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 27px;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid #383f40;
|
||||
background: #0d1011;
|
||||
color: #aeb4b5;
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 800;
|
||||
}
|
||||
.database-weapon-mod-slots li.has-slot-icon {
|
||||
min-width: 34px;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
}
|
||||
.database-weapon-mod-slots li.upgrade-slot {
|
||||
border-color: #315b6a;
|
||||
background: #0b171c;
|
||||
color: var(--shd-info);
|
||||
}
|
||||
.database-weapon-mod-slots li small {
|
||||
padding-left: 6px;
|
||||
border-left: 1px solid #315b6a;
|
||||
color: var(--shd-info);
|
||||
font-size: var(--shd-type-micro);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.database-weapon-mod-slots > p {
|
||||
margin: 0;
|
||||
}
|
||||
.database-item-lines {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
@@ -13357,6 +13763,123 @@ a.secondary:active {
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.7px;
|
||||
}
|
||||
.database-header-badge.exotic {
|
||||
border-color: #74552a;
|
||||
background: #1a160d;
|
||||
color: var(--quality-high-end);
|
||||
}
|
||||
.database-header-badge.highend {
|
||||
border-color: color-mix(in srgb, var(--quality-high-end) 58%, #52431a);
|
||||
background: #1c190c;
|
||||
color: var(--quality-high-end);
|
||||
}
|
||||
.database-header-badge.protocol-badge-standard {
|
||||
border-color: color-mix(in srgb, var(--quality-standard) 58%, #354142);
|
||||
background: #111718;
|
||||
color: var(--quality-standard);
|
||||
}
|
||||
.database-header-badge.protocol-badge-specialized {
|
||||
border-color: color-mix(in srgb, var(--quality-specialized) 58%, #274338);
|
||||
background: #0e1713;
|
||||
color: var(--quality-specialized);
|
||||
}
|
||||
.database-header-badge.protocol-badge-superior {
|
||||
border-color: color-mix(in srgb, var(--quality-superior) 58%, #2c3f5a);
|
||||
background: #10161d;
|
||||
color: var(--quality-superior);
|
||||
}
|
||||
.database-header-badge.protocol-badge-highend {
|
||||
border-color: color-mix(in srgb, var(--quality-high-end) 58%, #52431a);
|
||||
background: #1c190c;
|
||||
color: var(--quality-high-end);
|
||||
}
|
||||
.database-protocol-preview {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 9px 12px 0;
|
||||
}
|
||||
.database-protocol-primary {
|
||||
min-height: 66px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 0.7fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 7px;
|
||||
border: 1px solid var(--shd-border);
|
||||
border-left: 3px solid var(--shd-orange);
|
||||
background: linear-gradient(90deg, #21190e, #101314);
|
||||
}
|
||||
.database-protocol-primary span {
|
||||
padding-left: 5px;
|
||||
color: #c89138;
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.9px;
|
||||
}
|
||||
.database-protocol-primary b {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--shd-orange);
|
||||
font-size: 20px;
|
||||
letter-spacing: 0.7px;
|
||||
}
|
||||
.database-protocol-level {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.7px;
|
||||
}
|
||||
.database-protocol-level > span {
|
||||
flex: none;
|
||||
color: var(--shd-text);
|
||||
}
|
||||
.database-protocol-level input {
|
||||
width: 104px;
|
||||
accent-color: var(--shd-orange);
|
||||
}
|
||||
.database-protocol-talent {
|
||||
display: grid;
|
||||
}
|
||||
.database-protocol-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.database-protocol-diamonds {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.database-protocol-diamonds button {
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #596164;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.database-protocol-diamonds button.active,
|
||||
.database-protocol-diamonds button:hover,
|
||||
.database-protocol-diamonds button:focus-visible {
|
||||
color: var(--quality-high-end);
|
||||
}
|
||||
.database-protocol-diamonds button:focus-visible {
|
||||
outline: 2px solid var(--shd-orange);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.database-protocol-talent p {
|
||||
margin: 0;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: #101314;
|
||||
color: #a2a8aa;
|
||||
font: var(--shd-type-supporting)/1.55 var(--shd-font-editorial);
|
||||
}
|
||||
.database-fact-value {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -13691,6 +14214,9 @@ a.secondary:active {
|
||||
.database-item-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.database-weapon-mod-slots {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.database-item-card > header {
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,172 @@ describe('article documents', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts and canonicalizes metadata emitted by the Tiptap editor', () => {
|
||||
const parsed = parseArticleDocument({
|
||||
version: 1,
|
||||
document: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Read more',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://shd.build/news',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: null,
|
||||
title: 'News archive',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: null },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'First item' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed?.document.document.content).toEqual([
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Read more',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://shd.build/news',
|
||||
title: 'News archive',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'orderedList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'First item' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(parsed?.plainText).toContain('News archive');
|
||||
});
|
||||
|
||||
it('preserves supported ordered-list numbering', () => {
|
||||
const parsed = parseArticleDocument({
|
||||
version: 1,
|
||||
document: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 2, type: 'A' },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed?.document.document.content?.[0]).toMatchObject({
|
||||
type: 'orderedList',
|
||||
attrs: { start: 2, type: 'A' },
|
||||
});
|
||||
expect(parseArticleDocument(parsed?.document)?.document).toEqual(
|
||||
parsed?.document,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown link metadata and non-default ordered lists', () => {
|
||||
const documentWith = (content: unknown[]) => ({
|
||||
version: 1,
|
||||
document: { type: 'doc', content },
|
||||
});
|
||||
|
||||
expect(
|
||||
parseArticleDocument(
|
||||
documentWith([
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'unsafe metadata',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://shd.build',
|
||||
onclick: 'alert(1)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseArticleDocument(
|
||||
documentWith([
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 0, type: 'decimal' },
|
||||
content: [],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseArticleDocument(
|
||||
documentWith([
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: 1 },
|
||||
content: [],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects duplicate embed keys and unknown envelope fields', () => {
|
||||
const embed = {
|
||||
type: 'buildEmbed',
|
||||
|
||||
@@ -10,7 +10,7 @@ const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/;
|
||||
|
||||
export type ArticleMark = {
|
||||
type: 'bold' | 'italic' | 'code' | 'link';
|
||||
attrs?: { href: string };
|
||||
attrs?: { href: string; title?: string };
|
||||
};
|
||||
|
||||
export type ArticleNode = {
|
||||
@@ -53,7 +53,17 @@ const safeLink = (value: unknown) => {
|
||||
}
|
||||
};
|
||||
|
||||
function parseMarks(value: unknown): ArticleMark[] | null {
|
||||
const nullableBoundedString = (value: unknown, maximum: number) =>
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'string' &&
|
||||
value.length <= maximum &&
|
||||
!/\p{Cc}/u.test(value));
|
||||
|
||||
function parseMarks(
|
||||
value: unknown,
|
||||
onLinkTitle: (title: string) => void,
|
||||
): ArticleMark[] | null {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > 4) return null;
|
||||
const seen = new Set<string>();
|
||||
@@ -77,10 +87,31 @@ function parseMarks(value: unknown): ArticleMark[] | null {
|
||||
!isRecord(candidate.attrs)
|
||||
)
|
||||
return null;
|
||||
if (!exactKeys(candidate.attrs, ['href'])) return null;
|
||||
if (
|
||||
!exactKeys(candidate.attrs, [
|
||||
'href',
|
||||
'target',
|
||||
'rel',
|
||||
'class',
|
||||
'title',
|
||||
]) ||
|
||||
!nullableBoundedString(candidate.attrs.target, 100) ||
|
||||
!nullableBoundedString(candidate.attrs.rel, 500) ||
|
||||
!nullableBoundedString(candidate.attrs.class, 500) ||
|
||||
!nullableBoundedString(candidate.attrs.title, 500)
|
||||
)
|
||||
return null;
|
||||
const href = safeLink(candidate.attrs.href);
|
||||
if (!href) return null;
|
||||
marks.push({ type: 'link', attrs: { href } });
|
||||
const title =
|
||||
typeof candidate.attrs.title === 'string'
|
||||
? candidate.attrs.title.normalize('NFKC').trim()
|
||||
: '';
|
||||
if (title) onLinkTitle(title);
|
||||
marks.push({
|
||||
type: 'link',
|
||||
attrs: { href, ...(title ? { title } : {}) },
|
||||
});
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
@@ -134,7 +165,9 @@ export function parseArticleDocument(value: unknown): {
|
||||
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
|
||||
textLength += [...text].length;
|
||||
if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null;
|
||||
const marks = parseMarks(candidate.marks);
|
||||
const marks = parseMarks(candidate.marks, (title) =>
|
||||
textParts.push(title),
|
||||
);
|
||||
if (!marks) return null;
|
||||
textParts.push(text);
|
||||
return marks.length ? { type, text, marks } : { type, text };
|
||||
@@ -211,6 +244,31 @@ export function parseArticleDocument(value: unknown): {
|
||||
)
|
||||
return null;
|
||||
attrs = { level: Number(candidate.attrs.level) };
|
||||
} else if (type === 'orderedList' && candidate.attrs !== undefined) {
|
||||
if (
|
||||
!isRecord(candidate.attrs) ||
|
||||
!exactKeys(candidate.attrs, ['start', 'type'])
|
||||
)
|
||||
return null;
|
||||
const start = candidate.attrs.start ?? 1;
|
||||
const listType = candidate.attrs.type;
|
||||
if (
|
||||
!Number.isInteger(start) ||
|
||||
Number(start) < 1 ||
|
||||
Number(start) > 1_000_000 ||
|
||||
(listType !== null &&
|
||||
listType !== undefined &&
|
||||
(typeof listType !== 'string' ||
|
||||
!['1', 'a', 'A', 'i', 'I'].includes(listType)))
|
||||
)
|
||||
return null;
|
||||
const canonicalType =
|
||||
typeof listType === 'string' && listType !== '1' ? listType : undefined;
|
||||
attrs = {
|
||||
...(Number(start) === 1 ? {} : { start: Number(start) }),
|
||||
...(canonicalType ? { type: canonicalType } : {}),
|
||||
};
|
||||
if (!Object.keys(attrs).length) attrs = undefined;
|
||||
} else if (candidate.attrs !== undefined) {
|
||||
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
|
||||
return null;
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('summarizeLoadout', () => {
|
||||
it('returns an empty deterministic summary for a partial draft', () => {
|
||||
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
||||
stats: [],
|
||||
abilityStats: [],
|
||||
activeBonuses: [],
|
||||
equippedTalents: [],
|
||||
});
|
||||
@@ -214,6 +215,108 @@ describe('summarizeLoadout', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
|
||||
const draft = createDraft();
|
||||
draft.chips[0] = {
|
||||
setId: 'chip-set',
|
||||
quality: 'Superior',
|
||||
primary: { statId: 'skill-duration', value: 12 },
|
||||
secondary: [
|
||||
{ statId: 'agent-stat', value: 4 },
|
||||
{ statId: 'other-secondary', value: 6 },
|
||||
],
|
||||
};
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
|
||||
attribute('agent-stat', 'Agent Stat', { unit: '%' }),
|
||||
attribute('other-secondary', 'Other Secondary', { unit: '%' }),
|
||||
],
|
||||
chipSets: [
|
||||
{
|
||||
id: 'chip-set',
|
||||
name: 'Signal',
|
||||
specialization_ability_id: 'ability-1',
|
||||
twoPiece: null,
|
||||
talent: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'skill-duration')).toBeUndefined();
|
||||
expect(stat(summary, 'agent-stat')?.value).toBe(4);
|
||||
expect(stat(summary, 'other-secondary')?.value).toBe(6);
|
||||
expect(summary.abilityStats).toEqual([
|
||||
{
|
||||
abilityId: 'ability-1',
|
||||
stats: [
|
||||
{
|
||||
id: 'skill-duration',
|
||||
value: 12,
|
||||
sourceCount: 1,
|
||||
sources: [
|
||||
{
|
||||
source: 'Skill Chip 1 primary',
|
||||
chipIndex: 0,
|
||||
chipSetId: 'chip-set',
|
||||
value: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 10],
|
||||
[1, 20],
|
||||
[2, 30],
|
||||
[3, 40],
|
||||
])(
|
||||
'scales a three-chip 2-piece bonus with %i High-end chips',
|
||||
(highEndCount, expectedValue) => {
|
||||
const draft = createDraft();
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
draft.chips[index].setId = 'chip-set';
|
||||
draft.chips[index].quality =
|
||||
index < highEndCount ? 'High-end' : 'Standard';
|
||||
}
|
||||
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
attributes: [
|
||||
attribute('skill-damage', 'Skill Damage', { unit: '%' }),
|
||||
],
|
||||
chipSets: [
|
||||
{
|
||||
id: 'chip-set',
|
||||
name: 'Signal',
|
||||
specialization_ability_id: 'ability',
|
||||
twoPiece: {
|
||||
stat_attribute_id: 'skill-damage',
|
||||
min_value: 10,
|
||||
max_value: 40,
|
||||
},
|
||||
talent: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stat(summary, 'skill-damage')?.value).toBe(expectedValue);
|
||||
expect(summary.activeBonuses[0]).toMatchObject({
|
||||
count: 3,
|
||||
value: expectedValue,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('scales Protocol primary stats and Talent descriptions independently', () => {
|
||||
const draft = createDraft();
|
||||
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
|
||||
|
||||
@@ -7,6 +7,10 @@ export type LoadoutCatalog = {
|
||||
brands: any[];
|
||||
weapons: any[];
|
||||
talents: any[];
|
||||
skillChipProfile?: {
|
||||
primaryOptions: any[];
|
||||
secondaryOptions: any[];
|
||||
};
|
||||
chipSets: any[];
|
||||
protocols: any[];
|
||||
specializations: any[];
|
||||
@@ -14,6 +18,20 @@ export type LoadoutCatalog = {
|
||||
|
||||
export type LoadoutSummary = {
|
||||
stats: Array<{ id: string; value: number; sourceCount: number }>;
|
||||
abilityStats: Array<{
|
||||
abilityId: string;
|
||||
stats: Array<{
|
||||
id: string;
|
||||
value: number;
|
||||
sourceCount: number;
|
||||
sources: Array<{
|
||||
source: string;
|
||||
chipIndex: number;
|
||||
chipSetId: string;
|
||||
value: number;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
activeBonuses: any[];
|
||||
equippedTalents: any[];
|
||||
};
|
||||
@@ -139,6 +157,55 @@ export function summarizeLoadout(
|
||||
const brandCounts = new Map<string, number>();
|
||||
const activeBonuses: any[] = [];
|
||||
const equippedTalents: any[] = [];
|
||||
const abilityValues = new Map<string, Map<string, number>>();
|
||||
const abilitySources = new Map<
|
||||
string,
|
||||
Map<
|
||||
string,
|
||||
Array<{
|
||||
source: string;
|
||||
chipIndex: number;
|
||||
chipSetId: string;
|
||||
value: number;
|
||||
}>
|
||||
>
|
||||
>();
|
||||
const addAbilityStat = (
|
||||
abilityId: string,
|
||||
statId: string,
|
||||
value: unknown,
|
||||
chipIndex: number,
|
||||
chipSetId: string,
|
||||
) => {
|
||||
if (
|
||||
!abilityId ||
|
||||
!statId ||
|
||||
!catalogAttribute(catalog, statId) ||
|
||||
!Number.isFinite(Number(value))
|
||||
)
|
||||
return;
|
||||
if (!abilityValues.has(abilityId)) abilityValues.set(abilityId, new Map());
|
||||
const valuesForAbility = abilityValues.get(abilityId)!;
|
||||
valuesForAbility.set(
|
||||
statId,
|
||||
applyAttributeValue(
|
||||
catalog,
|
||||
valuesForAbility.get(statId) ?? 0,
|
||||
statId,
|
||||
Number(value),
|
||||
),
|
||||
);
|
||||
if (!abilitySources.has(abilityId))
|
||||
abilitySources.set(abilityId, new Map());
|
||||
const sourcesForAbility = abilitySources.get(abilityId)!;
|
||||
if (!sourcesForAbility.has(statId)) sourcesForAbility.set(statId, []);
|
||||
sourcesForAbility.get(statId)!.push({
|
||||
source: `Skill Chip ${chipIndex + 1} primary`,
|
||||
chipIndex,
|
||||
chipSetId,
|
||||
value: Number(value),
|
||||
});
|
||||
};
|
||||
|
||||
for (const slot of gearSlots) {
|
||||
const selection = draft.gear[slot];
|
||||
@@ -259,17 +326,36 @@ export function summarizeLoadout(
|
||||
string,
|
||||
{ count: number; highEnd: number; item: any }
|
||||
>();
|
||||
const validChipRoll = (
|
||||
kind: 'primaryOptions' | 'secondaryOptions',
|
||||
quality: string,
|
||||
roll: { statId: string; value: number },
|
||||
) => {
|
||||
if (!catalog.skillChipProfile) return true;
|
||||
const range = catalog.skillChipProfile[kind].find(
|
||||
(option) => option.statId === roll.statId,
|
||||
)?.ranges?.[quality];
|
||||
return (
|
||||
range &&
|
||||
roll.value >= Number(range.min) &&
|
||||
roll.value <= Number(range.max)
|
||||
);
|
||||
};
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const selection = draft.chips[index];
|
||||
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
||||
if (!item) continue;
|
||||
add(
|
||||
selection.primary.statId,
|
||||
selection.primary.value,
|
||||
`Skill Chip ${index + 1} primary`,
|
||||
);
|
||||
if (validChipRoll('primaryOptions', selection.quality, selection.primary))
|
||||
addAbilityStat(
|
||||
String(item.specialization_ability_id ?? ''),
|
||||
selection.primary.statId,
|
||||
selection.primary.value,
|
||||
index,
|
||||
String(item.id),
|
||||
);
|
||||
for (const roll of selection.secondary)
|
||||
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||
if (validChipRoll('secondaryOptions', selection.quality, roll))
|
||||
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
|
||||
group.count += 1;
|
||||
if (selection.quality === 'High-end') group.highEnd += 1;
|
||||
@@ -390,6 +476,28 @@ export function summarizeLoadout(
|
||||
catalogAttribute(catalog, right.id)?.name ?? '',
|
||||
),
|
||||
),
|
||||
abilityStats: [...abilityValues].map(([abilityId, abilityStatValues]) => ({
|
||||
abilityId,
|
||||
stats: [...abilityStatValues]
|
||||
.map(([id, value]) => {
|
||||
const statSources = abilitySources.get(abilityId)?.get(id) ?? [];
|
||||
return {
|
||||
id,
|
||||
value,
|
||||
sourceCount: statSources.length,
|
||||
sources: statSources,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
|
||||
catalogAttribute(catalog, right.id)?.category ?? '',
|
||||
) ||
|
||||
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
|
||||
catalogAttribute(catalog, right.id)?.name ?? '',
|
||||
),
|
||||
),
|
||||
})),
|
||||
activeBonuses,
|
||||
equippedTalents,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createDraft, parseLoadoutDraft } from './loadout.js';
|
||||
import {
|
||||
createDraft,
|
||||
parseLoadoutDraft,
|
||||
skillChipSecondaryCapacity,
|
||||
type SkillChipQuality,
|
||||
} from './loadout.js';
|
||||
|
||||
const copyDraft = () => structuredClone(createDraft());
|
||||
|
||||
@@ -9,6 +14,13 @@ describe('parseLoadoutDraft', () => {
|
||||
const parsed = parseLoadoutDraft(draft);
|
||||
|
||||
expect(parsed).toEqual(draft);
|
||||
expect(parsed?.version).toBe(5);
|
||||
expect(parsed?.chips[0]).toEqual({
|
||||
setId: '',
|
||||
quality: 'Standard',
|
||||
primary: { statId: '', value: 0 },
|
||||
secondary: [],
|
||||
});
|
||||
expect(parsed?.calculationMode).toBe('maximum');
|
||||
expect(parsed).not.toBe(draft);
|
||||
parsed!.gear.Holster.level = 1;
|
||||
@@ -23,13 +35,61 @@ describe('parseLoadoutDraft', () => {
|
||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
|
||||
it('rejects the retired version 4 schema', () => {
|
||||
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||
retired.version = 4;
|
||||
|
||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a version 5 draft that does not contain exactly nine chips', () => {
|
||||
const draft = copyDraft();
|
||||
draft.chips.pop();
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Standard', 0],
|
||||
['Specialized', 1],
|
||||
['Superior', 2],
|
||||
['High-end', 2],
|
||||
] as const)(
|
||||
'accepts %s chips with exactly %i secondary rolls',
|
||||
(quality, capacity) => {
|
||||
const draft = copyDraft();
|
||||
draft.chips[0].quality = quality;
|
||||
draft.chips[0].primary = { statId: 'primary', value: 10 };
|
||||
draft.chips[0].secondary = Array.from(
|
||||
{ length: capacity },
|
||||
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
|
||||
);
|
||||
|
||||
expect(skillChipSecondaryCapacity[quality]).toBe(capacity);
|
||||
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['Standard', 1],
|
||||
['Specialized', 0],
|
||||
['Specialized', 2],
|
||||
['Superior', 1],
|
||||
['High-end', 3],
|
||||
] as Array<[SkillChipQuality, number]>)(
|
||||
'rejects %s chips with %i secondary rolls',
|
||||
(quality, capacity) => {
|
||||
const draft = copyDraft();
|
||||
draft.chips[0].quality = quality;
|
||||
draft.chips[0].secondary = Array.from(
|
||||
{ length: capacity },
|
||||
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
|
||||
);
|
||||
|
||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a draft missing the unified combat profile', () => {
|
||||
const draft = copyDraft() as unknown as Record<string, unknown>;
|
||||
delete draft.combatProfile;
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
export type Roll = { statId: string; value: number };
|
||||
export const skillChipQualities = [
|
||||
'Standard',
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
export type SkillChipQuality = (typeof skillChipQualities)[number];
|
||||
export const skillChipSecondaryCapacity: Record<SkillChipQuality, number> = {
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
};
|
||||
export type GearSelection = {
|
||||
itemId: string;
|
||||
level: number;
|
||||
@@ -26,9 +39,9 @@ export type WeaponSelection = {
|
||||
};
|
||||
export type ChipSelection = {
|
||||
setId: string;
|
||||
quality: 'Superior' | 'High-end';
|
||||
quality: SkillChipQuality;
|
||||
primary: Roll;
|
||||
secondary: [Roll, Roll];
|
||||
secondary: Roll[];
|
||||
};
|
||||
export type ProtocolSelection = {
|
||||
itemId: string;
|
||||
@@ -68,7 +81,7 @@ export type SpecializationSelection = {
|
||||
nodeRanks: Record<string, number>;
|
||||
};
|
||||
export type LoadoutDraft = {
|
||||
version: 4;
|
||||
version: 5;
|
||||
buildName: string;
|
||||
calculationMode: CalculationMode;
|
||||
gear: Record<string, GearSelection>;
|
||||
@@ -127,9 +140,9 @@ export const blankWeapon = (): WeaponSelection => ({
|
||||
});
|
||||
export const blankChip = (): ChipSelection => ({
|
||||
setId: '',
|
||||
quality: 'Superior',
|
||||
quality: 'Standard',
|
||||
primary: blankRoll(),
|
||||
secondary: [blankRoll(), blankRoll()],
|
||||
secondary: [],
|
||||
});
|
||||
export const defaultCombatProfile = (): CombatProfile => ({
|
||||
encounterSeconds: 60,
|
||||
@@ -148,7 +161,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
|
||||
extraHealthActive: false,
|
||||
});
|
||||
export const createDraft = (): LoadoutDraft => ({
|
||||
version: 4,
|
||||
version: 5,
|
||||
buildName: 'Untitled loadout',
|
||||
calculationMode: 'maximum',
|
||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
||||
@@ -242,9 +255,12 @@ const isChipSelection = (value: unknown): value is ChipSelection =>
|
||||
isRecord(value) &&
|
||||
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
||||
typeof value.setId === 'string' &&
|
||||
(value.quality === 'Superior' || value.quality === 'High-end') &&
|
||||
skillChipQualities.some((quality) => value.quality === quality) &&
|
||||
isRoll(value.primary) &&
|
||||
isRollPair(value.secondary);
|
||||
Array.isArray(value.secondary) &&
|
||||
value.secondary.length ===
|
||||
skillChipSecondaryCapacity[value.quality as SkillChipQuality] &&
|
||||
value.secondary.every(isRoll);
|
||||
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
||||
isRecord(value) &&
|
||||
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
||||
@@ -353,7 +369,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
||||
'skillActivations',
|
||||
'specialization',
|
||||
]) ||
|
||||
value.version !== 4 ||
|
||||
value.version !== 5 ||
|
||||
typeof value.buildName !== 'string' ||
|
||||
!['maximum', 'custom'].includes(String(value.calculationMode))
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
currentHealthPercentBandPredicate,
|
||||
eventFieldComparisonPredicate,
|
||||
mechanicTargetingForMode,
|
||||
mechanicTargetingMode,
|
||||
parseConditionalMechanicDraft,
|
||||
readCurrentHealthPercentBand,
|
||||
readEventFieldComparison,
|
||||
@@ -200,6 +202,110 @@ describe('conditional mechanic authoring payload', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes assigned-ability activity for kill and assist event conditions', () => {
|
||||
expect(registeredEventFields('enemy_killed')).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'values.assignedAbilityActive',
|
||||
label: 'Assigned ability active',
|
||||
type: 'boolean',
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(registeredEventFields('assist')).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'values.assignedAbilityActive',
|
||||
label: 'Assigned ability active',
|
||||
type: 'boolean',
|
||||
},
|
||||
]),
|
||||
);
|
||||
const value = draft();
|
||||
value.clauses[0].event = 'enemy_killed';
|
||||
value.clauses[0].recipeKey = undefined;
|
||||
value.clauses[0].reducer = { kind: 'none' };
|
||||
value.clauses[0].if = eventFieldComparisonPredicate({
|
||||
path: 'values.assignedAbilityActive',
|
||||
operator: 'eq',
|
||||
value: true,
|
||||
});
|
||||
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.programs.I.clauses[0]).toMatchObject({
|
||||
when: 'enemy_killed',
|
||||
if: value.clauses[0].if,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses deterministic outcome and clause proc chances with ability mutations', () => {
|
||||
const value = draft();
|
||||
value.clauses[0].event = 'skill_hit';
|
||||
value.clauses[0].recipeKey = undefined;
|
||||
value.clauses[0].procChancePercent = 10;
|
||||
value.clauses[0].requiresAssignedAbilityActive = true;
|
||||
value.clauses[0].outcomes[0].probabilityPercent = 29;
|
||||
value.clauses[0].outcomes[0].statePool = undefined;
|
||||
value.clauses[0].outcomes[0].perStack = false;
|
||||
value.pools = [
|
||||
{
|
||||
key: 'extension-stacks',
|
||||
label: 'Extension stacks',
|
||||
stateKind: 'stack',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 8,
|
||||
},
|
||||
{
|
||||
key: 'extension-cooldown',
|
||||
label: 'Extension cooldown',
|
||||
stateKind: 'flag',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 15,
|
||||
},
|
||||
];
|
||||
value.clauses[0].mutations = [
|
||||
{ kind: 'add', pool: 'extension-stacks', value: 1 },
|
||||
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
|
||||
{
|
||||
kind: 'start_cooldown_on_max',
|
||||
stackPool: 'extension-stacks',
|
||||
cooldownPool: 'extension-cooldown',
|
||||
},
|
||||
];
|
||||
const result = parseConditionalMechanicDraft(value, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.programs.I.clauses[0]).toMatchObject({
|
||||
procChancePercent: 10,
|
||||
requiresAssignedAbilityActive: true,
|
||||
outcomes: [{ probabilityPercent: 29 }],
|
||||
});
|
||||
expect(result.value.programs.I.clauses[0].mutations).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
|
||||
{
|
||||
kind: 'start_cooldown_on_max',
|
||||
stackPool: 'extension-stacks',
|
||||
cooldownPool: 'extension-cooldown',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unregistered Extra Health event field deterministically', () => {
|
||||
const value = draft();
|
||||
value.clauses[0].event = 'extra_health_changed';
|
||||
@@ -735,4 +841,125 @@ describe('conditional mechanic authoring payload', () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes aiming and round-trips event-target explosion editor state', () => {
|
||||
expect(registeredEventFields('weapon_hit')).toContainEqual({
|
||||
path: 'values.aiming',
|
||||
label: 'Aiming down sights',
|
||||
type: 'boolean',
|
||||
});
|
||||
const targeting = mechanicTargetingForMode('event_target_explosion');
|
||||
expect(targeting).toEqual({
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
});
|
||||
expect(mechanicTargetingMode(targeting)).toBe('event_target_explosion');
|
||||
expect(mechanicTargetingForMode('none', targeting)).toBeUndefined();
|
||||
|
||||
const valid = draft();
|
||||
valid.clauses[0].if = eventFieldComparisonPredicate({
|
||||
path: 'values.aiming',
|
||||
operator: 'eq',
|
||||
value: true,
|
||||
});
|
||||
valid.clauses[0].outcomes[0] = {
|
||||
...valid.clauses[0].outcomes[0],
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
phase: 'after_event',
|
||||
attributeId: undefined,
|
||||
targeting,
|
||||
};
|
||||
const parsed = parseConditionalMechanicDraft(valid, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
});
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (parsed.ok) {
|
||||
expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual(
|
||||
targeting,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects incomplete, mis-scoped, and non-event area damage', () => {
|
||||
for (const configure of [
|
||||
(candidate: ConditionalMechanicDraftV1) => {
|
||||
candidate.clauses[0].outcomes[0].targeting = {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 0,
|
||||
};
|
||||
},
|
||||
(candidate: ConditionalMechanicDraftV1) => {
|
||||
candidate.clauses[0].outcomes[0].scope = 'self';
|
||||
},
|
||||
(candidate: ConditionalMechanicDraftV1) => {
|
||||
candidate.clauses[0].outcomes[0].phase = 'while_active';
|
||||
},
|
||||
(candidate: ConditionalMechanicDraftV1) => {
|
||||
candidate.clauses[0].event = 'enemy_killed';
|
||||
},
|
||||
]) {
|
||||
const invalid = draft();
|
||||
invalid.clauses[0].outcomes[0] = {
|
||||
...invalid.clauses[0].outcomes[0],
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
phase: 'after_event',
|
||||
attributeId: undefined,
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
};
|
||||
configure(invalid);
|
||||
expect(
|
||||
parseConditionalMechanicDraft(invalid, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires explicit equality for optional aiming evidence', () => {
|
||||
for (const predicate of [
|
||||
{
|
||||
kind: 'compare' as const,
|
||||
left: { source: 'event' as const, path: 'values.aiming' },
|
||||
operator: 'ne' as const,
|
||||
right: true,
|
||||
},
|
||||
{
|
||||
kind: 'not' as const,
|
||||
predicate: {
|
||||
kind: 'compare' as const,
|
||||
left: { source: 'event' as const, path: 'values.aiming' },
|
||||
operator: 'eq' as const,
|
||||
right: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'compare' as const,
|
||||
left: { source: 'event' as const, path: 'values.aiming' },
|
||||
operator: 'ne' as const,
|
||||
right: { source: 'combat' as const, path: 'currentHealth' },
|
||||
},
|
||||
]) {
|
||||
const invalid = draft();
|
||||
invalid.clauses[0].if = predicate;
|
||||
expect(
|
||||
parseConditionalMechanicDraft(invalid, {
|
||||
variantKeys: ['I', 'II'],
|
||||
allowedAttributeIds: new Set(['weapon-damage']),
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
MECHANIC_EVENT_KINDS,
|
||||
MECHANIC_EVENT_REGISTRY,
|
||||
mechanicOutcomeTargetingError,
|
||||
validateMechanicProgram,
|
||||
type ClauseMutation,
|
||||
type MechanicClause,
|
||||
type MechanicEventKind,
|
||||
type MechanicOutcome,
|
||||
type MechanicOutcomeScope,
|
||||
type MechanicOutcomeTargeting,
|
||||
type MechanicProgram,
|
||||
type MechanicResource,
|
||||
type NumericExpression,
|
||||
@@ -198,6 +200,44 @@ export function registeredEventFields(event: MechanicEventKind) {
|
||||
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
||||
}
|
||||
|
||||
export type MechanicTargetingMode =
|
||||
| 'none'
|
||||
| 'allies_within_radius'
|
||||
| 'enemies_within_radius'
|
||||
| 'event_target_explosion';
|
||||
|
||||
export function mechanicTargetingMode(
|
||||
targeting: MechanicOutcomeTargeting | undefined,
|
||||
): MechanicTargetingMode {
|
||||
if (!targeting) return 'none';
|
||||
if (targeting.kind === 'event_target_area') return 'event_target_explosion';
|
||||
return targeting.population === 'allies'
|
||||
? 'allies_within_radius'
|
||||
: 'enemies_within_radius';
|
||||
}
|
||||
|
||||
export function mechanicTargetingForMode(
|
||||
mode: MechanicTargetingMode,
|
||||
current?: MechanicOutcomeTargeting,
|
||||
): MechanicOutcomeTargeting | undefined {
|
||||
if (mode === 'none') return undefined;
|
||||
const radiusMeters =
|
||||
current?.radiusMeters ?? (mode === 'event_target_explosion' ? 5 : 20);
|
||||
if (mode === 'event_target_explosion') {
|
||||
return {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'within_radius',
|
||||
population: mode === 'allies_within_radius' ? 'allies' : 'enemies',
|
||||
radiusMeters,
|
||||
};
|
||||
}
|
||||
|
||||
export type StatePoolThresholdComparison = {
|
||||
pool: string;
|
||||
operator: PredicateComparisonOperator;
|
||||
@@ -258,6 +298,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
|
||||
'maxStacks',
|
||||
'targeting',
|
||||
'descriptionSlot',
|
||||
'probabilityPercent',
|
||||
],
|
||||
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
|
||||
kind: [
|
||||
@@ -291,6 +332,25 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
|
||||
continuous:
|
||||
'Valid only for restoration. The value is the total restoration, delivered at a constant rate across durationSeconds. Each activation creates an independent stream; overlapping streams add and overheal is lost when it occurs.',
|
||||
},
|
||||
probabilitySemantics:
|
||||
'probabilityPercent is an optional inclusive 0 through 100 percentage. In custom simulation, each eligible outcome rolls independently from the evaluation random stream. Maximum mode resolves every nonzero probability as successful.',
|
||||
targeting: {
|
||||
withinRadius: {
|
||||
kind: 'within_radius',
|
||||
population: ['allies', 'enemies'],
|
||||
radiusMeters: 'positive finite number',
|
||||
semantics:
|
||||
'Legacy radius metadata. It preserves authored range but does not select or fan out runtime targets.',
|
||||
},
|
||||
eventTargetArea: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 'positive finite number',
|
||||
semantics:
|
||||
'Valid only for target-scoped direct damage in on_event or after_event phases on a weapon_hit clause. The triggering event must supply an authoritative event-target area sample. Every valid enemy at or inside the radius receives one equal damage emission, ordered by entity ID.',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
const phases = new Set<OutcomePhase>(
|
||||
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase,
|
||||
@@ -371,6 +431,10 @@ function isReference(value: unknown): value is ValueReference {
|
||||
);
|
||||
case 'equipment':
|
||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||
case 'assigned_ability':
|
||||
return (
|
||||
value.path === 'active' || value.path === 'remainingDurationSeconds'
|
||||
);
|
||||
case 'attribute':
|
||||
return (
|
||||
typeof value.attributeId === 'string' &&
|
||||
@@ -527,6 +591,17 @@ function isMutation(value: unknown): value is ClauseMutation {
|
||||
isExpression(value.value))
|
||||
);
|
||||
}
|
||||
if (value.kind === 'extend_duration') {
|
||||
return value.target === 'assigned_ability' && isExpression(value.seconds);
|
||||
}
|
||||
if (value.kind === 'start_cooldown_on_max') {
|
||||
return (
|
||||
typeof value.stackPool === 'string' &&
|
||||
Boolean(value.stackPool.trim()) &&
|
||||
typeof value.cooldownPool === 'string' &&
|
||||
Boolean(value.cooldownPool.trim())
|
||||
);
|
||||
}
|
||||
return (
|
||||
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
||||
typeof value.pool === 'string' &&
|
||||
@@ -724,30 +799,21 @@ function parseOutcome(
|
||||
) {
|
||||
errors.push(`${path} description slot must be a positive integer.`);
|
||||
}
|
||||
if (value.targeting !== undefined) {
|
||||
if (
|
||||
!object(value.targeting) ||
|
||||
value.targeting.kind !== 'within_radius' ||
|
||||
!['allies', 'enemies'].includes(String(value.targeting.population)) ||
|
||||
!finite(value.targeting.radiusMeters) ||
|
||||
value.targeting.radiusMeters <= 0
|
||||
) {
|
||||
errors.push(`${path} has invalid radius targeting.`);
|
||||
} else if (
|
||||
value.targeting.population === 'allies' &&
|
||||
value.scope !== 'allies' &&
|
||||
value.scope !== 'self_and_allies'
|
||||
) {
|
||||
errors.push(
|
||||
`${path} ally radius targeting requires allies or self-and-allies scope.`,
|
||||
);
|
||||
} else if (
|
||||
value.targeting.population === 'enemies' &&
|
||||
value.scope !== 'target'
|
||||
) {
|
||||
errors.push(`${path} enemy radius targeting requires target scope.`);
|
||||
}
|
||||
if (
|
||||
value.probabilityPercent !== undefined &&
|
||||
(!finite(value.probabilityPercent) ||
|
||||
value.probabilityPercent < 0 ||
|
||||
value.probabilityPercent > 100)
|
||||
) {
|
||||
errors.push(`${path} probability must be from 0 through 100 percent.`);
|
||||
}
|
||||
const targetingError = mechanicOutcomeTargetingError({
|
||||
kind,
|
||||
scope: value.scope as MechanicOutcomeScope,
|
||||
phase: value.phase as OutcomePhase,
|
||||
targeting: value.targeting,
|
||||
});
|
||||
if (targetingError) errors.push(`${path} ${targetingError}`);
|
||||
if (!object(value.variants)) {
|
||||
errors.push(`${path} variants must be an object.`);
|
||||
return null;
|
||||
@@ -828,6 +894,22 @@ function parseClauses(
|
||||
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
|
||||
errors.push(`${path} has an invalid weight expression.`);
|
||||
}
|
||||
if (
|
||||
candidate.procChancePercent !== undefined &&
|
||||
(!finite(candidate.procChancePercent) ||
|
||||
candidate.procChancePercent < 0 ||
|
||||
candidate.procChancePercent > 100)
|
||||
) {
|
||||
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
|
||||
}
|
||||
if (
|
||||
candidate.requiresAssignedAbilityActive !== undefined &&
|
||||
typeof candidate.requiresAssignedAbilityActive !== 'boolean'
|
||||
) {
|
||||
errors.push(
|
||||
`${path} assigned-ability activity requirement must be a boolean.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
candidate.cooldownSeconds !== undefined &&
|
||||
(!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
|
||||
@@ -902,6 +984,15 @@ function programsForDraft(
|
||||
? {}
|
||||
: { cooldownSeconds: clause.cooldownSeconds }),
|
||||
...(clause.weight === undefined ? {} : { weight: clause.weight }),
|
||||
...(clause.procChancePercent === undefined
|
||||
? {}
|
||||
: { procChancePercent: clause.procChancePercent }),
|
||||
...(clause.requiresAssignedAbilityActive === undefined
|
||||
? {}
|
||||
: {
|
||||
requiresAssignedAbilityActive:
|
||||
clause.requiresAssignedAbilityActive,
|
||||
}),
|
||||
mutations: clause.mutations,
|
||||
outcomes: clause.outcomes.map(
|
||||
({
|
||||
|
||||
@@ -200,6 +200,143 @@ describe('conditional-effect persistence adapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves event-target area damage semantics on an outcome', () => {
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'weapon_hit',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]',
|
||||
}),
|
||||
],
|
||||
outcomes: [
|
||||
outcome({
|
||||
application_kind: 'direct_damage',
|
||||
effect_scope: 'target',
|
||||
stat_attribute_id: null,
|
||||
application_phase: 'after_event',
|
||||
state_pool_id: null,
|
||||
targeting_json: JSON.stringify({
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
{ name: 'Critical Explosion', variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
phase: 'after_event',
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('adapts assigned-ability activity predicates for kill and assist events', () => {
|
||||
for (const eventKind of ['enemy_killed', 'assist'] as const) {
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: eventKind,
|
||||
parameters_json: '{}',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]',
|
||||
conditions_json: JSON.stringify({
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'event',
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
outcomes: [outcome({ state_pool_id: null })],
|
||||
triggers: [],
|
||||
},
|
||||
{ variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'READY', errors: [] });
|
||||
expect(result.program?.clauses[0]).toMatchObject({
|
||||
when: eventKind,
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'event',
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves outcome probability metadata', () => {
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
rule: rule(),
|
||||
statePools: [],
|
||||
clauses: [
|
||||
clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'shot_fired',
|
||||
recipe_parameters_json: JSON.stringify({
|
||||
procChancePercent: 10,
|
||||
requiresAssignedAbilityActive: true,
|
||||
}),
|
||||
parameters_json: '{}',
|
||||
state_pool_id: null,
|
||||
mutations_json: '[]',
|
||||
}),
|
||||
],
|
||||
outcomes: [
|
||||
outcome({
|
||||
state_pool_id: null,
|
||||
application_kind: 'restore_ammo',
|
||||
effect_scope: 'self',
|
||||
stat_attribute_id: null,
|
||||
resource_kind: 'ammo',
|
||||
probability_percent: 29,
|
||||
}),
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
{ variantKey: 'I' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'READY', errors: [] });
|
||||
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
probabilityPercent: 29,
|
||||
resource: 'ammo',
|
||||
});
|
||||
expect(result.program?.clauses[0]).toMatchObject({
|
||||
procChancePercent: 10,
|
||||
requiresAssignedAbilityActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads continuous restoration delivery metadata', () => {
|
||||
const result = adaptConditionalEffectProgram(
|
||||
{
|
||||
|
||||
@@ -54,6 +54,7 @@ export type ConditionalEffectClauseRow = {
|
||||
event_kind?: DatabaseValue;
|
||||
recipe_key?: DatabaseValue;
|
||||
parameters_json: DatabaseValue;
|
||||
recipe_parameters_json?: DatabaseValue;
|
||||
conditions_json: DatabaseValue;
|
||||
reducer_json: DatabaseValue;
|
||||
weight_expression_json?: DatabaseValue;
|
||||
@@ -85,6 +86,7 @@ export type ConditionalEffectOutcomeRow = {
|
||||
delivery_mode?: DatabaseValue;
|
||||
delivery_duration_seconds?: DatabaseValue;
|
||||
targeting_json?: DatabaseValue;
|
||||
probability_percent?: DatabaseValue;
|
||||
value_scaling: DatabaseValue;
|
||||
unit: DatabaseValue;
|
||||
damage_basis: DatabaseValue;
|
||||
@@ -205,6 +207,9 @@ function isValueReference(value: unknown) {
|
||||
if (value.source === 'event' || value.source === 'equipment') {
|
||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||
}
|
||||
if (value.source === 'assigned_ability') {
|
||||
return value.path === 'active' || value.path === 'remainingDurationSeconds';
|
||||
}
|
||||
if (value.source === 'combat') {
|
||||
return (
|
||||
typeof value.path === 'string' &&
|
||||
@@ -313,6 +318,19 @@ function isMutation(value: unknown): value is ClauseMutation {
|
||||
isNumericExpression(value.value))
|
||||
);
|
||||
}
|
||||
if (value.kind === 'extend_duration') {
|
||||
return (
|
||||
value.target === 'assigned_ability' && isNumericExpression(value.seconds)
|
||||
);
|
||||
}
|
||||
if (value.kind === 'start_cooldown_on_max') {
|
||||
return (
|
||||
typeof value.stackPool === 'string' &&
|
||||
Boolean(value.stackPool.trim()) &&
|
||||
typeof value.cooldownPool === 'string' &&
|
||||
Boolean(value.cooldownPool.trim())
|
||||
);
|
||||
}
|
||||
return (
|
||||
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
|
||||
typeof value.pool === 'string' &&
|
||||
@@ -731,6 +749,17 @@ function parseOutcome(
|
||||
if (delivery === 'instant' && durationSeconds !== null) {
|
||||
errors.push(`${path} instant delivery cannot set a duration.`);
|
||||
}
|
||||
const probabilityPercent = number(row.probability_percent);
|
||||
if (
|
||||
row.probability_percent !== undefined &&
|
||||
row.probability_percent !== null &&
|
||||
row.probability_percent !== '' &&
|
||||
(probabilityPercent === null ||
|
||||
probabilityPercent < 0 ||
|
||||
probabilityPercent > 100)
|
||||
) {
|
||||
errors.push(`${path} probability must be from 0 through 100 percent.`);
|
||||
}
|
||||
let targeting: MechanicOutcome['targeting'] | undefined;
|
||||
if (
|
||||
row.targeting_json !== null &&
|
||||
@@ -766,6 +795,7 @@ function parseOutcome(
|
||||
? { delivery, durationSeconds }
|
||||
: {}),
|
||||
...(targeting ? { targeting } : {}),
|
||||
...(probabilityPercent === null ? {} : { probabilityPercent }),
|
||||
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
|
||||
? { attributeId: text(row.stat_attribute_id) }
|
||||
: {}),
|
||||
@@ -903,7 +933,7 @@ export function adaptConditionalEffectProgram(
|
||||
continue;
|
||||
}
|
||||
const parametersJson = parseJson(
|
||||
row.parameters_json,
|
||||
row.recipe_parameters_json ?? row.parameters_json,
|
||||
`${path} parameters`,
|
||||
errors,
|
||||
);
|
||||
@@ -976,6 +1006,25 @@ export function adaptConditionalEffectProgram(
|
||||
}
|
||||
}
|
||||
const recipeKey = nullableText(row.recipe_key);
|
||||
const procChancePercent = parametersJson.value.procChancePercent;
|
||||
if (
|
||||
procChancePercent !== undefined &&
|
||||
(!finite(procChancePercent) ||
|
||||
procChancePercent < 0 ||
|
||||
procChancePercent > 100)
|
||||
) {
|
||||
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
|
||||
}
|
||||
const requiresAssignedAbilityActive =
|
||||
parametersJson.value.requiresAssignedAbilityActive;
|
||||
if (
|
||||
requiresAssignedAbilityActive !== undefined &&
|
||||
typeof requiresAssignedAbilityActive !== 'boolean'
|
||||
) {
|
||||
errors.push(
|
||||
`${path} assigned-ability activity requirement must be a boolean.`,
|
||||
);
|
||||
}
|
||||
clauses.push({
|
||||
id,
|
||||
label: text(row.label) || text(row.clause_key) || undefined,
|
||||
@@ -989,6 +1038,10 @@ export function adaptConditionalEffectProgram(
|
||||
? {}
|
||||
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
|
||||
...(weight === undefined ? {} : { weight }),
|
||||
...(typeof procChancePercent === 'number' ? { procChancePercent } : {}),
|
||||
...(typeof requiresAssignedAbilityActive === 'boolean'
|
||||
? { requiresAssignedAbilityActive }
|
||||
: {}),
|
||||
mutations: mutationsJson.value,
|
||||
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}),
|
||||
});
|
||||
|
||||
@@ -19,6 +19,276 @@ const custom = (
|
||||
inputs,
|
||||
});
|
||||
|
||||
describe('deterministic mechanic probabilities and assigned abilities', () => {
|
||||
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'ammo-refill',
|
||||
name: 'Ammo Refill',
|
||||
pools: [
|
||||
{
|
||||
key: 'tactical-link-window',
|
||||
label: 'Tactical Link window',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 6,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'start-window',
|
||||
when: 'signature_ability_deployed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'refresh-window',
|
||||
when: 'enemy_killed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'recover-ammo',
|
||||
when: 'shot_fired',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'tactical-link-window' },
|
||||
operator: 'eq',
|
||||
right: 1,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'recover-one-ammo',
|
||||
label: 'Recover one ammo',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
resource: 'ammo',
|
||||
value: 1,
|
||||
probabilityPercent: 29,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const events = [
|
||||
{
|
||||
kind: 'signature_ability_deployed' as const,
|
||||
at: 0,
|
||||
values: { assignedAbilityDurationSeconds: 6 },
|
||||
},
|
||||
{ kind: 'shot_fired' as const, at: 1 },
|
||||
{ kind: 'shot_fired' as const, at: 2 },
|
||||
{ kind: 'enemy_killed' as const, at: 5 },
|
||||
{ kind: 'shot_fired' as const, at: 6 },
|
||||
{ kind: 'shot_fired' as const, at: 11 },
|
||||
];
|
||||
const first = evaluateMechanicProgram(
|
||||
program,
|
||||
events,
|
||||
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
|
||||
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
|
||||
);
|
||||
const second = evaluateMechanicProgram(
|
||||
program,
|
||||
events,
|
||||
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
|
||||
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
|
||||
);
|
||||
|
||||
expect(first.status).toBe('OK');
|
||||
expect(first.randomSeed).toBe(1);
|
||||
expect(first.outcomes.map((outcome) => outcome.at)).toEqual([2]);
|
||||
expect(first.outcomes[0]).toMatchObject({
|
||||
probabilityPercent: 29,
|
||||
probabilityRoll: expect.any(Number),
|
||||
appliedValue: 1,
|
||||
});
|
||||
expect(first.finalCombat?.currentAmmo).toBe(6);
|
||||
expect(second.outcomes).toEqual(first.outcomes);
|
||||
});
|
||||
|
||||
it('gates a proc before mutations, extends a live assigned ability, and starts cooldown on the eighth stack', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'tactical-loop',
|
||||
name: 'Tactical Loop',
|
||||
pools: [
|
||||
{
|
||||
key: 'extension-stacks',
|
||||
label: 'Extension stacks',
|
||||
scope: 'agent',
|
||||
initial: 7,
|
||||
min: 0,
|
||||
max: 8,
|
||||
},
|
||||
{
|
||||
key: 'extension-cooldown',
|
||||
label: 'Extension cooldown',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 15,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'extend-tactical-link',
|
||||
when: 'skill_hit',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'pool', pool: 'extension-cooldown' },
|
||||
operator: 'eq',
|
||||
right: 0,
|
||||
},
|
||||
requiresAssignedAbilityActive: true,
|
||||
procChancePercent: 10,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{ kind: 'add', pool: 'extension-stacks', value: 1 },
|
||||
{
|
||||
kind: 'extend_duration',
|
||||
target: 'assigned_ability',
|
||||
seconds: 0.3,
|
||||
},
|
||||
{
|
||||
kind: 'start_cooldown_on_max',
|
||||
stackPool: 'extension-stacks',
|
||||
cooldownPool: 'extension-cooldown',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const success = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'signature_ability_deployed',
|
||||
at: 0,
|
||||
values: { assignedAbilityDurationSeconds: 6 },
|
||||
},
|
||||
{ kind: 'skill_hit', at: 5.9 },
|
||||
{ kind: 'skill_hit', at: 6.1 },
|
||||
],
|
||||
{ mode: 'custom', encounterSeconds: 7, randomSeed: 7 },
|
||||
);
|
||||
const failed = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'signature_ability_deployed',
|
||||
at: 0,
|
||||
values: { assignedAbilityDurationSeconds: 6 },
|
||||
},
|
||||
{ kind: 'skill_hit', at: 5.9 },
|
||||
],
|
||||
{ mode: 'custom', encounterSeconds: 7, randomSeed: 1 },
|
||||
);
|
||||
|
||||
expect(success.status).toBe('OK');
|
||||
expect(success.emissions).toHaveLength(1);
|
||||
const extension = success.emissions[0].mutations.find(
|
||||
(mutation) => mutation.kind === 'extend_duration',
|
||||
);
|
||||
expect(extension).toMatchObject({ kind: 'extend_duration' });
|
||||
expect(extension?.before).toBeCloseTo(0.1);
|
||||
expect(extension?.after).toBeCloseTo(0.4);
|
||||
expect(success.emissions[0].mutations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: 'start_cooldown_on_max',
|
||||
pool: 'extension-cooldown',
|
||||
after: 1,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(success.finalState).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pool: 'extension-stacks',
|
||||
value: 8,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pool: 'extension-cooldown',
|
||||
value: 1,
|
||||
expiresAt: 20.9,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(failed.emissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not extend an assigned ability at its exact expiry and rejects invalid chance data', () => {
|
||||
const boundaryProgram: MechanicProgram = {
|
||||
id: 'exact-expiry',
|
||||
name: 'Exact expiry',
|
||||
clauses: [
|
||||
{
|
||||
id: 'extend',
|
||||
when: 'skill_hit',
|
||||
requiresAssignedAbilityActive: true,
|
||||
procChancePercent: 100,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{
|
||||
kind: 'extend_duration',
|
||||
target: 'assigned_ability',
|
||||
seconds: 0.3,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
boundaryProgram,
|
||||
[
|
||||
{
|
||||
kind: 'signature_ability_deployed',
|
||||
at: 0,
|
||||
values: { assignedAbilityDurationSeconds: 1 },
|
||||
},
|
||||
{ kind: 'skill_hit', at: 1 },
|
||||
],
|
||||
{ mode: 'custom', encounterSeconds: 2, randomSeed: 7 },
|
||||
).emissions,
|
||||
).toEqual([]);
|
||||
|
||||
const invalid = structuredClone(boundaryProgram);
|
||||
invalid.clauses[0].procChancePercent = 101;
|
||||
expect(validateMechanicProgram(invalid)).toMatchObject({
|
||||
ok: false,
|
||||
errors: [
|
||||
expect.stringContaining('proc chance must be from 0 through 100'),
|
||||
],
|
||||
});
|
||||
const invalidOutcome = structuredClone(boundaryProgram);
|
||||
invalidOutcome.clauses[0].outcomes = [
|
||||
{
|
||||
id: 'bad-chance',
|
||||
label: 'Bad chance',
|
||||
phase: 'after_event',
|
||||
kind: 'marker',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
value: 1,
|
||||
probabilityPercent: -1,
|
||||
},
|
||||
];
|
||||
expect(validateMechanicProgram(invalidOutcome)).toMatchObject({
|
||||
ok: false,
|
||||
errors: [
|
||||
expect.stringContaining('probability must be from 0 through 100'),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('structured mechanic runtime', () => {
|
||||
it('advertises the authoritative weapon-hit target and headshot fields', () => {
|
||||
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
|
||||
@@ -32,6 +302,11 @@ describe('structured mechanic runtime', () => {
|
||||
},
|
||||
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
|
||||
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
||||
{
|
||||
path: 'values.aiming',
|
||||
label: 'Aiming down sights',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
path: 'values.behindCover',
|
||||
label: 'Agent behind cover',
|
||||
@@ -305,6 +580,161 @@ describe('structured mechanic runtime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('matches kills and assists only while the assigned ability is active', () => {
|
||||
const weaponCriticalHitChance = 'weapon-critical-hit-chance';
|
||||
const activeOutcome = {
|
||||
id: 'tactical-link-critical-chance',
|
||||
label: 'Weapon Critical Hit Chance',
|
||||
phase: 'while_active' as const,
|
||||
kind: 'attribute_modifier' as const,
|
||||
scope: 'self' as const,
|
||||
basis: 'flat' as const,
|
||||
value: 10.2,
|
||||
attributeId: weaponCriticalHitChance,
|
||||
statePool: 'tactical-link-kill-bonus',
|
||||
};
|
||||
const activePredicate = {
|
||||
kind: 'compare' as const,
|
||||
left: {
|
||||
source: 'event' as const,
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq' as const,
|
||||
right: true,
|
||||
};
|
||||
const program: MechanicProgram = {
|
||||
id: 'tactical-link-kill-bonus',
|
||||
name: 'Tactical Link Kill Bonus',
|
||||
pools: [
|
||||
{
|
||||
key: 'tactical-link-kill-bonus',
|
||||
label: 'Tactical Link kill bonus',
|
||||
scope: 'agent',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
expiresAfterSeconds: 10,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'kill-during-tactical-link',
|
||||
when: 'enemy_killed',
|
||||
if: activePredicate,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
|
||||
],
|
||||
outcomes: [activeOutcome],
|
||||
},
|
||||
{
|
||||
id: 'assist-during-tactical-link',
|
||||
when: 'assist',
|
||||
if: activePredicate,
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [
|
||||
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
|
||||
],
|
||||
outcomes: [{ ...activeOutcome }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'enemy_killed',
|
||||
at: 0,
|
||||
values: { assignedAbilityActive: true },
|
||||
},
|
||||
{
|
||||
kind: 'assist',
|
||||
at: 4,
|
||||
values: { assignedAbilityActive: false },
|
||||
},
|
||||
{ kind: 'enemy_killed', at: 6 },
|
||||
{
|
||||
kind: 'assist',
|
||||
at: 10,
|
||||
values: { assignedAbilityActive: true },
|
||||
},
|
||||
],
|
||||
custom(19),
|
||||
{
|
||||
maxHealth: 1_000,
|
||||
currentHealth: 1_000,
|
||||
attributes: { [weaponCriticalHitChance]: 0 },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.outcomes.map((outcome) => outcome.at)).toEqual([0, 10]);
|
||||
expect(result.finalState).toContainEqual(
|
||||
expect.objectContaining({
|
||||
pool: 'tactical-link-kill-bonus',
|
||||
value: 1,
|
||||
expiresAt: 20,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid assigned-ability activity predicates and event values', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'assigned-ability-activity',
|
||||
name: 'Assigned ability activity',
|
||||
clauses: [
|
||||
{
|
||||
id: 'kill',
|
||||
when: 'enemy_killed',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'event',
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const invalidPredicate = structuredClone(program);
|
||||
invalidPredicate.clauses[0].if = {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.assignedAbilityActive' },
|
||||
operator: 'eq',
|
||||
right: 'yes',
|
||||
};
|
||||
expect(validateMechanicProgram(invalidPredicate)).toMatchObject({
|
||||
ok: false,
|
||||
errors: [
|
||||
expect.stringContaining('Assigned ability active with a boolean value'),
|
||||
],
|
||||
});
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'enemy_killed',
|
||||
at: 0,
|
||||
values: { assignedAbilityActive: 'yes' },
|
||||
} as unknown as MechanicEvent,
|
||||
],
|
||||
custom(1),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'INVALID',
|
||||
errors: [
|
||||
expect.stringContaining('Assigned ability active must be a boolean'),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('executes imported talent trigger families as first-class events', () => {
|
||||
const eventKinds: MechanicEventKind[] = [
|
||||
'ally_downed',
|
||||
@@ -2550,4 +2980,251 @@ describe('mechanic validation and pseudo-script', () => {
|
||||
expect(result.script).toContain('TO ALLIES WITHIN 40m');
|
||||
expect(result.script).toContain('TO ENEMIES WITHIN 20m');
|
||||
});
|
||||
|
||||
it('applies aimed critical-hit area damage once to each valid enemy at or inside the radius', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'aimed-critical-explosion',
|
||||
name: 'Aimed Critical Explosion',
|
||||
clauses: [
|
||||
{
|
||||
id: 'aimed-critical-hit',
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.critical' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.aiming' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'explosion',
|
||||
label: 'Explosion',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: 20,
|
||||
right: {
|
||||
ref: { source: 'attribute', attributeId: 'firepower' },
|
||||
},
|
||||
},
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
probabilityPercent: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy-b',
|
||||
values: { critical: true, aiming: true },
|
||||
areaTargetSample: {
|
||||
center: 'event_target',
|
||||
validTargets: [
|
||||
{
|
||||
entityId: 'enemy-c',
|
||||
population: 'enemy',
|
||||
distanceMeters: 5.001,
|
||||
},
|
||||
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
|
||||
{ entityId: 'ally-a', population: 'ally', distanceMeters: 2 },
|
||||
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
custom(5),
|
||||
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
|
||||
);
|
||||
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.outcomes.map((outcome) => outcome.targetId)).toEqual([
|
||||
'enemy-a',
|
||||
'enemy-b',
|
||||
]);
|
||||
expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([
|
||||
2000, 2000,
|
||||
]);
|
||||
expect(
|
||||
new Set(result.outcomes.map((outcome) => outcome.probabilityRoll)).size,
|
||||
).toBe(1);
|
||||
expect(result.outcomes[0].probabilityRoll).not.toBeNull();
|
||||
expect(result.script).toContain(
|
||||
'EXPLOSION CENTERED ON EVENT TARGET; APPLY TO VALID ENEMIES WITHIN 5m',
|
||||
);
|
||||
const maximum = evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy-b',
|
||||
values: { critical: true, aiming: true },
|
||||
areaTargetSample: {
|
||||
center: 'event_target',
|
||||
validTargets: [
|
||||
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
|
||||
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
{ mode: 'maximum', encounterSeconds: 5 },
|
||||
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
|
||||
);
|
||||
expect(maximum.outcomes.map((outcome) => outcome.targetId)).toEqual([
|
||||
'enemy-a',
|
||||
'enemy-b',
|
||||
]);
|
||||
expect(
|
||||
maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('requires authoritative area evidence only when an area outcome activates', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'area-evidence',
|
||||
name: 'Area Evidence',
|
||||
clauses: [
|
||||
{
|
||||
id: 'critical-hit',
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.critical' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [
|
||||
{
|
||||
id: 'explosion',
|
||||
label: 'Explosion',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
value: 1,
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy',
|
||||
values: { critical: false },
|
||||
},
|
||||
],
|
||||
custom(5),
|
||||
),
|
||||
).toMatchObject({ status: 'OK', outcomes: [] });
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
program,
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy',
|
||||
values: { critical: true },
|
||||
},
|
||||
],
|
||||
custom(5),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'INVALID',
|
||||
errors: [
|
||||
expect.stringContaining('authoritative event-target area sample'),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed and duplicate area-target samples', () => {
|
||||
const result = evaluateMechanicProgram(
|
||||
{ id: 'sample-validation', name: 'Sample Validation', clauses: [] },
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy',
|
||||
areaTargetSample: {
|
||||
center: 'event_target',
|
||||
validTargets: [
|
||||
{ entityId: 'enemy', population: 'enemy', distanceMeters: 0 },
|
||||
{ entityId: 'enemy', population: 'enemy', distanceMeters: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
custom(5),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: 'INVALID',
|
||||
errors: [expect.stringContaining('duplicates entity "enemy"')],
|
||||
});
|
||||
|
||||
const oversizedTargets = Array.from({ length: 257 }, (_, index) => ({
|
||||
entityId: index === 0 ? 'enemy' : `enemy-${index}`,
|
||||
population: 'enemy' as const,
|
||||
distanceMeters: index === 0 ? 0 : 1,
|
||||
}));
|
||||
expect(
|
||||
evaluateMechanicProgram(
|
||||
{ id: 'sample-bound', name: 'Sample Bound', clauses: [] },
|
||||
[
|
||||
{
|
||||
kind: 'weapon_hit',
|
||||
at: 1,
|
||||
targetId: 'enemy',
|
||||
areaTargetSample: {
|
||||
center: 'event_target',
|
||||
validTargets: oversizedTargets,
|
||||
},
|
||||
},
|
||||
],
|
||||
custom(5),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: 'INVALID',
|
||||
errors: [expect.stringContaining('cannot contain more than 256')],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,16 @@
|
||||
function link(mark: ArticleMark) {
|
||||
return mark.type === 'link' ? mark.attrs?.href ?? '#' : '#';
|
||||
}
|
||||
|
||||
function orderedListStart(node: ArticleNode) {
|
||||
const start = Number(node.attrs?.start ?? 1);
|
||||
return Number.isInteger(start) && start > 1 ? start : undefined;
|
||||
}
|
||||
|
||||
function orderedListType(node: ArticleNode): 'a' | 'A' | 'i' | 'I' | undefined {
|
||||
const type = node.attrs?.type;
|
||||
return ['a', 'A', 'i', 'I'].includes(String(type)) ? type as 'a' | 'A' | 'i' | 'I' : undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
|
||||
@@ -32,7 +42,7 @@
|
||||
{#if mark.type === 'bold'}<strong>{@render renderText(text, marks, index + 1)}</strong>
|
||||
{:else if mark.type === 'italic'}<em>{@render renderText(text, marks, index + 1)}</em>
|
||||
{:else if mark.type === 'code'}<code>{@render renderText(text, marks, index + 1)}</code>
|
||||
{:else}<a href={link(mark)} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
|
||||
{:else}<a href={link(mark)} title={mark.attrs?.title} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -46,7 +56,7 @@
|
||||
{:else if node.type === 'heading' && Number(node.attrs?.level) === 2}<h2>{@render children(node)}</h2>
|
||||
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
|
||||
{:else if node.type === 'bulletList'}<ul>{@render children(node)}</ul>
|
||||
{:else if node.type === 'orderedList'}<ol>{@render children(node)}</ol>
|
||||
{:else if node.type === 'orderedList'}<ol start={orderedListStart(node)} type={orderedListType(node)}>{@render children(node)}</ol>
|
||||
{:else if node.type === 'listItem'}<li>{@render children(node)}</li>
|
||||
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
|
||||
{:else if node.type === 'horizontalRule'}<hr/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||
import ArticleContent from '$lib/components/ArticleContent.svelte';
|
||||
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
|
||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
let { data, form = null } = $props();
|
||||
@@ -25,7 +25,7 @@
|
||||
<header class="article-reader-head">
|
||||
<div class="article-reader-channel"><span>{data.article.channel ?? (data.article.kind === 'guide' ? 'COMMUNITY GUIDE' : 'NEWS')}</span>{#if data.article.featured}<b>FEATURED</b>{/if}{#if data.article.recommended}<b>RECOMMENDED</b>{/if}</div>
|
||||
<h1>{data.article.title}</h1><p>{data.article.deck}</p>
|
||||
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>OPEN NEWSROOM</a>{/if}</div></div>
|
||||
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>Edit Article</a>{/if}</div></div>
|
||||
{#if data.article.source}<aside class="article-source-notice"><div><span>OFFICIAL SOURCE</span><strong>Originally published by {data.article.source.name}</strong></div><a href={data.article.source.url} target="_blank" rel="noopener noreferrer">READ ON UBISOFT</a></aside>{/if}
|
||||
{#if data.tags.length}<nav class="article-reader-tags" aria-label="Article tags">{#each data.tags as tag}<a href={`/${path}?tag=${encodeURIComponent(tag.slug)}`}>{tag.name}</a>{/each}</nav>{/if}
|
||||
</header>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import type { DatabaseItem } from '$lib/database/items';
|
||||
import MarkdownContent from './MarkdownContent.svelte';
|
||||
import WeaponModSlotIcon from './WeaponModSlotIcon.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
@@ -16,9 +17,21 @@
|
||||
hideDiscussionUtility?: boolean;
|
||||
anchor?: boolean;
|
||||
} = $props();
|
||||
let protocolLevel = $state(1);
|
||||
let talentUpgrades = $state(0);
|
||||
|
||||
const number = (value: number) => Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
|
||||
const valueWithUnit = (value: number, unit: string) => `${number(value)}${unit ? (unit === '%' ? '%' : ` ${unit}`) : ''}`;
|
||||
const protocolPrimaryValue = () => item.protocolPreview
|
||||
? item.protocolPreview.primaryBaseValue * (1 + (protocolLevel - 1) / 49)
|
||||
: 0;
|
||||
const talentDescription = () => item.protocolPreview?.talentDescription?.replace(/\{(\d+)\}/g, (placeholder, raw) => {
|
||||
const effect = item.protocolPreview?.talentEffects[Number(raw) - 1];
|
||||
return effect ? valueWithUnit(effect.baseValue * (1 + talentUpgrades / 5), effect.unit) : placeholder;
|
||||
});
|
||||
</script>
|
||||
|
||||
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'}>
|
||||
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'} class:protocol-quality-standard={item.topBorderTone === 'standard'} class:protocol-quality-specialized={item.topBorderTone === 'specialized'} class:protocol-quality-superior={item.topBorderTone === 'superior'} class:protocol-quality-highend={item.topBorderTone === 'highend'}>
|
||||
<header>
|
||||
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
|
||||
<div class="database-item-icon">
|
||||
@@ -31,13 +44,38 @@
|
||||
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
|
||||
<div class="database-header-status">
|
||||
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
|
||||
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge">{badge}</span>{/each}
|
||||
{#if item.headerBadge}<span class="database-header-badge">{item.headerBadge}</span>{/if}
|
||||
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge" class:exotic={badge === 'EXOTIC'}>{badge}</span>{/each}
|
||||
{#if item.headerBadge}<span class="database-header-badge" class:protocol-badge-standard={item.headerBadgeTone === 'standard'} class:protocol-badge-specialized={item.headerBadgeTone === 'specialized'} class:protocol-badge-superior={item.headerBadgeTone === 'superior'} class:protocol-badge-highend={item.headerBadgeTone === 'highend'}>{item.headerBadge}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
{#if item.badges?.length}<div class="database-item-badges">{#each item.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
|
||||
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
|
||||
{#if item.protocolPreview}
|
||||
<section class="database-protocol-preview">
|
||||
<div class="database-protocol-primary"><span>{item.protocolPreview.primaryStat}</span><b>{Number.isFinite(protocolPrimaryValue()) ? valueWithUnit(protocolPrimaryValue(), item.protocolPreview.primaryUnit) : 'NOT CONFIGURED'}</b></div>
|
||||
{#if item.protocolPreview.talentDescription}
|
||||
<div class="database-protocol-talent"><p>{talentDescription()}</p></div>
|
||||
{/if}
|
||||
<div class="database-protocol-controls"><label class="database-protocol-level"><span>LVL {protocolLevel}</span><input type="range" min="1" max="50" step="1" bind:value={protocolLevel} aria-label={`${item.name} level`}/></label>{#if item.protocolPreview.talentDescription}<div class="database-protocol-diamonds" role="group" aria-label={`${item.name} talent upgrades`}>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrades >= upgrade} aria-label={`Set talent upgrades to ${upgrade}`} aria-pressed={talentUpgrades === upgrade} onclick={() => talentUpgrades = talentUpgrades === upgrade ? 0 : upgrade}>◆</button>{/each}</div>{/if}</div>
|
||||
</section>
|
||||
{:else}
|
||||
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
|
||||
{/if}
|
||||
{#if item.weaponModSlots}
|
||||
<div class="database-weapon-mod-slots">
|
||||
<div><span>MOD SLOTS</span></div>
|
||||
{#if item.weaponModSlots.length}
|
||||
<ul aria-label={`${item.name} Weapon Mod slots`}>
|
||||
{#each item.weaponModSlots as modSlot}
|
||||
<li class:upgrade-slot={modSlot.unlockedAtTierOne} class:has-slot-icon={Boolean(modSlot.iconUrl)} title={`${modSlot.slot}${modSlot.unlockedAtTierOne ? ' - Unlocked when this weapon upgrades to Tier 1' : ''}`}>
|
||||
{#if modSlot.iconUrl}<WeaponModSlotIcon slot={modSlot.slot} iconUrl={modSlot.iconUrl}/><span class="sr-only">{modSlot.slot}</span>{:else}<span>{modSlot.slot}</span>{/if}
|
||||
{#if modSlot.unlockedAtTierOne}<small>T1+</small>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p>MOD SLOT DATA NOT CONFIGURED</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.lines?.length}
|
||||
<div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
|
||||
{/if}
|
||||
@@ -53,7 +91,7 @@
|
||||
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
|
||||
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
|
||||
</div>
|
||||
{:else}
|
||||
{:else if !item.protocolPreview}
|
||||
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
|
||||
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import {
|
||||
MOD_QUALITIES,
|
||||
MOD_SECONDARY_CAPACITY,
|
||||
MOD_SECONDARY_QUALITIES,
|
||||
type ModSecondaryRange
|
||||
} from '$lib/mods/secondary-pool';
|
||||
|
||||
let {
|
||||
kind,
|
||||
pool,
|
||||
stats,
|
||||
error,
|
||||
saved = false,
|
||||
resultToken = ''
|
||||
}: {
|
||||
kind: 'Weapon Mod' | 'Gear Mod';
|
||||
pool: ModSecondaryRange[];
|
||||
stats: Array<{ id: string; name: string; unit: string }>;
|
||||
error?: string;
|
||||
saved?: boolean;
|
||||
resultToken?: string;
|
||||
} = $props();
|
||||
|
||||
const poolId = $derived(kind === 'Weapon Mod' ? 'weapon-mod-secondary-pool' : 'gear-mod-secondary-pool');
|
||||
const action = '?/saveSecondaryPool';
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const blankSecondary = (): ModSecondaryRange => ({
|
||||
statId: '',
|
||||
values: Object.fromEntries(
|
||||
MOD_SECONDARY_QUALITIES.map((quality) => [quality, {
|
||||
level1Min: 0,
|
||||
level1Max: 0,
|
||||
level20Min: 0,
|
||||
level20Max: 0
|
||||
}])
|
||||
) as ModSecondaryRange['values']
|
||||
});
|
||||
let editing = $state(false);
|
||||
let draft = $state<ModSecondaryRange[]>([]);
|
||||
let handledResultToken = $state('');
|
||||
|
||||
function openEditor() {
|
||||
draft = pool.length ? structuredClone(pool) : [blankSecondary(), blankSecondary()];
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function addSecondary() {
|
||||
if (draft.length < 50) draft = [...draft, blankSecondary()];
|
||||
}
|
||||
|
||||
function removeSecondary(index: number) {
|
||||
if (draft.length > 2) draft = draft.filter((_, itemIndex) => itemIndex !== index);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (saved && resultToken && resultToken !== handledResultToken) {
|
||||
handledResultToken = resultToken;
|
||||
editing = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="mod-secondary-pool-registry">
|
||||
<div class="selection-head">
|
||||
<div>
|
||||
<span>SHARED {kind.toUpperCase()} SECONDARY POOL</span>
|
||||
<small>ONE CANONICAL POOL FOR EVERY REGULAR {kind.toUpperCase()}</small>
|
||||
</div>
|
||||
<button type="button" class="row-action" onclick={openEditor}>EDIT SHARED POOL →</button>
|
||||
</div>
|
||||
<div class="mod-secondary-capacity">
|
||||
{#each MOD_QUALITIES as quality}
|
||||
<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{MOD_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {MOD_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>
|
||||
{/each}
|
||||
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{pool.length}</strong><small>SHARED OPTIONS</small></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if editing}
|
||||
<div class="dashboard-editor mod-secondary-pool-editor">
|
||||
<div class="editor-title">
|
||||
<div><p class="eyebrow">SHARED CONFIGURATION</p><h3>{kind.toUpperCase()} SECONDARY ATTRIBUTES</h3></div>
|
||||
<button aria-label={`Close ${kind} secondary pool editor`} onclick={() => editing = false}>×</button>
|
||||
</div>
|
||||
<PendingForm {action}>
|
||||
<input type="hidden" name="id" value={poolId}/>
|
||||
<input type="hidden" name="name" value={`Shared ${kind} Secondary Pool`}/>
|
||||
<input type="hidden" name="secondaryRanges" value={JSON.stringify(draft)}/>
|
||||
<p class="console-note">Standard quality has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Ranges scale linearly from Level 1 to Level 20.</p>
|
||||
<div class="selection-head pool-editor-head">
|
||||
<div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END LEVEL ENDPOINTS</small></div>
|
||||
<button type="button" class="row-action" onclick={addSecondary} disabled={draft.length >= 50}>ADD ATTRIBUTE +</button>
|
||||
</div>
|
||||
<div class="mod-secondary-pool-options">
|
||||
{#each draft as range, index}
|
||||
<div class="mod-secondary-pool-option">
|
||||
<strong>{String(index + 1).padStart(2, '0')}</strong>
|
||||
<SearchableAssignment
|
||||
items={stats}
|
||||
mode="single"
|
||||
compact
|
||||
subjectLabel="POSSIBLE ATTRIBUTE"
|
||||
searchPlaceholder="Search attributes…"
|
||||
excludedIds={draft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)}
|
||||
bind:value={range.statId}
|
||||
/>
|
||||
{#each MOD_SECONDARY_QUALITIES as quality}
|
||||
<div class="mod-secondary-quality-range">
|
||||
<b class={qualityClass(quality)}>{quality.toUpperCase()}</b>
|
||||
<label><span>LEVEL 1</span><div><input type="number" step="any" bind:value={range.values[quality].level1Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level1Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 maximum`} required/></div></label>
|
||||
<label><span>LEVEL 20</span><div><input type="number" step="any" bind:value={range.values[quality].level20Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level20Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 maximum`} required/></div></label>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" aria-label={`Remove shared secondary attribute option ${index + 1}`} onclick={() => removeSecondary(index)} disabled={draft.length <= 2}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -29,8 +29,6 @@
|
||||
gear_max: 'gearMax',
|
||||
weapon_min: 'weaponMin',
|
||||
weapon_max: 'weaponMax',
|
||||
skill_chip_min: 'chipMin',
|
||||
skill_chip_max: 'chipMax',
|
||||
cap_value: 'capValue'
|
||||
};
|
||||
return proposed[names[key] ?? key];
|
||||
@@ -46,6 +44,8 @@
|
||||
);
|
||||
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
|
||||
const currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
|
||||
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]);
|
||||
const skillChipFields = $derived([
|
||||
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
|
||||
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
|
||||
@@ -256,7 +256,7 @@
|
||||
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
|
||||
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
|
||||
{/each}
|
||||
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
|
||||
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
|
||||
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('current', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('current', `${source[0]}_max`))}</strong></div></div>
|
||||
{/each}
|
||||
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('current', 'cap_value'))}</strong></div>
|
||||
@@ -273,13 +273,24 @@
|
||||
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
|
||||
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
|
||||
{/each}
|
||||
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
|
||||
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
|
||||
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('proposed', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('proposed', `${source[0]}_max`))}</strong></div></div>
|
||||
{/each}
|
||||
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
|
||||
<div class="proposal-compare-grid">
|
||||
<article class="proposal-editor-preview before">
|
||||
<header><span>CURRENT LIVE · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>BEFORE</b></header>
|
||||
<div class="proposal-structured-list">{#each currentSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{:else}<p>SHARED PROFILE NOT CONFIGURED</p>{/each}</div>
|
||||
</article>
|
||||
<article class="proposal-editor-preview after">
|
||||
<header><span>PROPOSED · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>AFTER</b></header>
|
||||
<div class="proposal-structured-list">{#each proposedSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{/each}</div>
|
||||
</article>
|
||||
</div>
|
||||
{:else if module === 'skillchips' && action === 'save'}
|
||||
<div class="proposal-compare-grid">
|
||||
<article class="proposal-editor-preview before">
|
||||
@@ -287,7 +298,7 @@
|
||||
{#if skillChipRecord}
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
|
||||
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
|
||||
<div><span>3-PIECE TALENT</span><strong>{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
||||
@@ -301,7 +312,7 @@
|
||||
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
|
||||
<div class="proposal-field-summary">
|
||||
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
|
||||
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
|
||||
<div><span>3-PIECE TALENT</span><strong>{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
|
||||
</div>
|
||||
<div class="proposal-structured-list">
|
||||
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import {
|
||||
SKILL_CHIP_QUALITIES,
|
||||
SKILL_CHIP_SECONDARY_CAPACITY,
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
type SkillChipProfile
|
||||
} from '$lib/skill-chips/profiles';
|
||||
|
||||
let {
|
||||
primaryProfiles,
|
||||
primaryProfileBase,
|
||||
secondaryPool,
|
||||
stats,
|
||||
primaryError,
|
||||
secondaryError,
|
||||
primarySaved = false,
|
||||
secondarySaved = false,
|
||||
resultToken = ''
|
||||
}: {
|
||||
primaryProfiles: SkillChipProfile[];
|
||||
primaryProfileBase: SkillChipProfile[];
|
||||
secondaryPool: SkillChipProfile[];
|
||||
stats: Array<{ id: string; name: string; unit: string }>;
|
||||
primaryError?: string;
|
||||
secondaryError?: string;
|
||||
primarySaved?: boolean;
|
||||
secondarySaved?: boolean;
|
||||
resultToken?: string;
|
||||
} = $props();
|
||||
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const blankValues = (qualities: readonly string[]) => Object.fromEntries(
|
||||
qualities.map((quality) => [quality, { min: '', max: '' }])
|
||||
);
|
||||
const hydrated = (profile: SkillChipProfile, qualities: readonly string[]) => ({
|
||||
statId: profile.statId,
|
||||
values: Object.fromEntries(qualities.map((quality) => [quality, {
|
||||
min: profile.values[quality]?.min ?? '',
|
||||
max: profile.values[quality]?.max ?? ''
|
||||
}]))
|
||||
});
|
||||
const blankSecondary = () => ({ statId: '', values: blankValues(SKILL_CHIP_SECONDARY_QUALITIES) });
|
||||
let primaryEditing = $state(false);
|
||||
let secondaryEditing = $state(false);
|
||||
let primaryDraft = $state<any[]>([]);
|
||||
let secondaryDraft = $state<any[]>([]);
|
||||
let handledPrimaryToken = $state('');
|
||||
let handledSecondaryToken = $state('');
|
||||
|
||||
function openPrimary() {
|
||||
primaryDraft = primaryProfiles.map((profile) => hydrated(profile, SKILL_CHIP_QUALITIES));
|
||||
secondaryEditing = false;
|
||||
primaryEditing = true;
|
||||
}
|
||||
function openSecondary() {
|
||||
const loaded: any[] = secondaryPool.map((profile) =>
|
||||
hydrated(profile, SKILL_CHIP_SECONDARY_QUALITIES)
|
||||
);
|
||||
while (loaded.length < 2) loaded.push(blankSecondary());
|
||||
secondaryDraft = loaded;
|
||||
primaryEditing = false;
|
||||
secondaryEditing = true;
|
||||
}
|
||||
$effect(() => {
|
||||
if (primarySaved && resultToken && resultToken !== handledPrimaryToken) {
|
||||
handledPrimaryToken = resultToken;
|
||||
primaryEditing = false;
|
||||
}
|
||||
if (secondarySaved && resultToken && resultToken !== handledSecondaryToken) {
|
||||
handledSecondaryToken = resultToken;
|
||||
secondaryEditing = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="skill-chip-shared-config">
|
||||
<section class="mod-secondary-pool-registry">
|
||||
<div class="selection-head"><div><span>SHARED PRIMARY PROFILE</span><small>FOUR ABILITY-SCOPED STATS · ONE RANGE PER QUALITY</small></div><button type="button" class="row-action" onclick={openPrimary}>EDIT PRIMARY RANGES →</button></div>
|
||||
<div class="mod-secondary-capacity">
|
||||
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{primaryProfiles.filter((profile) => profile.values[quality]).length}</strong><small>OF 4 CONFIGURED</small></span>{/each}
|
||||
<span class="pool-total"><b>CALCULATION</b><strong>—</strong><small>NOT YET DEFINED</small></span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mod-secondary-pool-registry">
|
||||
<div class="selection-head"><div><span>SHARED SECONDARY POOL</span><small>ONE CANONICAL ATTRIBUTE POOL FOR EVERY SKILL CHIP</small></div><button type="button" class="row-action" onclick={openSecondary}>EDIT SHARED POOL →</button></div>
|
||||
<div class="mod-secondary-capacity">
|
||||
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{SKILL_CHIP_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {SKILL_CHIP_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>{/each}
|
||||
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{secondaryPool.length}</strong><small>SHARED OPTIONS</small></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if primaryEditing}
|
||||
<div class="dashboard-editor skill-chip-profile-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP PRIMARY RANGES</h3></div><button aria-label="Close primary profile editor" onclick={() => primaryEditing = false}>×</button></div>
|
||||
<PendingForm action="?/savePrimaryProfile">
|
||||
<input type="hidden" name="id" value="skill-chip-primary-profile"/><input type="hidden" name="name" value="Shared Skill Chip Primary Profile"/><input type="hidden" name="baseProfiles" value={JSON.stringify(primaryProfileBase)}/><input type="hidden" name="profiles" value={JSON.stringify(primaryDraft)}/>
|
||||
<p class="console-note">Every Skill Chip rolls one of these four ability-scoped primary stats. Configure the direct minimum and maximum for each quality. Current combat calculations do not apply these values.</p>
|
||||
<div class="skill-chip-profile-options fixed">
|
||||
{#each primaryDraft as profile}
|
||||
<div class="skill-chip-profile-option"><div><strong>{stats.find((item) => item.id === profile.statId)?.name ?? 'UNKNOWN ATTRIBUTE'}</strong><small>{stats.find((item) => item.id === profile.statId)?.unit || 'FLAT'} · ABILITY SCOPED</small></div>{#each SKILL_CHIP_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} maximum`} required/></span></label>{/each}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if primaryError}<p class="error">{primaryError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => primaryEditing = false}>CANCEL</button><button class="primary">SAVE PRIMARY PROFILE →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if secondaryEditing}
|
||||
<div class="dashboard-editor skill-chip-profile-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP SECONDARY POOL</h3></div><button aria-label="Close secondary pool editor" onclick={() => secondaryEditing = false}>×</button></div>
|
||||
<PendingForm action="?/saveSecondaryPool">
|
||||
<input type="hidden" name="id" value="skill-chip-secondary-pool"/><input type="hidden" name="name" value="Shared Skill Chip Secondary Pool"/><input type="hidden" name="baseProfiles" value={JSON.stringify(secondaryPool)}/><input type="hidden" name="profiles" value={JSON.stringify(secondaryDraft)}/>
|
||||
<p class="console-note">Standard has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Skill Chips have no item level, so each quality uses one direct range.</p>
|
||||
<div class="selection-head pool-editor-head"><div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END RANGES</small></div><button type="button" class="row-action" onclick={() => secondaryDraft = [...secondaryDraft, blankSecondary()]} disabled={secondaryDraft.length >= 50}>ADD ATTRIBUTE +</button></div>
|
||||
<div class="skill-chip-profile-options">
|
||||
{#each secondaryDraft as profile, index}
|
||||
<div class="skill-chip-profile-option selectable"><strong>{String(index + 1).padStart(2, '0')}</strong><SearchableAssignment items={stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={secondaryDraft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)} bind:value={profile.statId}/>{#each SKILL_CHIP_SECONDARY_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} maximum`} required/></span></label>{/each}<button type="button" aria-label={`Remove secondary option ${index + 1}`} onclick={() => secondaryDraft = secondaryDraft.filter((_, itemIndex) => itemIndex !== index)} disabled={secondaryDraft.length <= 2}>×</button></div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if secondaryError}<p class="error">{secondaryError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => secondaryEditing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
slot,
|
||||
iconUrl,
|
||||
decorative = true,
|
||||
size = 24
|
||||
}: { slot: string; iconUrl?: string | null; decorative?: boolean; size?: number } = $props();
|
||||
</script>
|
||||
|
||||
{#if iconUrl}
|
||||
<img
|
||||
class="weapon-mod-slot-icon"
|
||||
src={iconUrl}
|
||||
alt={decorative ? '' : `${slot} Weapon Mod slot`}
|
||||
loading="lazy"
|
||||
width={size}
|
||||
height={size}
|
||||
style={`--weapon-mod-slot-icon-size: ${size}px`}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
img {
|
||||
width: var(--weapon-mod-slot-icon-size);
|
||||
height: var(--weapon-mod-slot-icon-size);
|
||||
flex: 0 0 var(--weapon-mod-slot-icon-size);
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import WeaponModSlotIcon from '$lib/components/WeaponModSlotIcon.svelte';
|
||||
|
||||
type SlotIcon = {
|
||||
id: string;
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
let {
|
||||
slots,
|
||||
error = '',
|
||||
saved = false,
|
||||
resultToken = ''
|
||||
}: {
|
||||
slots: SlotIcon[];
|
||||
error?: string;
|
||||
saved?: boolean;
|
||||
resultToken?: string;
|
||||
} = $props();
|
||||
|
||||
let editing = $state<SlotIcon | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (saved && resultToken && resultToken !== handledResultToken) {
|
||||
handledResultToken = resultToken;
|
||||
editing = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="slot-icon-registry" aria-labelledby="slot-icon-registry-title">
|
||||
<div class="selection-head">
|
||||
<div>
|
||||
<span id="slot-icon-registry-title">SHARED MOD SLOT ICONS</span>
|
||||
<small>ONE CANONICAL ICON FOR EACH WEAPON MOD SLOT</small>
|
||||
</div>
|
||||
<b>{slots.every((slot) => slot.iconUrl) ? 'READY' : 'CONFIG REQUIRED'}</b>
|
||||
</div>
|
||||
<div class="slot-icon-grid">
|
||||
{#each slots as slot}
|
||||
<button
|
||||
type="button"
|
||||
class:configured={Boolean(slot.iconUrl)}
|
||||
aria-pressed={editing?.id === slot.id}
|
||||
onclick={() => editing = slot}
|
||||
>
|
||||
<span class="slot-icon-preview">
|
||||
{#if slot.iconUrl}
|
||||
<WeaponModSlotIcon slot={slot.name} iconUrl={slot.iconUrl}/>
|
||||
{:else}
|
||||
<i aria-hidden="true">?</i>
|
||||
{/if}
|
||||
</span>
|
||||
<span><strong>{slot.name}</strong><small>{slot.iconUrl ? 'ICON REGISTERED' : 'ICON REQUIRED'}</small></span>
|
||||
<b>EDIT</b>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if editing}
|
||||
<section class="dashboard-editor slot-icon-editor" aria-labelledby="slot-icon-editor-title">
|
||||
<div class="editor-title">
|
||||
<div><p class="eyebrow">SHARED MOD SLOT ARTWORK</p><h3 id="slot-icon-editor-title">{editing.name.toUpperCase()}</h3></div>
|
||||
<button aria-label="Close slot icon editor" onclick={() => editing = null}>×</button>
|
||||
</div>
|
||||
<PendingForm action="?/saveSlotIcon" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="name" value={`${editing.name} Slot`}/>
|
||||
<input type="hidden" name="slotType" value={editing.name}/>
|
||||
<input type="hidden" name="baseUpdatedAt" value={editing.updatedAt}/>
|
||||
<div class="slot-icon-editor-body">
|
||||
<div class="slot-icon-editor-identity">
|
||||
<span class="slot-icon-preview large">
|
||||
{#if editing.iconUrl}
|
||||
<WeaponModSlotIcon slot={editing.name} iconUrl={editing.iconUrl} size={56}/>
|
||||
{:else}
|
||||
<i aria-hidden="true">?</i>
|
||||
{/if}
|
||||
</span>
|
||||
<div><small>CANONICAL SLOT</small><strong>{editing.name}</strong><p>Weapon cards use this artwork anywhere the slot is available.</p></div>
|
||||
</div>
|
||||
<IconUploadField currentUrl={editing.iconUrl} label={`${editing.name.toUpperCase()} SLOT ICON`} resetToken={`${editing.id}:${editing.updatedAt}`}/>
|
||||
</div>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE SLOT ICON →</button></div>
|
||||
</PendingForm>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.slot-icon-registry {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--shd-border);
|
||||
border-left: 2px solid var(--shd-info);
|
||||
background: #0d1011;
|
||||
}
|
||||
.slot-icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.slot-icon-grid > button {
|
||||
min-height: 62px;
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: #101415;
|
||||
color: var(--shd-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.slot-icon-grid > button:hover,
|
||||
.slot-icon-grid > button:focus-visible,
|
||||
.slot-icon-grid > button[aria-pressed='true'] {
|
||||
border-color: var(--shd-orange);
|
||||
background: #191712;
|
||||
}
|
||||
.slot-icon-grid > button:focus-visible {
|
||||
outline: 2px solid var(--shd-orange);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.slot-icon-grid > button > span:nth-child(2) {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.slot-icon-grid strong,
|
||||
.slot-icon-grid small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.slot-icon-grid strong {
|
||||
font-size: var(--shd-type-label);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.slot-icon-grid small,
|
||||
.slot-icon-grid > button > b {
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-micro);
|
||||
letter-spacing: .07em;
|
||||
}
|
||||
.slot-icon-grid > button.configured small,
|
||||
.slot-icon-grid > button.configured > b {
|
||||
color: var(--shd-info);
|
||||
}
|
||||
.slot-icon-preview {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #313839;
|
||||
background: #090b0c;
|
||||
}
|
||||
.slot-icon-preview i {
|
||||
color: #586164;
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
}
|
||||
.slot-icon-editor {
|
||||
border-top-color: var(--shd-info);
|
||||
}
|
||||
.slot-icon-editor-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, .7fr) minmax(360px, 1.3fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.slot-icon-editor-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: #0d1011;
|
||||
}
|
||||
.slot-icon-preview.large {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
flex: 0 0 76px;
|
||||
}
|
||||
.slot-icon-editor-identity > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.slot-icon-editor-identity small,
|
||||
.slot-icon-editor-identity p {
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
}
|
||||
.slot-icon-editor-identity p {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.slot-icon-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.slot-icon-editor-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.slot-icon-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,8 @@
|
||||
blankConditionalMechanicDraft,
|
||||
currentHealthPercentBandPredicate,
|
||||
eventFieldComparisonPredicate,
|
||||
mechanicTargetingForMode,
|
||||
mechanicTargetingMode,
|
||||
parseConditionalMechanicDraft,
|
||||
readCurrentHealthPercentBand,
|
||||
readEventFieldComparison,
|
||||
@@ -21,6 +23,7 @@
|
||||
type ConditionalMechanicPoolDraft,
|
||||
type EditablePredicateScalar,
|
||||
type EventFieldComparison,
|
||||
type MechanicTargetingMode,
|
||||
type PredicateComparisonOperator,
|
||||
type StatePoolThresholdComparison
|
||||
} from '$lib/calculations/mechanic-editor';
|
||||
@@ -182,7 +185,9 @@
|
||||
}
|
||||
|
||||
function eventPredicateOperatorOptions(clause: ConditionalMechanicClauseDraft) {
|
||||
return selectedEventPredicateField(clause)?.type === 'number'
|
||||
const field = selectedEventPredicateField(clause);
|
||||
if (field?.path === 'values.aiming') return predicateOperatorOptions.filter((option) => option.value === 'eq');
|
||||
return field?.type === 'number'
|
||||
? predicateOperatorOptions
|
||||
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
|
||||
}
|
||||
@@ -190,15 +195,21 @@
|
||||
function setClauseEvent(clauseIndex: number, event: string) {
|
||||
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
|
||||
const clause = rule.clauses[clauseIndex];
|
||||
const outcomes = nextEvent === 'weapon_hit'
|
||||
? clause.outcomes
|
||||
: clause.outcomes.map((outcome) => outcome.targeting?.kind === 'event_target_area'
|
||||
? { ...outcome, targeting: undefined }
|
||||
: outcome);
|
||||
const fields = registeredEventFields(nextEvent);
|
||||
const comparison = readEventFieldComparison(clause.if);
|
||||
if (!comparison || !fields.length) {
|
||||
updateClause(clauseIndex, { event: nextEvent });
|
||||
updateClause(clauseIndex, { event: nextEvent, outcomes });
|
||||
return;
|
||||
}
|
||||
const field = fields[0];
|
||||
updateClause(clauseIndex, {
|
||||
event: nextEvent,
|
||||
outcomes,
|
||||
if: eventFieldComparisonPredicate({
|
||||
path: field.path,
|
||||
operator: 'eq',
|
||||
@@ -403,6 +414,69 @@
|
||||
updateClause(clauseIndex, { outcomes });
|
||||
}
|
||||
|
||||
function targetingOptions(clause: ConditionalMechanicClauseDraft, outcome: ConditionalMechanicOutcomeDraft) {
|
||||
if (outcome.scope === 'allies' || outcome.scope === 'self_and_allies') {
|
||||
return [
|
||||
{ value: 'none', label: 'NONE' },
|
||||
{ value: 'allies_within_radius', label: 'ALLIES IN RADIUS' }
|
||||
];
|
||||
}
|
||||
if (outcome.scope === 'target') {
|
||||
return [
|
||||
{ value: 'none', label: 'NONE' },
|
||||
{ value: 'enemies_within_radius', label: 'ENEMIES IN RADIUS · LEGACY' },
|
||||
...(outcome.kind === 'direct_damage' && clause.event === 'weapon_hit'
|
||||
? [{ value: 'event_target_explosion', label: 'HIT-TARGET EXPLOSION' }]
|
||||
: [])
|
||||
];
|
||||
}
|
||||
return [{ value: 'none', label: 'NONE' }];
|
||||
}
|
||||
|
||||
function setOutcomeScope(clauseIndex: number, outcomeIndex: number, scope: MechanicOutcomeScope) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const targeting = outcome.targeting;
|
||||
const compatible = !targeting
|
||||
|| (targeting.kind === 'event_target_area' && scope === 'target' && outcome.kind === 'direct_damage')
|
||||
|| (targeting.kind === 'within_radius' && targeting.population === 'enemies' && scope === 'target')
|
||||
|| (targeting.kind === 'within_radius' && targeting.population === 'allies' && (scope === 'allies' || scope === 'self_and_allies'));
|
||||
updateOutcome(clauseIndex, outcomeIndex, { scope, targeting: compatible ? targeting : undefined });
|
||||
}
|
||||
|
||||
function setOutcomeKind(clauseIndex: number, outcomeIndex: number, kind: ConditionalMechanicOutcomeDraft['kind']) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
updateOutcome(clauseIndex, outcomeIndex, {
|
||||
kind,
|
||||
resource: kind === 'restoration' || kind === 'resource_cost' ? outcome.resource ?? 'health' : undefined,
|
||||
attributeId: kind === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,
|
||||
delivery: kind === 'restoration' ? outcome.delivery : undefined,
|
||||
durationSeconds: kind === 'restoration' ? outcome.durationSeconds : undefined,
|
||||
targeting: outcome.targeting?.kind === 'event_target_area' && kind !== 'direct_damage'
|
||||
? undefined
|
||||
: outcome.targeting
|
||||
});
|
||||
}
|
||||
|
||||
function setOutcomeTargeting(clauseIndex: number, outcomeIndex: number, mode: MechanicTargetingMode) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
updateOutcome(clauseIndex, outcomeIndex, {
|
||||
targeting: mechanicTargetingForMode(mode, outcome.targeting),
|
||||
phase: mode === 'event_target_explosion' && outcome.phase !== 'on_event' && outcome.phase !== 'after_event'
|
||||
? 'after_event'
|
||||
: outcome.phase
|
||||
});
|
||||
}
|
||||
|
||||
function phaseOptions(outcome: ConditionalMechanicOutcomeDraft) {
|
||||
return [
|
||||
{ value: 'on_event', label: 'ON EVENT' },
|
||||
{ value: 'after_event', label: 'AFTER EVENT' },
|
||||
{ value: 'while_active', label: 'WHILE ACTIVE', disabled: outcome.targeting?.kind === 'event_target_area' },
|
||||
{ value: 'on_enter', label: 'ON ENTER', disabled: outcome.targeting?.kind === 'event_target_area' },
|
||||
{ value: 'on_exit', label: 'ON EXIT', disabled: outcome.targeting?.kind === 'event_target_area' }
|
||||
];
|
||||
}
|
||||
|
||||
function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const value = Number(raw);
|
||||
@@ -684,6 +758,50 @@
|
||||
});
|
||||
}
|
||||
|
||||
function addAbilityDurationExtension(clauseIndex: number) {
|
||||
updateClause(clauseIndex, {
|
||||
mutations: [
|
||||
...rule.clauses[clauseIndex].mutations,
|
||||
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function addCooldownOnMax(clauseIndex: number) {
|
||||
const stackPool = rule.pools.find((pool) => pool.max && pool.max > 0)?.key;
|
||||
const cooldownPool = rule.pools.find((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds)?.key;
|
||||
if (!stackPool || !cooldownPool || stackPool === cooldownPool) return;
|
||||
updateClause(clauseIndex, {
|
||||
mutations: [
|
||||
...rule.clauses[clauseIndex].mutations,
|
||||
{ kind: 'start_cooldown_on_max', stackPool, cooldownPool }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function updateAbilityDurationExtension(clauseIndex: number, mutationIndex: number, raw: string) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const seconds = Number(raw);
|
||||
mutations[mutationIndex] = {
|
||||
kind: 'extend_duration',
|
||||
target: 'assigned_ability',
|
||||
seconds: Number.isFinite(seconds) ? seconds : 0
|
||||
};
|
||||
updateClause(clauseIndex, { mutations });
|
||||
}
|
||||
|
||||
function updateCooldownOnMax(
|
||||
clauseIndex: number,
|
||||
mutationIndex: number,
|
||||
patch: { stackPool?: string; cooldownPool?: string }
|
||||
) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const current = mutations[mutationIndex];
|
||||
if (current.kind !== 'start_cooldown_on_max') return;
|
||||
mutations[mutationIndex] = { ...current, ...patch };
|
||||
updateClause(clauseIndex, { mutations });
|
||||
}
|
||||
|
||||
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
|
||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||
const current = mutations[mutationIndex];
|
||||
@@ -981,6 +1099,20 @@
|
||||
{:else if conditionModel(clause) === 'advanced'}
|
||||
<p class="advanced-condition-note">This saved condition remains active and is preserved unchanged. Use the generated mechanic preview to inspect it.</p>
|
||||
{/if}
|
||||
{#if allowAssignedAbility}
|
||||
<div class="ability-proc-controls">
|
||||
<SegmentedChoice
|
||||
legend="ASSIGNED ABILITY"
|
||||
name={`${idPrefix}-assigned-ability-${clauseIndex}`}
|
||||
options={[{value:'false',label:'ANY STATE'},{value:'true',label:'MUST BE ACTIVE'}]}
|
||||
value={String(clause.requiresAssignedAbilityActive ?? false)}
|
||||
onchange={(values) => updateClause(clauseIndex,{requiresAssignedAbilityActive:values[0] === 'true' ? true : undefined})}
|
||||
/>
|
||||
<label class="field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="field proc-chance-field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="reducer-controls">
|
||||
<SegmentedChoice
|
||||
@@ -1007,38 +1139,53 @@
|
||||
</div>
|
||||
|
||||
<div class="mutation-panel">
|
||||
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button></div>
|
||||
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><div class="mutation-actions"><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button>{#if allowAssignedAbility}<button type="button" class="secondary compact-action" onclick={() => addAbilityDurationExtension(clauseIndex)}>EXTEND ABILITY +</button>{/if}<button type="button" class="secondary compact-action" onclick={() => addCooldownOnMax(clauseIndex)} disabled={rule.pools.filter((pool) => pool.max && pool.max > 0).length < 2}>ADD MAX COOLDOWN +</button></div></div>
|
||||
{#each clause.mutations as mutation, mutationIndex}
|
||||
<div class="mutation-row">
|
||||
<SegmentedChoice
|
||||
legend="OPERATION"
|
||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
||||
/>
|
||||
<SegmentedChoice
|
||||
legend="STATE POOL"
|
||||
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
|
||||
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
||||
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
||||
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
||||
/>
|
||||
{#if mutation.kind !== 'clear'}
|
||||
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
||||
{/if}
|
||||
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
|
||||
{#if mutation.kind === 'extend_duration'}
|
||||
<div class="mutation-row ability-mutation-row">
|
||||
<div class="branch-readout"><span>OPERATION</span><strong>EXTEND ASSIGNED ABILITY</strong></div>
|
||||
<label class="field">SECONDS<input type="number" min="0" step="any" value={typeof mutation.seconds === 'number' ? mutation.seconds : 0} oninput={(event) => updateAbilityDurationExtension(clauseIndex,mutationIndex,event.currentTarget.value)}/><small>Applies only while the assigned ability remains active.</small></label>
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else if mutation.kind === 'start_cooldown_on_max'}
|
||||
<div class="mutation-row cooldown-mutation-row">
|
||||
<div class="branch-readout"><span>OPERATION</span><strong>START COOLDOWN ON MAX</strong></div>
|
||||
<SegmentedChoice legend="STACK POOL" name={`${idPrefix}-cooldown-stack-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.max && pool.max > 0).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.stackPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{stackPool:values[0]})}/>
|
||||
<SegmentedChoice legend="COOLDOWN POOL" name={`${idPrefix}-cooldown-pool-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds && pool.key !== mutation.stackPool).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.cooldownPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{cooldownPool:values[0]})}/>
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mutation-row">
|
||||
<SegmentedChoice
|
||||
legend="STATE OWNER"
|
||||
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
|
||||
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
|
||||
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
|
||||
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
|
||||
legend="OPERATION"
|
||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
||||
/>
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
<SegmentedChoice
|
||||
legend="STATE POOL"
|
||||
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
|
||||
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
||||
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
||||
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
||||
/>
|
||||
{#if mutation.kind !== 'clear'}
|
||||
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
||||
{/if}
|
||||
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
|
||||
<SegmentedChoice
|
||||
legend="STATE OWNER"
|
||||
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
|
||||
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
|
||||
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
|
||||
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
|
||||
/>
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
|
||||
{/each}
|
||||
@@ -1049,25 +1196,24 @@
|
||||
<div class="outcome-card">
|
||||
<div class="outcome-grid">
|
||||
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,delivery:values[0] === 'restoration' ? outcome.delivery : undefined,durationSeconds:values[0] === 'restoration' ? outcome.durationSeconds : undefined})}/>
|
||||
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => setOutcomeKind(clauseIndex,outcomeIndex,values[0] as ConditionalMechanicOutcomeDraft['kind'])}/>
|
||||
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
</div>
|
||||
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
|
||||
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => setOutcomeScope(clauseIndex,outcomeIndex,values[0] as MechanicOutcomeScope)}/>
|
||||
{#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
|
||||
<div class="delivery-controls">
|
||||
<SegmentedChoice
|
||||
legend="AREA TARGETING"
|
||||
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
|
||||
options={[{value:'none',label:'NONE'},{value:'allies',label:'ALLIES IN RADIUS'},{value:'enemies',label:'ENEMIES IN RADIUS'}]}
|
||||
value={outcome.targeting?.population ?? 'none'}
|
||||
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
|
||||
targeting: values[0] === 'none'
|
||||
? undefined
|
||||
: {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20}
|
||||
})}
|
||||
options={targetingOptions(clause,outcome)}
|
||||
value={mechanicTargetingMode(outcome.targeting)}
|
||||
onchange={(values) => setOutcomeTargeting(clauseIndex,outcomeIndex,values[0] as MechanicTargetingMode)}
|
||||
/>
|
||||
{#if outcome.targeting}
|
||||
<label class="field">RADIUS (METERS)<input type="number" min="0.01" step="any" value={outcome.targeting.radiusMeters} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{targeting:{...outcome.targeting!,radiusMeters:Number(event.currentTarget.value)}})}/></label>
|
||||
{#if outcome.targeting.kind === 'event_target_area'}
|
||||
<p class="area-targeting-note"><strong>EVENT TARGET CENTER</strong><span>Damages each valid enemy at or inside the radius once. Runtime events must supply the authoritative target sample.</span></p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1154,8 +1300,9 @@
|
||||
<label class="field">{column.label} VALUE<input type="number" step="any" value={outcome.variants[column.variantKey]?.value ?? 0} oninput={(event) => updateVariant(clauseIndex,outcomeIndex,column.variantKey,event.currentTarget.value)}/></label>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="field probability-field">OUTCOME CHANCE · %<input type="number" min="0" max="100" step="any" value={outcome.probabilityPercent ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{probabilityPercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Each eligible outcome rolls independently. Maximum Potential resolves each nonzero chance as successful.</small></label>
|
||||
<div class="outcome-footer">
|
||||
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={[{value:'on_event',label:'ON EVENT'},{value:'after_event',label:'AFTER EVENT'},{value:'while_active',label:'WHILE ACTIVE'},{value:'on_enter',label:'ON ENTER'},{value:'on_exit',label:'ON EXIT'}]} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
|
||||
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={phaseOptions(outcome)} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
|
||||
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1198,6 +1345,8 @@
|
||||
.branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0}
|
||||
.branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
|
||||
.condition-controls{display:grid;grid-template-columns:minmax(260px,.7fr) minmax(320px,1.3fr);align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
|
||||
.ability-proc-controls{display:grid;grid-template-columns:minmax(240px,.7fr) minmax(180px,.4fr);align-items:end;gap:var(--shd-gap-sm)}
|
||||
.ability-proc-controls .field small,.proc-chance-field small,.probability-field small,.ability-mutation-row .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
|
||||
.health-boundaries{display:grid;grid-template-columns:repeat(2,minmax(150px,1fr));gap:var(--shd-gap-sm)}
|
||||
.health-boundaries .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
|
||||
.predicate-fields{display:grid;grid-template-columns:minmax(170px,1fr) minmax(190px,.8fr);align-items:end;gap:var(--shd-gap-sm)}
|
||||
@@ -1207,12 +1356,15 @@
|
||||
.branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)}
|
||||
.reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
|
||||
.mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)}
|
||||
.mutation-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
|
||||
.mutation-row{display:grid;grid-template-columns:minmax(260px,1fr) minmax(220px,1fr) minmax(110px,.35fr) minmax(240px,1fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
|
||||
.ability-mutation-row{grid-template-columns:minmax(240px,1fr) minmax(180px,.5fr) auto}.cooldown-mutation-row{grid-template-columns:minmax(210px,.8fr) minmax(220px,1fr) minmax(220px,1fr) auto}.probability-field{max-width:260px}
|
||||
.outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
|
||||
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
|
||||
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
|
||||
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
|
||||
.delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0}
|
||||
.area-targeting-note{grid-column:1/-1;display:grid;gap:2px;margin:0;padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-model-surface);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.area-targeting-note strong{color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
|
||||
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
|
||||
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
|
||||
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { GameDataTargetType } from '$lib/game-data-targets';
|
||||
|
||||
export type DatabaseBadge = { label: string; tone?: string };
|
||||
export type DatabaseMeter = { label: string; value: number };
|
||||
export type DatabaseWeaponModSlot = {
|
||||
slot: string;
|
||||
unlockedAtTierOne: boolean;
|
||||
iconUrl?: string;
|
||||
};
|
||||
export type DatabaseFact = {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -26,8 +31,10 @@ export type DatabaseItem = {
|
||||
renderUrl?: string;
|
||||
gameIcon?: GameIconName;
|
||||
headerBadge?: string;
|
||||
headerBadgeTone?: string;
|
||||
headerBadges?: string[];
|
||||
headerMetaIcon?: GameIconName;
|
||||
topBorderTone?: string;
|
||||
badges?: DatabaseBadge[];
|
||||
facts: DatabaseFact[];
|
||||
factLayout?: 'grid' | 'stack';
|
||||
@@ -35,9 +42,17 @@ export type DatabaseItem = {
|
||||
lines?: DatabaseFact[];
|
||||
afterLines?: DatabaseFact[];
|
||||
iconGroups?: DatabaseIconGroup[];
|
||||
weaponModSlots?: DatabaseWeaponModSlot[];
|
||||
description?: string;
|
||||
secondaryDescription?: string;
|
||||
descriptionStyle?: 'prose' | 'data';
|
||||
protocolPreview?: {
|
||||
primaryStat: string;
|
||||
primaryUnit: string;
|
||||
primaryBaseValue: number;
|
||||
talentDescription?: string;
|
||||
talentEffects: Array<{ baseValue: number; unit: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type DatabaseRelatedItem = {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MOD_SECONDARY_CAPACITY,
|
||||
normalizeModSecondaryPool,
|
||||
} from './secondary-pool';
|
||||
|
||||
const endpoints = (
|
||||
level1Min: number | string,
|
||||
level1Max: number | string,
|
||||
level20Min: number | string,
|
||||
level20Max: number | string,
|
||||
) => ({ level1Min, level1Max, level20Min, level20Max });
|
||||
|
||||
const range = (statId: string) => ({
|
||||
statId,
|
||||
values: {
|
||||
Specialized: endpoints('1.5', '2.5', '3.5', '4.5'),
|
||||
Superior: endpoints(2, 3, 4, 5),
|
||||
'High-end': endpoints(3, 4, 5, 6),
|
||||
},
|
||||
});
|
||||
|
||||
describe('shared Mod secondary pool', () => {
|
||||
it('defines secondary capacity for every Mod quality', () => {
|
||||
expect(MOD_SECONDARY_CAPACITY).toEqual({
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes Attribute IDs and valid Level 1/20 endpoints', () => {
|
||||
const result = normalizeModSecondaryPool([
|
||||
range(' critical-hit-chance '),
|
||||
range('critical-hit-damage'),
|
||||
]);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.pool).toEqual([
|
||||
{
|
||||
statId: 'critical-hit-chance',
|
||||
values: {
|
||||
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
|
||||
Superior: endpoints(2, 3, 4, 5),
|
||||
'High-end': endpoints(3, 4, 5, 6),
|
||||
},
|
||||
},
|
||||
{
|
||||
statId: 'critical-hit-damage',
|
||||
values: {
|
||||
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
|
||||
Superior: endpoints(2, 3, 4, 5),
|
||||
'High-end': endpoints(3, 4, 5, 6),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects duplicate Attributes after ID normalization', () => {
|
||||
const result = normalizeModSecondaryPool([
|
||||
range('critical-hit-chance'),
|
||||
range(' critical-hit-chance '),
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
pool: [],
|
||||
error: 'Choose a unique Attribute for every shared pool option.',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'missing quality ranges',
|
||||
{
|
||||
...range('critical-hit-chance'),
|
||||
values: {
|
||||
Specialized: endpoints(1, 2, 3, 4),
|
||||
Superior: endpoints(2, 3, 4, 5),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'reversed ranges',
|
||||
{
|
||||
...range('critical-hit-chance'),
|
||||
values: {
|
||||
...range('critical-hit-chance').values,
|
||||
Specialized: endpoints(2, 1, 3, 4),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'Level 20 regression',
|
||||
{
|
||||
...range('critical-hit-chance'),
|
||||
values: {
|
||||
...range('critical-hit-chance').values,
|
||||
Specialized: endpoints(2, 3, 1, 2),
|
||||
},
|
||||
},
|
||||
],
|
||||
])('rejects %s', (_label, invalidRange) => {
|
||||
const result = normalizeModSecondaryPool([
|
||||
invalidRange,
|
||||
range('critical-hit-damage'),
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
pool: [],
|
||||
error:
|
||||
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
export const MOD_QUALITIES = [
|
||||
'Standard',
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
|
||||
export const MOD_SECONDARY_QUALITIES = [
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
|
||||
export const MOD_SECONDARY_CAPACITY = {
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
} as const satisfies Record<(typeof MOD_QUALITIES)[number], number>;
|
||||
|
||||
export type ModSecondaryQuality = (typeof MOD_SECONDARY_QUALITIES)[number];
|
||||
|
||||
export type ModSecondaryRange = {
|
||||
statId: string;
|
||||
values: Record<
|
||||
ModSecondaryQuality,
|
||||
{
|
||||
level1Min: number;
|
||||
level1Max: number;
|
||||
level20Min: number;
|
||||
level20Max: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export function normalizeModSecondaryPool(input: unknown): {
|
||||
pool: ModSecondaryRange[];
|
||||
error: string | null;
|
||||
} {
|
||||
if (!Array.isArray(input) || input.length < 2 || input.length > 50) {
|
||||
return {
|
||||
pool: [],
|
||||
error: 'Configure between two and 50 shared secondary Attributes.',
|
||||
};
|
||||
}
|
||||
|
||||
const pool = input.map((candidate) => {
|
||||
const range = candidate as Partial<ModSecondaryRange>;
|
||||
return {
|
||||
statId: typeof range.statId === 'string' ? range.statId.trim() : '',
|
||||
values: Object.fromEntries(
|
||||
MOD_SECONDARY_QUALITIES.map((quality) => {
|
||||
const values = range.values?.[quality] as
|
||||
| Partial<ModSecondaryRange['values'][ModSecondaryQuality]>
|
||||
| undefined;
|
||||
return [
|
||||
quality,
|
||||
{
|
||||
level1Min: Number(values?.level1Min),
|
||||
level1Max: Number(values?.level1Max),
|
||||
level20Min: Number(values?.level20Min),
|
||||
level20Max: Number(values?.level20Max),
|
||||
},
|
||||
];
|
||||
}),
|
||||
) as ModSecondaryRange['values'],
|
||||
};
|
||||
});
|
||||
|
||||
if (
|
||||
pool.some((range) => !range.statId) ||
|
||||
new Set(pool.map((range) => range.statId)).size !== pool.length
|
||||
) {
|
||||
return {
|
||||
pool: [],
|
||||
error: 'Choose a unique Attribute for every shared pool option.',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
pool.some((range) =>
|
||||
MOD_SECONDARY_QUALITIES.some((quality) => {
|
||||
const values = range.values[quality];
|
||||
return (
|
||||
Object.values(values).some((value) => !Number.isFinite(value)) ||
|
||||
values.level1Max < values.level1Min ||
|
||||
values.level20Max < values.level20Min ||
|
||||
values.level20Min < values.level1Min ||
|
||||
values.level20Max < values.level1Max
|
||||
);
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return {
|
||||
pool: [],
|
||||
error:
|
||||
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
|
||||
};
|
||||
}
|
||||
|
||||
return { pool, error: null };
|
||||
}
|
||||
@@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
||||
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
||||
expect.arrayContaining(['delivery', 'durationSeconds']),
|
||||
);
|
||||
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
]),
|
||||
);
|
||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||
'Prompt contract version: 7',
|
||||
'Prompt contract version: 10',
|
||||
);
|
||||
|
||||
const advertised = structuredClone(
|
||||
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
|
||||
expect(parsed.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('advertises the canonical Extra Health change event contract in version 7', () => {
|
||||
it('advertises the canonical Extra Health change event contract in version 10', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request,
|
||||
@@ -196,9 +196,9 @@ describe('AI mechanic authoring prompt', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||
'Prompt contract version: 7',
|
||||
'Prompt contract version: 10',
|
||||
);
|
||||
expect(input.canonicalCatalog.events).toContainEqual({
|
||||
key: 'extra_health_changed',
|
||||
@@ -222,6 +222,144 @@ describe('AI mechanic authoring prompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises assigned-ability activity for kill and assist predicates', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request,
|
||||
attributes: [],
|
||||
events: [
|
||||
MECHANIC_EVENT_REGISTRY.enemy_killed,
|
||||
MECHANIC_EVENT_REGISTRY.assist,
|
||||
],
|
||||
actions: [],
|
||||
recipeKeys: [],
|
||||
}),
|
||||
);
|
||||
|
||||
for (const event of input.canonicalCatalog.events) {
|
||||
expect(event.fields).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'values.assignedAbilityActive',
|
||||
label: 'Assigned ability active',
|
||||
type: 'boolean',
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
expect(input.examplesAndRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('values.assignedAbilityActive'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('advertises and round-trips aimed event-target area damage', () => {
|
||||
const protocolRequest: AiMechanicAuthoringRequest = {
|
||||
...request,
|
||||
context: {
|
||||
sourceKind: 'os_protocol_talent',
|
||||
sourceName: 'Untitled mechanic',
|
||||
sourceDescription:
|
||||
'Critical Hits performed during aiming create an explosion with a radius of 5 meters around the target that deals damage equal to 2000% Firepower to them.',
|
||||
},
|
||||
valueColumns: [{ key: 'base', variantKey: 'base', label: 'BASE' }],
|
||||
allowProtocolUpgradeScaling: true,
|
||||
};
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request: protocolRequest,
|
||||
attributes: [
|
||||
{
|
||||
id: 'firepower-id',
|
||||
name: 'Firepower',
|
||||
description: null,
|
||||
unit: '',
|
||||
type: null,
|
||||
category: null,
|
||||
calculationType: null,
|
||||
},
|
||||
],
|
||||
events: [MECHANIC_EVENT_REGISTRY.weapon_hit],
|
||||
actions: [],
|
||||
recipeKeys: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(input.canonicalCatalog.events[0].fields).toContainEqual({
|
||||
path: 'values.aiming',
|
||||
label: 'Aiming down sights',
|
||||
type: 'boolean',
|
||||
});
|
||||
expect(
|
||||
input.canonicalCatalog.grammar.outcome.targeting.eventTargetArea,
|
||||
).toMatchObject({
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
});
|
||||
expect(input.examplesAndRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('values.aiming'),
|
||||
expect.stringContaining('event_target_area'),
|
||||
]),
|
||||
);
|
||||
|
||||
const advertised = structuredClone(
|
||||
input.canonicalCatalog.grammar.aimedCriticalAreaDamageShape,
|
||||
);
|
||||
const outcome = advertised.clauses[0].outcomes[0];
|
||||
const base = JSON.parse(
|
||||
JSON.stringify(
|
||||
outcome.variants['replace-with-each-required-variant-key'],
|
||||
).replaceAll(
|
||||
'replace-with-an-exact-catalog-attribute-id',
|
||||
'firepower-id',
|
||||
),
|
||||
);
|
||||
outcome.variants = { base };
|
||||
const parsed = parseConditionalMechanicDraft(advertised, {
|
||||
variantKeys: ['base'],
|
||||
allowedAttributeIds: new Set(['firepower-id']),
|
||||
});
|
||||
expect(parsed.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('advertises reproducible outcome and clause proc probabilities in version 10', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
request,
|
||||
attributes: [],
|
||||
events: [MECHANIC_EVENT_REGISTRY.skill_hit],
|
||||
actions: [],
|
||||
recipeKeys: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
||||
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
|
||||
expect.arrayContaining([
|
||||
'procChancePercent',
|
||||
'requiresAssignedAbilityActive',
|
||||
]),
|
||||
);
|
||||
expect(input.canonicalCatalog.grammar.mutationKinds).toEqual(
|
||||
expect.arrayContaining(['extend_duration', 'start_cooldown_on_max']),
|
||||
);
|
||||
expect(
|
||||
input.canonicalCatalog.grammar.probabilityOutcomeShape,
|
||||
).toMatchObject({
|
||||
probabilityPercent: 29,
|
||||
resource: 'ammo',
|
||||
});
|
||||
expect(input.examplesAndRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('procChancePercent'),
|
||||
expect.stringContaining('reproducible seeded random stream'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('advertises authoritative current Health percentage with strict band semantics', () => {
|
||||
const input = JSON.parse(
|
||||
buildAiMechanicAuthoringInput({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
|
||||
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
||||
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
||||
|
||||
export const AI_MECHANIC_PROMPT_VERSION = 7;
|
||||
export const AI_MECHANIC_PROMPT_VERSION = 10;
|
||||
|
||||
const mechanicsGrammar = {
|
||||
version: 1,
|
||||
@@ -40,7 +40,14 @@ const mechanicsGrammar = {
|
||||
},
|
||||
clause: {
|
||||
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
|
||||
optional: ['label', 'recipeKey', 'cooldownSeconds', 'weight'],
|
||||
optional: [
|
||||
'label',
|
||||
'recipeKey',
|
||||
'cooldownSeconds',
|
||||
'weight',
|
||||
'procChancePercent',
|
||||
'requiresAssignedAbilityActive',
|
||||
],
|
||||
},
|
||||
predicateKinds: ['true', 'exists', 'compare', 'all', 'any', 'not'],
|
||||
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
|
||||
@@ -63,6 +70,8 @@ const mechanicsGrammar = {
|
||||
'derive',
|
||||
'clear',
|
||||
'transfer',
|
||||
'extend_duration',
|
||||
'start_cooldown_on_max',
|
||||
],
|
||||
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
|
||||
valueReferences: [
|
||||
@@ -70,6 +79,7 @@ const mechanicsGrammar = {
|
||||
'scenario',
|
||||
'combat',
|
||||
'equipment',
|
||||
'assigned_ability',
|
||||
'attribute',
|
||||
'reducer',
|
||||
'pool',
|
||||
@@ -97,6 +107,7 @@ const mechanicsGrammar = {
|
||||
},
|
||||
},
|
||||
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
||||
{ ref: { source: 'assigned_ability', path: 'remainingDurationSeconds' } },
|
||||
{
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
@@ -200,6 +211,16 @@ const mechanicsGrammar = {
|
||||
value: 'all',
|
||||
clearSource: true,
|
||||
},
|
||||
{
|
||||
kind: 'extend_duration',
|
||||
target: 'assigned_ability',
|
||||
seconds: 0.3,
|
||||
},
|
||||
{
|
||||
kind: 'start_cooldown_on_max',
|
||||
stackPool: 'extension-stacks',
|
||||
cooldownPool: 'extension-cooldown',
|
||||
},
|
||||
],
|
||||
outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES,
|
||||
continuousRestorationOutcomeShape: {
|
||||
@@ -232,6 +253,83 @@ const mechanicsGrammar = {
|
||||
},
|
||||
},
|
||||
},
|
||||
probabilityOutcomeShape: {
|
||||
key: 'recover-ammo',
|
||||
label: 'Recover Ammo',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
resource: 'ammo',
|
||||
probabilityPercent: 29,
|
||||
variants: {
|
||||
'replace-with-each-required-variant-key': { value: 1 },
|
||||
},
|
||||
},
|
||||
aimedCriticalAreaDamageShape: {
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: [
|
||||
{
|
||||
key: 'aimed-critical-explosion',
|
||||
label: 'Aimed critical hit explosion',
|
||||
event: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.critical' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.aiming' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [
|
||||
{
|
||||
key: 'critical-explosion-damage',
|
||||
label: 'Critical explosion damage',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
variants: {
|
||||
'replace-with-each-required-variant-key': {
|
||||
value: 2000,
|
||||
expression: {
|
||||
op: 'multiply',
|
||||
left: 20,
|
||||
right: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'replace-with-an-exact-catalog-attribute-id',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
preciseTraumaShape: {
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
@@ -453,9 +551,16 @@ export function buildAiMechanicAuthoringInput(input: {
|
||||
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
|
||||
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
|
||||
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
|
||||
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
|
||||
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
|
||||
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
|
||||
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
||||
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
|
||||
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
|
||||
'When allowAssignedAbility is true, requiresAssignedAbilityActive gates a clause on the runtime assigned-ability state. skill_deployed and signature_ability_deployed may supply values.assignedAbilityDurationSeconds to begin that state. At an exact expiry timestamp, the ability expires before a later clause can extend it.',
|
||||
'procChancePercent is a clause-level independent percentage roll after its predicate and cooldown checks but before the reducer, mutations, and outcomes. Use it when a successful proc changes state. probabilityPercent is an outcome-level independent percentage roll for one outcome only.',
|
||||
'Custom simulation uses a reproducible seeded random stream. Maximum Potential resolves every nonzero chance as successful. Never use clause weight as a proc chance.',
|
||||
'extend_duration may target only assigned_ability and does nothing while the ability is inactive. start_cooldown_on_max starts a configured expiring Agent cooldown pool only when a mutation in the same clause transitions its capped stack pool to its maximum.',
|
||||
'If cadence is not stated, ask rather than assuming a one-second interval.',
|
||||
'If wording contains separate actions such as restoration on a periodic snapshot and a health cost on every shot, represent them as separate clauses.',
|
||||
'A pool with expiresAfterSeconds refreshes the shared expiry for every current stack whenever any mutation writes that pool. Use it for mechanics where gaining a stack refreshes the duration of the full stack pool.',
|
||||
|
||||
@@ -9,21 +9,35 @@ describe('AI mechanic authoring rate limit', () => {
|
||||
beforeEach(() => resetAiMechanicRateLimitForTests());
|
||||
|
||||
it('allows only one in-flight request per account', () => {
|
||||
const release = reserveAiMechanicGeneration('agent-1', 1_000);
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', 1_001)).toThrow(
|
||||
const release = reserveAiMechanicGeneration('agent-1', false, 1_000);
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', false, 1_001)).toThrow(
|
||||
AiMechanicRateLimitError,
|
||||
);
|
||||
release();
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', 1_002)).not.toThrow();
|
||||
expect(() =>
|
||||
reserveAiMechanicGeneration('agent-1', false, 1_002),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('enforces the hourly account limit', () => {
|
||||
for (let request = 0; request < 10; request += 1) {
|
||||
const release = reserveAiMechanicGeneration('agent-1', 10_000 + request);
|
||||
const release = reserveAiMechanicGeneration(
|
||||
'agent-1',
|
||||
false,
|
||||
10_000 + request,
|
||||
);
|
||||
release();
|
||||
}
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', 10_100)).toThrow(
|
||||
expect(() => reserveAiMechanicGeneration('agent-1', false, 10_100)).toThrow(
|
||||
'This account has reached the hourly mechanic authoring limit.',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not limit administrators', () => {
|
||||
const releases = Array.from({ length: 101 }, () =>
|
||||
reserveAiMechanicGeneration('admin-1', true, 10_000),
|
||||
);
|
||||
|
||||
for (const release of releases) release();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,12 @@ const retryAfter = (requests: number[], now: number) =>
|
||||
* duplicate submissions and basic abuse; OpenAI project budgets remain the hard
|
||||
* cross-instance spending boundary.
|
||||
*/
|
||||
export function reserveAiMechanicGeneration(userId: string, now = Date.now()) {
|
||||
export function reserveAiMechanicGeneration(
|
||||
userId: string,
|
||||
isAdmin = false,
|
||||
now = Date.now(),
|
||||
) {
|
||||
if (isAdmin) return () => {};
|
||||
if (inFlightUsers.has(userId)) {
|
||||
throw new AiMechanicRateLimitError(
|
||||
'A mechanic authoring request is already running for this account.',
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function recordIsacAudit(input: {
|
||||
export async function auditRecord(
|
||||
table: string,
|
||||
id: string,
|
||||
suppliedDatabase?: Database,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (
|
||||
![
|
||||
@@ -56,7 +57,7 @@ export async function auditRecord(
|
||||
) {
|
||||
throw new Error('Unsupported audit table');
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const database = suppliedDatabase ?? (await getDatabase());
|
||||
return (
|
||||
(await (
|
||||
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
|
||||
|
||||
@@ -187,6 +187,7 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
|
||||
combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT,
|
||||
delivery_duration_seconds REAL, targeting_json TEXT, min_state_value REAL, max_state_value REAL,
|
||||
probability_percent REAL,
|
||||
description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
|
||||
)`);
|
||||
|
||||
@@ -220,6 +221,8 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
key: 'weapon-kill',
|
||||
label: 'Weapon kill',
|
||||
event: 'enemy_killed',
|
||||
procChancePercent: 29,
|
||||
requiresAssignedAbilityActive: true,
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
@@ -255,6 +258,7 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
population: 'allies',
|
||||
radiusMeters: 40,
|
||||
},
|
||||
probabilityPercent: 29,
|
||||
variants: {
|
||||
I: { value: 240 },
|
||||
II: { value: 336 },
|
||||
@@ -263,12 +267,18 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
{
|
||||
key: 'target-detonation',
|
||||
label: 'Target Detonation',
|
||||
phase: 'on_exit',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
statePool: 'marked-target',
|
||||
perStack: true,
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
variants: {
|
||||
I: {
|
||||
value: 240,
|
||||
@@ -322,6 +332,23 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
mutations: [],
|
||||
outcomes: [],
|
||||
},
|
||||
{
|
||||
key: 'tactical-link-assist',
|
||||
label: 'Tactical Link assist',
|
||||
event: 'assist',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: {
|
||||
source: 'event',
|
||||
path: 'values.assignedAbilityActive',
|
||||
},
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -366,11 +393,23 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
population: 'allies',
|
||||
radiusMeters: 40,
|
||||
},
|
||||
probabilityPercent: 29,
|
||||
variants: {
|
||||
I: { value: 240 },
|
||||
II: { value: 336 },
|
||||
},
|
||||
});
|
||||
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
targeting: {
|
||||
kind: 'event_target_area',
|
||||
center: 'event_target',
|
||||
validTargets: 'enemies',
|
||||
radiusMeters: 5,
|
||||
},
|
||||
});
|
||||
expect(loaded?.clauses[0].if).toEqual({
|
||||
kind: 'compare',
|
||||
left: {
|
||||
@@ -381,6 +420,10 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
operator: 'lt',
|
||||
right: 75,
|
||||
});
|
||||
expect(loaded?.clauses[0]).toMatchObject({
|
||||
procChancePercent: 29,
|
||||
requiresAssignedAbilityActive: true,
|
||||
});
|
||||
expect(loaded?.pools[0]).toMatchObject({
|
||||
key: 'marked-target',
|
||||
stateKind: 'target_mark',
|
||||
@@ -399,7 +442,7 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
},
|
||||
]);
|
||||
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
|
||||
phase: 'on_exit',
|
||||
phase: 'after_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
statePool: 'marked-target',
|
||||
@@ -430,5 +473,14 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
right: true,
|
||||
},
|
||||
});
|
||||
expect(loaded?.clauses[2]).toMatchObject({
|
||||
event: 'assist',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.assignedAbilityActive' },
|
||||
operator: 'eq',
|
||||
right: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,7 @@ const outcomeMetadataColumns = [
|
||||
'delivery_mode',
|
||||
'delivery_duration_seconds',
|
||||
'targeting_json',
|
||||
'probability_percent',
|
||||
'state_pool_id',
|
||||
'min_state_value',
|
||||
'max_state_value',
|
||||
@@ -205,6 +206,10 @@ export function conditionalRuleDraftFromRows(
|
||||
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
|
||||
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
|
||||
.map((row): ConditionalMechanicClauseDraft => {
|
||||
const parameters = parseJson<Record<string, unknown>>(
|
||||
row.recipe_parameters_json,
|
||||
{},
|
||||
);
|
||||
const grouped = new Map<string, OutcomeRow[]>();
|
||||
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
|
||||
const key = String(outcome.effect_key);
|
||||
@@ -268,6 +273,10 @@ export function conditionalRuleDraftFromRows(
|
||||
...(first.targeting_json
|
||||
? { targeting: parseJson(first.targeting_json, undefined) }
|
||||
: {}),
|
||||
...(first.probability_percent === null ||
|
||||
first.probability_percent === undefined
|
||||
? {}
|
||||
: { probabilityPercent: Number(first.probability_percent) }),
|
||||
...(first.stat_attribute_id
|
||||
? { attributeId: String(first.stat_attribute_id) }
|
||||
: {}),
|
||||
@@ -329,6 +338,15 @@ export function conditionalRuleDraftFromRows(
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(typeof parameters.procChancePercent === 'number'
|
||||
? { procChancePercent: parameters.procChancePercent }
|
||||
: {}),
|
||||
...(typeof parameters.requiresAssignedAbilityActive === 'boolean'
|
||||
? {
|
||||
requiresAssignedAbilityActive:
|
||||
parameters.requiresAssignedAbilityActive,
|
||||
}
|
||||
: {}),
|
||||
...(row.cooldown_seconds === null || row.cooldown_seconds === undefined
|
||||
? {}
|
||||
: { cooldownSeconds: Number(row.cooldown_seconds) }),
|
||||
@@ -495,7 +513,17 @@ export async function saveConditionalRuleInTransaction(
|
||||
clause.label || clause.key,
|
||||
clause.event,
|
||||
clause.recipeKey ?? null,
|
||||
'{}',
|
||||
JSON.stringify({
|
||||
...(clause.procChancePercent === undefined
|
||||
? {}
|
||||
: { procChancePercent: clause.procChancePercent }),
|
||||
...(clause.requiresAssignedAbilityActive === undefined
|
||||
? {}
|
||||
: {
|
||||
requiresAssignedAbilityActive:
|
||||
clause.requiresAssignedAbilityActive,
|
||||
}),
|
||||
}),
|
||||
JSON.stringify(clause.if ?? { kind: 'true' }),
|
||||
JSON.stringify(clause.reducer),
|
||||
clause.weight === undefined ? null : JSON.stringify(clause.weight),
|
||||
@@ -518,9 +546,9 @@ export async function saveConditionalRuleInTransaction(
|
||||
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
|
||||
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
|
||||
resource_kind,delivery_mode,delivery_duration_seconds,
|
||||
targeting_json,min_state_value,max_state_value,description_slot,sort_order,
|
||||
targeting_json,probability_percent,min_state_value,max_state_value,description_slot,sort_order,
|
||||
created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
randomUUID(),
|
||||
ruleId,
|
||||
@@ -552,6 +580,7 @@ export async function saveConditionalRuleInTransaction(
|
||||
outcome.targeting === undefined
|
||||
? null
|
||||
: JSON.stringify(outcome.targeting),
|
||||
outcome.probabilityPercent ?? null,
|
||||
outcome.minStacks ?? null,
|
||||
outcome.maxStacks ?? null,
|
||||
outcome.descriptionSlot ?? null,
|
||||
|
||||
@@ -37,14 +37,31 @@ const proposalResultKeys: Partial<
|
||||
saveAugmentProfile: { saved: 'augmentProfileSaved' },
|
||||
},
|
||||
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
||||
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
|
||||
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
|
||||
weaponmods: {
|
||||
save: { saved: 'weaponModSaved', mode: 'weaponModMode' },
|
||||
saveSlotIcon: { saved: 'weaponModSlotIconSaved' },
|
||||
saveSecondaryPool: {
|
||||
saved: 'weaponModSecondaryPoolSaved',
|
||||
mode: 'weaponModSecondaryPoolMode',
|
||||
},
|
||||
},
|
||||
gearmods: {
|
||||
save: { saved: 'gearModSaved', mode: 'gearModMode' },
|
||||
saveSecondaryPool: {
|
||||
saved: 'gearModSecondaryPoolSaved',
|
||||
mode: 'gearModSecondaryPoolMode',
|
||||
},
|
||||
},
|
||||
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
|
||||
protocols: {
|
||||
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
||||
saveProfile: { saved: 'profileSaved' },
|
||||
},
|
||||
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
|
||||
skillchips: {
|
||||
save: { saved: 'skillChipSaved', mode: 'skillChipMode' },
|
||||
savePrimaryProfile: { saved: 'skillChipPrimaryProfileSaved' },
|
||||
saveSecondaryPool: { saved: 'skillChipSecondaryPoolSaved' },
|
||||
},
|
||||
specializations: {
|
||||
saveProgression: { saved: 'progressionSaved' },
|
||||
saveSpecialization: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BackgroundRemovalError,
|
||||
removeBackgroundPreview,
|
||||
} from './remove-background';
|
||||
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
|
||||
|
||||
export const GAME_ICON_TYPES = [
|
||||
'talent',
|
||||
@@ -16,6 +17,7 @@ export const GAME_ICON_TYPES = [
|
||||
'specialization',
|
||||
'specialization_ability',
|
||||
'specialization_node',
|
||||
'weapon_mod_slot',
|
||||
] as const;
|
||||
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
|
||||
export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024;
|
||||
@@ -23,7 +25,7 @@ export const MAX_GAME_ICON_PIXELS = 12_000_000;
|
||||
export const GAME_ICON_SIZE = 256;
|
||||
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||
const STORAGE_KEY_PATTERN =
|
||||
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
|
||||
/^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i;
|
||||
const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
|
||||
talent: 'Talents',
|
||||
brand_set: 'Brands',
|
||||
@@ -32,6 +34,7 @@ const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
|
||||
specialization: 'Specializations',
|
||||
specialization_ability: 'Specializations',
|
||||
specialization_node: 'Specializations',
|
||||
weapon_mod_slot: 'Weapon Mods',
|
||||
};
|
||||
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
||||
talent: 'Talent',
|
||||
@@ -41,6 +44,7 @@ const LIBRARY_LABEL: Record<GameDataIconType, string> = {
|
||||
specialization: 'Specialization',
|
||||
specialization_ability: 'Specialization Ability',
|
||||
specialization_node: 'Specialization Tree Node',
|
||||
weapon_mod_slot: 'Weapon Mod Slot',
|
||||
};
|
||||
|
||||
export class GameDataIconValidationError extends Error {}
|
||||
@@ -255,6 +259,13 @@ async function libraryMetadata(
|
||||
],
|
||||
};
|
||||
}
|
||||
case 'weapon_mod_slot': {
|
||||
const definition = weaponModSlotDefinitionById(entityId);
|
||||
return {
|
||||
name: definition ? `${definition.name} Slot` : null,
|
||||
tags: ['weapon mod', 'slot', definition?.name],
|
||||
};
|
||||
}
|
||||
}
|
||||
return { name: null, tags: [] };
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));
|
||||
const { mkdirMock, rmMock, writeFileMock } = vi.hoisted(() => ({
|
||||
mkdirMock: vi.fn(),
|
||||
rmMock: vi.fn(),
|
||||
writeFileMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:fs/promises')>()),
|
||||
mkdir: mkdirMock,
|
||||
rm: rmMock,
|
||||
writeFile: writeFileMock,
|
||||
}));
|
||||
|
||||
import { GAME_ICON_TYPES } from './game-data-icons';
|
||||
import {
|
||||
GAME_DATA_RENDER_TYPES,
|
||||
ManagedImageDeletionError,
|
||||
applyGameDataRender,
|
||||
deleteManagedImage,
|
||||
processManagedImage,
|
||||
} from './managed-images';
|
||||
@@ -26,6 +33,16 @@ class FakeDatabase {
|
||||
prepare(sql: string) {
|
||||
return Promise.resolve({
|
||||
get: async (...args: unknown[]) => {
|
||||
if (
|
||||
sql.startsWith('SELECT * FROM managed_images') &&
|
||||
sql.includes('source_entity_type')
|
||||
) {
|
||||
return [...this.images.values()].find(
|
||||
(row) =>
|
||||
row.source_entity_type === args[0] &&
|
||||
row.source_entity_id === args[1],
|
||||
);
|
||||
}
|
||||
if (sql.startsWith('SELECT * FROM managed_images'))
|
||||
return this.images.get(String(args[0]));
|
||||
if (sql.startsWith('SELECT * FROM game_data_icons')) {
|
||||
@@ -42,7 +59,64 @@ class FakeDatabase {
|
||||
}
|
||||
if (sql.startsWith('DELETE FROM managed_images')) {
|
||||
const row = this.images.get(String(args[0]));
|
||||
if (row?.storage_key === args[1]) this.images.delete(String(args[0]));
|
||||
if (!sql.includes('storage_key') || row?.storage_key === args[1])
|
||||
this.images.delete(String(args[0]));
|
||||
return;
|
||||
}
|
||||
if (sql.startsWith('UPDATE managed_images SET')) {
|
||||
const row = this.images.get(String(args[7]));
|
||||
if (!row) throw new Error('Missing managed image for update');
|
||||
this.images.set(String(args[7]), {
|
||||
...row,
|
||||
category: args[0],
|
||||
tags_json: args[1],
|
||||
is_icon: 0,
|
||||
force_white: 0,
|
||||
original_filename: args[2],
|
||||
byte_size: args[3],
|
||||
width: args[4],
|
||||
height: args[5],
|
||||
updated_at: args[6],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (sql.startsWith('INSERT INTO managed_images')) {
|
||||
const [
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
tagsJson,
|
||||
isIcon,
|
||||
forceWhite,
|
||||
storageKey,
|
||||
originalFilename,
|
||||
byteSize,
|
||||
width,
|
||||
height,
|
||||
sourceType,
|
||||
sourceEntityId,
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
] = args;
|
||||
this.images.set(String(id), {
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
tags_json: tagsJson,
|
||||
is_icon: isIcon,
|
||||
force_white: forceWhite,
|
||||
storage_key: storageKey,
|
||||
original_filename: originalFilename,
|
||||
byte_size: byteSize,
|
||||
width,
|
||||
height,
|
||||
source_entity_type: sourceType,
|
||||
source_entity_id: sourceEntityId,
|
||||
created_by: createdBy,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unexpected run: ${sql}`);
|
||||
@@ -210,3 +284,94 @@ describe('processManagedImage', () => {
|
||||
expect(metadata.height).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyGameDataRender', () => {
|
||||
beforeEach(() => {
|
||||
mkdirMock.mockReset().mockResolvedValue(undefined);
|
||||
rmMock.mockReset().mockResolvedValue(undefined);
|
||||
writeFileMock.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each(GAME_DATA_RENDER_TYPES)(
|
||||
'persists and returns authoritative %s render metadata',
|
||||
async (type) => {
|
||||
const database = new FakeDatabase();
|
||||
const result = await applyGameDataRender(
|
||||
database as never,
|
||||
type,
|
||||
sourceId,
|
||||
'Test Entity',
|
||||
{
|
||||
file: new File(['source'], 'source.png', { type: 'image/png' }),
|
||||
remove: false,
|
||||
processed: {
|
||||
output: Buffer.from('processed-render'),
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
},
|
||||
'operator-1',
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
original_filename: 'source.png',
|
||||
byte_size: Buffer.byteLength('processed-render'),
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
source_entity_type: `${type}_render`,
|
||||
source_entity_id: sourceId,
|
||||
created_by: 'operator-1',
|
||||
created_at: expect.any(String),
|
||||
updated_at: expect.any(String),
|
||||
});
|
||||
expect(writeFileMock).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it('returns refreshed replacement metadata without rewriting creator provenance', async () => {
|
||||
const database = new FakeDatabase();
|
||||
database.images.set(imageId, {
|
||||
id: imageId,
|
||||
name: 'Ability render',
|
||||
category: 'Specializations',
|
||||
tags_json: '[]',
|
||||
is_icon: 0,
|
||||
force_white: 0,
|
||||
storage_key: `library-${imageId}.webp`,
|
||||
original_filename: 'old.png',
|
||||
byte_size: 3,
|
||||
width: 100,
|
||||
height: 100,
|
||||
source_entity_type: 'specialization_ability_render',
|
||||
source_entity_id: sourceId,
|
||||
created_by: 'original-operator',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
updated_at: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const result = await applyGameDataRender(
|
||||
database as never,
|
||||
'specialization_ability',
|
||||
sourceId,
|
||||
'Ability',
|
||||
{
|
||||
file: new File(['new'], 'new.png', { type: 'image/png' }),
|
||||
remove: false,
|
||||
processed: {
|
||||
output: Buffer.from('replacement'),
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
},
|
||||
'replacement-operator',
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: imageId,
|
||||
original_filename: 'new.png',
|
||||
byte_size: Buffer.byteLength('replacement'),
|
||||
created_by: 'original-operator',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ export type GameDataRenderType = (typeof GAME_DATA_RENDER_TYPES)[number];
|
||||
const MAX_PIXELS = 24_000_000;
|
||||
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
||||
const KEY_PATTERN =
|
||||
/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
|
||||
/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i;
|
||||
|
||||
export class ManagedImageValidationError extends Error {}
|
||||
export class ManagedImageDeletionError extends Error {}
|
||||
@@ -250,7 +250,7 @@ export async function applyGameDataRender(
|
||||
);
|
||||
await (
|
||||
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
|
||||
original_filename=?,byte_size=?,width=?,height=?,created_by=?,updated_at=? WHERE id=?`)
|
||||
original_filename=?,byte_size=?,width=?,height=?,updated_at=? WHERE id=?`)
|
||||
).run(
|
||||
category[type],
|
||||
tags,
|
||||
@@ -258,19 +258,14 @@ export async function applyGameDataRender(
|
||||
processed.output.length,
|
||||
processed.width,
|
||||
processed.height,
|
||||
actorId,
|
||||
now,
|
||||
existing.id,
|
||||
);
|
||||
return {
|
||||
...existing,
|
||||
category: category[type],
|
||||
tags_json: tags,
|
||||
byte_size: processed.output.length,
|
||||
width: processed.width,
|
||||
height: processed.height,
|
||||
updated_at: now,
|
||||
};
|
||||
return (
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(existing.id)) ?? null
|
||||
);
|
||||
}
|
||||
const id = randomUUID();
|
||||
const storageKey = await saveManagedImageFile(id, processed.output);
|
||||
@@ -298,20 +293,11 @@ export async function applyGameDataRender(
|
||||
now,
|
||||
now,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
category: category[type],
|
||||
tags_json: tags,
|
||||
is_icon: 0,
|
||||
storage_key: storageKey,
|
||||
byte_size: processed.output.length,
|
||||
width: processed.width,
|
||||
height: processed.height,
|
||||
source_entity_type: sourceType,
|
||||
source_entity_id: entityId,
|
||||
updated_at: now,
|
||||
};
|
||||
return (
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(id)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteManagedImage(database: Database, id: string) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import {
|
||||
MOD_SECONDARY_QUALITIES,
|
||||
normalizeModSecondaryPool,
|
||||
type ModSecondaryRange,
|
||||
} from '$lib/mods/secondary-pool';
|
||||
|
||||
export type ModSecondaryPoolKind = 'weapon' | 'gear';
|
||||
|
||||
function tables(kind: ModSecondaryPoolKind) {
|
||||
return {
|
||||
options: `${kind}_mod_secondary_options`,
|
||||
values: `${kind}_mod_secondary_quality_values`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getModSecondaryPool(
|
||||
database: Database,
|
||||
kind: ModSecondaryPoolKind,
|
||||
): Promise<ModSecondaryRange[]> {
|
||||
const table = tables(kind);
|
||||
const [options, values] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT stat_attribute_id FROM ${table.options}
|
||||
ORDER BY stat_attribute_id`)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT * FROM ${table.values}
|
||||
ORDER BY stat_attribute_id,quality`)
|
||||
).all(),
|
||||
]);
|
||||
|
||||
const pool = options.map((option) => {
|
||||
const optionValues = values.filter(
|
||||
(value) => value.stat_attribute_id === option.stat_attribute_id,
|
||||
);
|
||||
if (
|
||||
optionValues.length !== MOD_SECONDARY_QUALITIES.length ||
|
||||
MOD_SECONDARY_QUALITIES.some(
|
||||
(quality) => !optionValues.some((value) => value.quality === quality),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`${kind} Mod secondary Attribute ${String(option.stat_attribute_id)} does not have a complete quality profile.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
statId: String(option.stat_attribute_id),
|
||||
values: Object.fromEntries(
|
||||
optionValues.map((value) => [
|
||||
String(value.quality),
|
||||
{
|
||||
level1Min: Number(value.level_1_min),
|
||||
level1Max: Number(value.level_1_max),
|
||||
level20Min: Number(value.level_20_min),
|
||||
level20Max: Number(value.level_20_max),
|
||||
},
|
||||
]),
|
||||
) as ModSecondaryRange['values'],
|
||||
};
|
||||
});
|
||||
if (!pool.length) return [];
|
||||
const normalized = normalizeModSecondaryPool(pool);
|
||||
if (normalized.error) {
|
||||
throw new Error(`${kind} Mod secondary pool: ${normalized.error}`);
|
||||
}
|
||||
return normalized.pool;
|
||||
}
|
||||
|
||||
export async function replaceModSecondaryPool(
|
||||
database: Database,
|
||||
kind: ModSecondaryPoolKind,
|
||||
pool: ModSecondaryRange[],
|
||||
) {
|
||||
const table = tables(kind);
|
||||
await database.exec(`DELETE FROM ${table.values}`);
|
||||
await database.exec(`DELETE FROM ${table.options}`);
|
||||
for (const range of pool) {
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO ${table.options}(stat_attribute_id) VALUES(?)`,
|
||||
)
|
||||
).run(range.statId);
|
||||
for (const quality of MOD_SECONDARY_QUALITIES) {
|
||||
const values = range.values[quality];
|
||||
await (
|
||||
await database.prepare(`INSERT INTO ${table.values}
|
||||
(stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max)
|
||||
VALUES(?,?,?,?,?,?)`)
|
||||
).run(
|
||||
range.statId,
|
||||
quality,
|
||||
values.level1Min,
|
||||
values.level1Max,
|
||||
values.level20Min,
|
||||
values.level20Max,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getOsProtocolProfileAttributeIds,
|
||||
type OsProtocolAttributeType,
|
||||
} from './os-protocol-profiles';
|
||||
|
||||
const databases: Array<Awaited<ReturnType<typeof connect>>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(databases.splice(0).map((database) => database.close()));
|
||||
});
|
||||
|
||||
async function databaseWithAttributes() {
|
||||
const database = await connect(':memory:');
|
||||
databases.push(database);
|
||||
await database.exec(`CREATE TABLE stat_attributes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
unit TEXT NOT NULL,
|
||||
active INTEGER NOT NULL
|
||||
)`);
|
||||
for (const [id, name, unit] of [
|
||||
['firepower-flat', 'Firepower', ''],
|
||||
['firepower-percent', 'Firepower', '%'],
|
||||
['toughness-flat', 'Toughness', ''],
|
||||
['toughness-percent', 'Toughness', '%'],
|
||||
['engineering-flat', 'Engineering', ''],
|
||||
['weapon-damage-flat', 'Weapon Damage', ''],
|
||||
['weapon-damage-percent', 'Weapon Damage', '%'],
|
||||
['skill-damage-percent', 'Skill Damage', '%'],
|
||||
['health-flat', 'Health', ''],
|
||||
]) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO stat_attributes(id,name,unit,active) VALUES(?,?,?,1)',
|
||||
)
|
||||
).run(id, name, unit);
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
describe('OS Protocol profile Attributes', () => {
|
||||
it.each([
|
||||
[
|
||||
'Firepower',
|
||||
{
|
||||
Core: 'firepower-flat',
|
||||
Damage: 'weapon-damage-flat',
|
||||
Health: 'health-flat',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Toughness',
|
||||
{
|
||||
Core: 'toughness-flat',
|
||||
Damage: 'weapon-damage-flat',
|
||||
Health: 'health-flat',
|
||||
},
|
||||
],
|
||||
[
|
||||
'Engineering',
|
||||
{
|
||||
Core: 'engineering-flat',
|
||||
Damage: 'skill-damage-percent',
|
||||
Health: 'health-flat',
|
||||
},
|
||||
],
|
||||
] as const)('resolves the exact %s role rows', async (type, expected) => {
|
||||
const database = await databaseWithAttributes();
|
||||
|
||||
await expect(
|
||||
getOsProtocolProfileAttributeIds(
|
||||
database,
|
||||
type as OsProtocolAttributeType,
|
||||
),
|
||||
).resolves.toEqual(expected);
|
||||
});
|
||||
|
||||
it('reports a missing required role without selecting a similarly named row', async () => {
|
||||
const database = await databaseWithAttributes();
|
||||
await (
|
||||
await database.prepare(
|
||||
"DELETE FROM stat_attributes WHERE id='weapon-damage-flat'",
|
||||
)
|
||||
).run();
|
||||
|
||||
await expect(
|
||||
getOsProtocolProfileAttributeIds(database, 'Firepower'),
|
||||
).resolves.toEqual({
|
||||
Core: 'firepower-flat',
|
||||
Damage: null,
|
||||
Health: 'health-flat',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
|
||||
export const OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS = {
|
||||
Firepower: {
|
||||
Core: { name: 'Firepower', unit: '' },
|
||||
Damage: { name: 'Weapon Damage', unit: '' },
|
||||
Health: { name: 'Health', unit: '' },
|
||||
},
|
||||
Toughness: {
|
||||
Core: { name: 'Toughness', unit: '' },
|
||||
Damage: { name: 'Weapon Damage', unit: '' },
|
||||
Health: { name: 'Health', unit: '' },
|
||||
},
|
||||
Engineering: {
|
||||
Core: { name: 'Engineering', unit: '' },
|
||||
Damage: { name: 'Skill Damage', unit: '%' },
|
||||
Health: { name: 'Health', unit: '' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type OsProtocolAttributeType =
|
||||
keyof typeof OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS;
|
||||
export type OsProtocolSecondaryRole = 'Core' | 'Damage' | 'Health';
|
||||
|
||||
export async function getOsProtocolProfileAttributeIds(
|
||||
database: Database,
|
||||
attributeType: OsProtocolAttributeType,
|
||||
) {
|
||||
const specs = OS_PROTOCOL_PROFILE_ATTRIBUTE_SPECS[attributeType];
|
||||
const entries = await Promise.all(
|
||||
(
|
||||
Object.entries(specs) as Array<
|
||||
[OsProtocolSecondaryRole, { name: string; unit: string }]
|
||||
>
|
||||
).map(async ([role, spec]) => {
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM stat_attributes WHERE active=1 AND name=? COLLATE NOCASE AND unit=?',
|
||||
)
|
||||
).get(spec.name, spec.unit);
|
||||
return [role, row ? String(row.id) : null] as const;
|
||||
}),
|
||||
);
|
||||
return Object.fromEntries(entries) as Record<
|
||||
OsProtocolSecondaryRole,
|
||||
string | null
|
||||
>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
||||
|
||||
export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
const [
|
||||
@@ -16,6 +17,10 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
talentTemplates,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipPrimaryOptions,
|
||||
chipPrimaryQualityValues,
|
||||
chipSecondaryOptions,
|
||||
chipSecondaryQualityValues,
|
||||
chipTalents,
|
||||
chipTalentEffects,
|
||||
protocols,
|
||||
@@ -83,6 +88,18 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_primary_quality_values')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_secondary_options')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_secondary_quality_values',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
@@ -161,6 +178,16 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
),
|
||||
})),
|
||||
})),
|
||||
skillChipProfile: {
|
||||
primaryOptions: hydrateSkillChipProfileRows(
|
||||
chipPrimaryOptions,
|
||||
chipPrimaryQualityValues,
|
||||
),
|
||||
secondaryOptions: hydrateSkillChipProfileRows(
|
||||
chipSecondaryOptions,
|
||||
chipSecondaryQualityValues,
|
||||
),
|
||||
},
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find(
|
||||
(item) => item.skill_chip_set_id === set.id,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import {
|
||||
SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES,
|
||||
SKILL_CHIP_QUALITIES,
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
type SkillChipProfile,
|
||||
} from '$lib/skill-chips/profiles';
|
||||
|
||||
export async function getSkillChipPrimaryAttributeIds(database: Database) {
|
||||
const rows = await (
|
||||
await database.prepare(
|
||||
`SELECT id,name FROM stat_attributes
|
||||
WHERE active=1 AND name IN (${SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES.map(() => '?').join(',')})
|
||||
ORDER BY name COLLATE NOCASE`,
|
||||
)
|
||||
).all(...SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES);
|
||||
if (rows.length !== SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES.length)
|
||||
throw new Error(
|
||||
'All four fixed Skill Chip primary Attributes must be active.',
|
||||
);
|
||||
return rows.map((row) => String(row.id));
|
||||
}
|
||||
|
||||
async function getProfiles(
|
||||
database: Database,
|
||||
optionTable: string,
|
||||
valueTable: string,
|
||||
) {
|
||||
const [options, values] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
`SELECT stat_attribute_id FROM ${optionTable} ORDER BY stat_attribute_id`,
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
`SELECT * FROM ${valueTable} ORDER BY stat_attribute_id,quality`,
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
if (!options.length) return [];
|
||||
return options.map((option) => ({
|
||||
statId: String(option.stat_attribute_id),
|
||||
values: Object.fromEntries(
|
||||
values
|
||||
.filter((value) => value.stat_attribute_id === option.stat_attribute_id)
|
||||
.map((value) => [
|
||||
String(value.quality),
|
||||
{ min: Number(value.min_value), max: Number(value.max_value) },
|
||||
]),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export const getSkillChipPrimaryProfiles = (database: Database) =>
|
||||
getProfiles(
|
||||
database,
|
||||
'skill_chip_primary_options',
|
||||
'skill_chip_primary_quality_values',
|
||||
);
|
||||
|
||||
export const getSkillChipSecondaryPool = (database: Database) =>
|
||||
getProfiles(
|
||||
database,
|
||||
'skill_chip_secondary_options',
|
||||
'skill_chip_secondary_quality_values',
|
||||
);
|
||||
|
||||
async function replaceProfiles(
|
||||
database: Database,
|
||||
optionTable: string,
|
||||
valueTable: string,
|
||||
profiles: SkillChipProfile[],
|
||||
qualities: readonly string[],
|
||||
) {
|
||||
await database.exec(`DELETE FROM ${valueTable}`);
|
||||
await database.exec(`DELETE FROM ${optionTable}`);
|
||||
for (const profile of profiles) {
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO ${optionTable}(stat_attribute_id) VALUES(?)`,
|
||||
)
|
||||
).run(profile.statId);
|
||||
for (const quality of qualities) {
|
||||
const range = profile.values[quality];
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO ${valueTable}(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`,
|
||||
)
|
||||
).run(profile.statId, quality, range.min, range.max);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const replaceSkillChipPrimaryProfiles = (
|
||||
database: Database,
|
||||
profiles: SkillChipProfile[],
|
||||
) =>
|
||||
replaceProfiles(
|
||||
database,
|
||||
'skill_chip_primary_options',
|
||||
'skill_chip_primary_quality_values',
|
||||
profiles,
|
||||
SKILL_CHIP_QUALITIES,
|
||||
);
|
||||
|
||||
export const replaceSkillChipSecondaryPool = (
|
||||
database: Database,
|
||||
profiles: SkillChipProfile[],
|
||||
) =>
|
||||
replaceProfiles(
|
||||
database,
|
||||
'skill_chip_secondary_options',
|
||||
'skill_chip_secondary_quality_values',
|
||||
profiles,
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
);
|
||||
@@ -17,37 +17,51 @@ export async function refreshSpecializationFocusReady(
|
||||
abilityCount: 0,
|
||||
chipReadyCount: 0,
|
||||
};
|
||||
const [config, abilityCountRow, chipReadyRow, nodes, rankProfiles] =
|
||||
await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'",
|
||||
)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
|
||||
const [
|
||||
config,
|
||||
abilityCountRow,
|
||||
profileRow,
|
||||
chipReadyRow,
|
||||
nodes,
|
||||
rankProfiles,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT xp_points,shd_cache_points FROM specialization_progression_config WHERE id='default'",
|
||||
)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
|
||||
JOIN specialization_abilities ability ON ability.id=link.ability_id
|
||||
WHERE link.focus_id=? AND ability.active=1`)
|
||||
).get(focusId),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
|
||||
).get(focusId),
|
||||
(
|
||||
await database.prepare(`SELECT
|
||||
(SELECT COUNT(*) FROM skill_chip_primary_options)=4
|
||||
AND (SELECT COUNT(*) FROM skill_chip_primary_quality_values)=16
|
||||
AND (SELECT COUNT(*) FROM skill_chip_secondary_options)>=2
|
||||
AND (SELECT COUNT(*) FROM skill_chip_secondary_quality_values)=
|
||||
(SELECT COUNT(*)*3 FROM skill_chip_secondary_options) AS ready`)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM specialization_focus_abilities link
|
||||
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
|
||||
WHERE link.focus_id=? AND (
|
||||
SELECT COUNT(*) FROM skill_chip_sets chips
|
||||
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
|
||||
)=3`)
|
||||
).get(focusId),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number',
|
||||
)
|
||||
).all(focusId),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank',
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
).get(focusId),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT row_number,max_rank FROM specialization_tree_nodes WHERE focus_id=? AND active=1 ORDER BY row_number,column_number',
|
||||
)
|
||||
).all(focusId),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT rank,point_cost FROM specialization_rank_cost_profiles ORDER BY rank',
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
const pointForRank = new Map(
|
||||
rankProfiles.map((profile) => [
|
||||
Number(profile.rank),
|
||||
@@ -69,6 +83,7 @@ export async function refreshSpecializationFocusReady(
|
||||
Number(focus.active) === 1 &&
|
||||
abilityCount === 3 &&
|
||||
chipReadyCount === 3 &&
|
||||
Number(profileRow?.ready) === 1 &&
|
||||
nodes.length > 0 &&
|
||||
contiguousRows &&
|
||||
pointTotal === availablePoints;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
normalizeSkillChipProfiles,
|
||||
SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES,
|
||||
SKILL_CHIP_QUALITIES,
|
||||
SKILL_CHIP_SECONDARY_CAPACITY,
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
} from './profiles';
|
||||
|
||||
const primaryIds = [
|
||||
'skill-duration',
|
||||
'skill-radius',
|
||||
'skill-cooldown-recovery',
|
||||
'skill-intensity',
|
||||
] as const;
|
||||
|
||||
const ranges = (qualities: readonly string[], offset = 0) =>
|
||||
Object.fromEntries(
|
||||
qualities.map((quality, index) => [
|
||||
quality,
|
||||
{ min: String(offset + index + 1), max: offset + index + 2 },
|
||||
]),
|
||||
);
|
||||
|
||||
const profile = (statId: string, qualities: readonly string[], offset = 0) => ({
|
||||
statId,
|
||||
values: ranges(qualities, offset),
|
||||
});
|
||||
|
||||
describe('Skill Chip shared profiles', () => {
|
||||
it('defines the exact four fixed primary Attributes and all four qualities', () => {
|
||||
expect(SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES).toEqual([
|
||||
'Skill Duration',
|
||||
'Skill Radius',
|
||||
'Skill Cooldown Recovery',
|
||||
'Skill Intensity',
|
||||
]);
|
||||
expect(SKILL_CHIP_QUALITIES).toEqual([
|
||||
'Standard',
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
]);
|
||||
});
|
||||
|
||||
it('defines the 0/1/2/2 secondary capacity mapping', () => {
|
||||
expect(SKILL_CHIP_SECONDARY_CAPACITY).toEqual({
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes exactly the four fixed primary profiles across every quality', () => {
|
||||
const raw = primaryIds.map((statId, index) =>
|
||||
profile(` ${statId} `, SKILL_CHIP_QUALITIES, index * 10),
|
||||
);
|
||||
|
||||
const result = normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, {
|
||||
expectedStatIds: primaryIds,
|
||||
minimumOptions: 4,
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.profiles).toHaveLength(4);
|
||||
expect(result.profiles.map(({ statId }) => statId)).toEqual(primaryIds);
|
||||
expect(result.profiles[0]).toEqual({
|
||||
statId: 'skill-duration',
|
||||
values: {
|
||||
Standard: { min: 1, max: 2 },
|
||||
Specialized: { min: 2, max: 3 },
|
||||
Superior: { min: 3, max: 4 },
|
||||
'High-end': { min: 4, max: 5 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a primary profile that does not contain the exact fixed set', () => {
|
||||
const raw = primaryIds.map((statId) =>
|
||||
profile(statId, SKILL_CHIP_QUALITIES),
|
||||
);
|
||||
raw[3] = profile('weapon-damage', SKILL_CHIP_QUALITIES);
|
||||
|
||||
expect(
|
||||
normalizeSkillChipProfiles(raw, SKILL_CHIP_QUALITIES, {
|
||||
expectedStatIds: primaryIds,
|
||||
minimumOptions: 4,
|
||||
}),
|
||||
).toEqual({
|
||||
profiles: [],
|
||||
error:
|
||||
'The primary profile must contain the four fixed Skill Attributes.',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes secondary profiles across only the three supported qualities', () => {
|
||||
expect(SKILL_CHIP_SECONDARY_QUALITIES).toEqual([
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
]);
|
||||
const result = normalizeSkillChipProfiles(
|
||||
[
|
||||
profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES),
|
||||
profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES, 10),
|
||||
],
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
{ minimumOptions: 2 },
|
||||
);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.profiles[0]).toEqual({
|
||||
statId: 'critical-hit-chance',
|
||||
values: {
|
||||
Specialized: { min: 1, max: 2 },
|
||||
Superior: { min: 2, max: 3 },
|
||||
'High-end': { min: 3, max: 4 },
|
||||
},
|
||||
});
|
||||
expect(result.profiles[0].values).not.toHaveProperty('Standard');
|
||||
});
|
||||
|
||||
it('rejects duplicate Attributes after trimming IDs', () => {
|
||||
const result = normalizeSkillChipProfiles(
|
||||
[
|
||||
profile('critical-hit-chance', SKILL_CHIP_SECONDARY_QUALITIES),
|
||||
profile(' critical-hit-chance ', SKILL_CHIP_SECONDARY_QUALITIES),
|
||||
],
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
{ minimumOptions: 2 },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
profiles: [],
|
||||
error: 'Choose a unique Attribute for every shared option.',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'missing ranges',
|
||||
{
|
||||
Specialized: { min: 1, max: 2 },
|
||||
Superior: { min: 2, max: 3 },
|
||||
},
|
||||
'High-end requires a minimum and maximum.',
|
||||
],
|
||||
[
|
||||
'reversed ranges',
|
||||
{
|
||||
Specialized: { min: 2, max: 1 },
|
||||
Superior: { min: 2, max: 3 },
|
||||
'High-end': { min: 3, max: 4 },
|
||||
},
|
||||
'Specialized requires a valid non-negative range.',
|
||||
],
|
||||
[
|
||||
'negative ranges',
|
||||
{
|
||||
Specialized: { min: -1, max: 2 },
|
||||
Superior: { min: 2, max: 3 },
|
||||
'High-end': { min: 3, max: 4 },
|
||||
},
|
||||
'Specialized requires a valid non-negative range.',
|
||||
],
|
||||
])('rejects %s', (_label, values, error) => {
|
||||
const result = normalizeSkillChipProfiles(
|
||||
[
|
||||
{ statId: 'critical-hit-chance', values },
|
||||
profile('critical-hit-damage', SKILL_CHIP_SECONDARY_QUALITIES),
|
||||
],
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
{ minimumOptions: 2 },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ profiles: [], error });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
export const SKILL_CHIP_QUALITIES = [
|
||||
'Standard',
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
|
||||
export const SKILL_CHIP_SECONDARY_QUALITIES = [
|
||||
'Specialized',
|
||||
'Superior',
|
||||
'High-end',
|
||||
] as const;
|
||||
|
||||
export const SKILL_CHIP_SECONDARY_CAPACITY = {
|
||||
Standard: 0,
|
||||
Specialized: 1,
|
||||
Superior: 2,
|
||||
'High-end': 2,
|
||||
} as const satisfies Record<(typeof SKILL_CHIP_QUALITIES)[number], number>;
|
||||
|
||||
export const SKILL_CHIP_PRIMARY_ATTRIBUTE_NAMES = [
|
||||
'Skill Duration',
|
||||
'Skill Radius',
|
||||
'Skill Cooldown Recovery',
|
||||
'Skill Intensity',
|
||||
] as const;
|
||||
|
||||
export type SkillChipRange = { min: number; max: number };
|
||||
export type SkillChipProfile = {
|
||||
statId: string;
|
||||
values: Record<string, SkillChipRange>;
|
||||
};
|
||||
|
||||
export function hydrateSkillChipProfileRows(
|
||||
options: Array<Record<string, unknown>>,
|
||||
qualityValues: Array<Record<string, unknown>>,
|
||||
) {
|
||||
return options.map((option) => ({
|
||||
statId: String(option.stat_attribute_id),
|
||||
ranges: Object.fromEntries(
|
||||
qualityValues
|
||||
.filter((range) => range.stat_attribute_id === option.stat_attribute_id)
|
||||
.map((range) => [
|
||||
String(range.quality),
|
||||
{ min: Number(range.min_value), max: Number(range.max_value) },
|
||||
]),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
const finite = (value: unknown) => {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
};
|
||||
|
||||
export function normalizeSkillChipProfiles(
|
||||
raw: unknown,
|
||||
qualities: readonly string[],
|
||||
options: {
|
||||
expectedStatIds?: readonly string[];
|
||||
minimumOptions?: number;
|
||||
} = {},
|
||||
): { profiles: SkillChipProfile[]; error: string | null } {
|
||||
if (!Array.isArray(raw))
|
||||
return { profiles: [], error: 'The shared profile payload is invalid.' };
|
||||
if (raw.length < (options.minimumOptions ?? 1) || raw.length > 50)
|
||||
return {
|
||||
profiles: [],
|
||||
error: `Configure ${options.minimumOptions ?? 1}–50 shared Attribute options.`,
|
||||
};
|
||||
|
||||
const profiles: SkillChipProfile[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
||||
return { profiles: [], error: 'A shared Attribute option is invalid.' };
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
const statId = String(candidate.statId ?? '').trim();
|
||||
if (!statId || seen.has(statId))
|
||||
return {
|
||||
profiles: [],
|
||||
error: 'Choose a unique Attribute for every shared option.',
|
||||
};
|
||||
seen.add(statId);
|
||||
const rawValues = candidate.values;
|
||||
if (!rawValues || typeof rawValues !== 'object' || Array.isArray(rawValues))
|
||||
return {
|
||||
profiles: [],
|
||||
error: 'Every shared Attribute needs quality ranges.',
|
||||
};
|
||||
const values: Record<string, SkillChipRange> = {};
|
||||
for (const quality of qualities) {
|
||||
const rawRange = (rawValues as Record<string, unknown>)[quality];
|
||||
if (!rawRange || typeof rawRange !== 'object' || Array.isArray(rawRange))
|
||||
return {
|
||||
profiles: [],
|
||||
error: `${quality} requires a minimum and maximum.`,
|
||||
};
|
||||
const range = rawRange as Record<string, unknown>;
|
||||
const min = finite(range.min);
|
||||
const max = finite(range.max);
|
||||
if (min === null || max === null || min < 0 || max < min)
|
||||
return {
|
||||
profiles: [],
|
||||
error: `${quality} requires a valid non-negative range.`,
|
||||
};
|
||||
values[quality] = { min, max };
|
||||
}
|
||||
profiles.push({ statId, values });
|
||||
}
|
||||
|
||||
if (options.expectedStatIds) {
|
||||
const expected = [...options.expectedStatIds].sort();
|
||||
const received = profiles.map((profile) => profile.statId).sort();
|
||||
if (
|
||||
expected.length !== received.length ||
|
||||
expected.some((statId, index) => statId !== received[index])
|
||||
)
|
||||
return {
|
||||
profiles: [],
|
||||
error:
|
||||
'The primary profile must contain the four fixed Skill Attributes.',
|
||||
};
|
||||
}
|
||||
return { profiles, error: null };
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
WEAPON_MOD_SLOT_DEFINITIONS,
|
||||
WEAPON_MOD_SLOT_TYPES,
|
||||
excludedTierZeroWeaponModSlot,
|
||||
expandSharedWeaponModSlots,
|
||||
normalizeWeaponModSlotPayload,
|
||||
publicWeaponModSlots,
|
||||
sharedWeaponModSlots,
|
||||
validateWeaponModSlots,
|
||||
weaponModSlotDefinitionById,
|
||||
weaponModSlotDefinitionByName,
|
||||
weaponModSlotsComplete,
|
||||
type WeaponModSlotConfig,
|
||||
} from './mod-slots';
|
||||
@@ -15,6 +20,23 @@ const configuration = (tier: number, slots: string[]): WeaponModSlotConfig => ({
|
||||
});
|
||||
|
||||
describe('weapon mod slot configuration', () => {
|
||||
it('assigns one stable UUID to every canonical slot type', () => {
|
||||
expect(WEAPON_MOD_SLOT_DEFINITIONS).toHaveLength(8);
|
||||
expect(new Set(WEAPON_MOD_SLOT_DEFINITIONS.map(({ id }) => id)).size).toBe(
|
||||
8,
|
||||
);
|
||||
expect(WEAPON_MOD_SLOT_DEFINITIONS.map(({ name }) => name)).toEqual(
|
||||
WEAPON_MOD_SLOT_TYPES,
|
||||
);
|
||||
for (const definition of WEAPON_MOD_SLOT_DEFINITIONS) {
|
||||
expect(definition.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(weaponModSlotDefinitionById(definition.id)).toBe(definition);
|
||||
expect(weaponModSlotDefinitionByName(definition.name)).toBe(definition);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts missing and partial slot data for later configuration', () => {
|
||||
const slots = [
|
||||
configuration(0, []),
|
||||
@@ -99,4 +121,31 @@ describe('weapon mod slot configuration', () => {
|
||||
]),
|
||||
).toEqual([configuration(1, ['Optics', 'Muzzle', 'Barrel'])]);
|
||||
});
|
||||
|
||||
it('marks the slot gained by a native T0 weapon at T1', () => {
|
||||
const slots = expandSharedWeaponModSlots(
|
||||
['Magazine', 'Optics', 'Barrel', 'Muzzle', 'Underbarrel'],
|
||||
'Barrel',
|
||||
);
|
||||
|
||||
expect(publicWeaponModSlots(0, slots)).toEqual([
|
||||
{ slot: 'Optics', unlockedAtTierOne: false },
|
||||
{ slot: 'Muzzle', unlockedAtTierOne: false },
|
||||
{ slot: 'Underbarrel', unlockedAtTierOne: false },
|
||||
{ slot: 'Magazine', unlockedAtTierOne: false },
|
||||
{ slot: 'Barrel', unlockedAtTierOne: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows native T1 slots without an upgrade-only state', () => {
|
||||
expect(
|
||||
publicWeaponModSlots(1, [
|
||||
configuration(1, ['Magazine', 'Optics', 'Muzzle']),
|
||||
]),
|
||||
).toEqual([
|
||||
{ slot: 'Optics', unlockedAtTierOne: false },
|
||||
{ slot: 'Muzzle', unlockedAtTierOne: false },
|
||||
{ slot: 'Magazine', unlockedAtTierOne: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
export const WEAPON_MOD_SLOT_TYPES = [
|
||||
'Optics',
|
||||
'Muzzle',
|
||||
'Underbarrel',
|
||||
'Magazine',
|
||||
'Barrel',
|
||||
'Upper Receiver',
|
||||
'Lower Receiver',
|
||||
'Trigger',
|
||||
export const WEAPON_MOD_SLOT_DEFINITIONS = [
|
||||
{ id: '28ffc3b1-5d98-4273-a9af-67dbff3e5c76', name: 'Optics' },
|
||||
{ id: 'c4598f51-2cb0-4a20-a3a7-6d8983a26598', name: 'Muzzle' },
|
||||
{ id: '85ca3493-1871-4d22-950a-ed718fd547e0', name: 'Underbarrel' },
|
||||
{ id: '4d809a35-8285-4bff-be98-a0effe1d257e', name: 'Magazine' },
|
||||
{ id: '0e157cfe-975c-441d-8f49-72983161e7b1', name: 'Barrel' },
|
||||
{ id: '551fd7c8-3e5b-4c89-8437-9b901e89e8aa', name: 'Upper Receiver' },
|
||||
{ id: '72573367-6e91-4312-9cc3-710ede4f078e', name: 'Lower Receiver' },
|
||||
{ id: '8dda5edb-6079-4a23-a556-cd9be4521c70', name: 'Trigger' },
|
||||
] as const;
|
||||
|
||||
export type WeaponModSlotType =
|
||||
(typeof WEAPON_MOD_SLOT_DEFINITIONS)[number]['name'];
|
||||
|
||||
export const WEAPON_MOD_SLOT_TYPES = WEAPON_MOD_SLOT_DEFINITIONS.map(
|
||||
(definition) => definition.name,
|
||||
) as WeaponModSlotType[];
|
||||
|
||||
export function weaponModSlotDefinitionById(id: string) {
|
||||
return WEAPON_MOD_SLOT_DEFINITIONS.find((definition) => definition.id === id);
|
||||
}
|
||||
|
||||
export function weaponModSlotDefinitionByName(name: string) {
|
||||
return WEAPON_MOD_SLOT_DEFINITIONS.find(
|
||||
(definition) => definition.name === name,
|
||||
);
|
||||
}
|
||||
|
||||
export type WeaponModSlotConfig = {
|
||||
tier: number;
|
||||
slots: string[];
|
||||
};
|
||||
|
||||
export type PublicWeaponModSlot = {
|
||||
slot: string;
|
||||
unlockedAtTierOne: boolean;
|
||||
iconUrl?: string;
|
||||
};
|
||||
|
||||
export function normalizeWeaponModSlotPayload(
|
||||
payload: unknown,
|
||||
): WeaponModSlotConfig[] {
|
||||
@@ -52,6 +75,33 @@ export function sharedWeaponModSlots(
|
||||
];
|
||||
}
|
||||
|
||||
export function publicWeaponModSlots(
|
||||
nativeTier: number,
|
||||
configurations: WeaponModSlotConfig[],
|
||||
): PublicWeaponModSlot[] {
|
||||
const nativeSlots =
|
||||
configurations.find((row) => Number(row.tier) === nativeTier)?.slots ?? [];
|
||||
const displayedSlots =
|
||||
nativeTier === 0
|
||||
? sharedWeaponModSlots(configurations).length
|
||||
? sharedWeaponModSlots(configurations)
|
||||
: nativeSlots
|
||||
: nativeSlots;
|
||||
const ordered = [...new Set(displayedSlots)].sort(
|
||||
(left, right) =>
|
||||
WEAPON_MOD_SLOT_TYPES.indexOf(
|
||||
left as (typeof WEAPON_MOD_SLOT_TYPES)[number],
|
||||
) -
|
||||
WEAPON_MOD_SLOT_TYPES.indexOf(
|
||||
right as (typeof WEAPON_MOD_SLOT_TYPES)[number],
|
||||
),
|
||||
);
|
||||
return ordered.map((slot) => ({
|
||||
slot,
|
||||
unlockedAtTierOne: nativeTier === 0 && !nativeSlots.includes(slot),
|
||||
}));
|
||||
}
|
||||
|
||||
export function excludedTierZeroWeaponModSlot(
|
||||
sharedSlots: string[],
|
||||
configurations: WeaponModSlotConfig[],
|
||||
|
||||
@@ -92,6 +92,10 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
||||
WHERE users.id=?`)
|
||||
).get(agentId);
|
||||
const isSelf = viewer?.id === agentId;
|
||||
const canManageNews =
|
||||
isSelf &&
|
||||
(viewerPermissions.includes('*') ||
|
||||
viewerPermissions.includes('news.manage'));
|
||||
if (unavailable(agent) || (!Boolean(agent?.is_public) && !isSelf))
|
||||
error(404, 'Agent profile not found');
|
||||
if (params.id !== String(agent.public_slug)) {
|
||||
@@ -129,6 +133,9 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
||||
profileCommentCount,
|
||||
buildCount,
|
||||
guideCount,
|
||||
ownedBuildRows,
|
||||
ownedGuideRows,
|
||||
ownedNewsRows,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT roles.name FROM user_roles
|
||||
@@ -216,6 +223,28 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM articles
|
||||
WHERE kind='guide' AND owner_id=? AND visibility='public' AND deleted_at IS NULL`)
|
||||
).get(agentId),
|
||||
isSelf
|
||||
? (
|
||||
await database.prepare(`SELECT id,slug,title,visibility,updated_at
|
||||
FROM builds WHERE owner_id=? ORDER BY updated_at DESC`)
|
||||
).all(agentId)
|
||||
: [],
|
||||
isSelf
|
||||
? (
|
||||
await database.prepare(`SELECT articles.id,articles.slug,draft.title,articles.visibility,articles.updated_at
|
||||
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id
|
||||
WHERE articles.kind='guide' AND articles.owner_id=? AND articles.deleted_at IS NULL
|
||||
ORDER BY articles.updated_at DESC`)
|
||||
).all(agentId)
|
||||
: [],
|
||||
canManageNews
|
||||
? (
|
||||
await database.prepare(`SELECT articles.id,articles.slug,draft.title,articles.visibility,articles.updated_at
|
||||
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id
|
||||
WHERE articles.kind='news' AND articles.owner_id=? AND articles.deleted_at IS NULL
|
||||
ORDER BY articles.updated_at DESC`)
|
||||
).all(agentId)
|
||||
: [],
|
||||
]);
|
||||
|
||||
const loadedCommentIds = new Set(
|
||||
@@ -401,6 +430,32 @@ export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
||||
title: String(guide.title),
|
||||
updatedAt: String(guide.updated_at),
|
||||
})),
|
||||
ownedContent: isSelf
|
||||
? {
|
||||
builds: ownedBuildRows.map((build) => ({
|
||||
id: String(build.id),
|
||||
slug: String(build.slug),
|
||||
title: String(build.title),
|
||||
visibility: String(build.visibility),
|
||||
updatedAt: String(build.updated_at),
|
||||
})),
|
||||
guides: ownedGuideRows.map((guide) => ({
|
||||
id: String(guide.id),
|
||||
slug: String(guide.slug),
|
||||
title: String(guide.title),
|
||||
visibility: String(guide.visibility),
|
||||
updatedAt: String(guide.updated_at),
|
||||
})),
|
||||
news: ownedNewsRows.map((news) => ({
|
||||
id: String(news.id),
|
||||
slug: String(news.slug),
|
||||
title: String(news.title),
|
||||
visibility: String(news.visibility),
|
||||
updatedAt: String(news.updated_at),
|
||||
})),
|
||||
canManageNews,
|
||||
}
|
||||
: null,
|
||||
authoredComments: authoredCommentRows.map((comment) => {
|
||||
const targetType = String(comment.target_type);
|
||||
const targetId = String(comment.target_id);
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
type OwnedContent = PublicContent & { visibility: string };
|
||||
type AuthoredComment = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -60,6 +61,12 @@
|
||||
counts: { builds: number; guides: number; authoredComments: number; profileComments: number };
|
||||
builds: PublicContent[];
|
||||
guides: PublicContent[];
|
||||
ownedContent: {
|
||||
builds: OwnedContent[];
|
||||
guides: OwnedContent[];
|
||||
news: OwnedContent[];
|
||||
canManageNews: boolean;
|
||||
} | null;
|
||||
authoredComments: AuthoredComment[];
|
||||
profileComments: ProfileComment[];
|
||||
viewer: { id: string | null; canComment: boolean; canReportProfile: boolean; isSelf: boolean };
|
||||
@@ -86,6 +93,7 @@
|
||||
? 'DATE UNKNOWN'
|
||||
: date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).toUpperCase();
|
||||
};
|
||||
const visibilityLabel = (visibility: string) => visibility.toUpperCase().replace('_', ' ');
|
||||
|
||||
$effect(() => {
|
||||
const token = String(form?.profileCommentResultToken ?? form?.commentResultToken ?? '');
|
||||
@@ -242,6 +250,44 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if data.viewer.isSelf && data.ownedContent}
|
||||
<section class="profile-section content-control">
|
||||
<header>
|
||||
<div><p class="eyebrow">OWNER WORKSPACE</p><h2>CONTENT CONTROL</h2></div>
|
||||
<span>PRIVATE TO YOU</span>
|
||||
</header>
|
||||
<p class="content-control-intro">Open any authored item to edit it or change its visibility. Private and unlisted items appear only here.</p>
|
||||
<div class="content-control-grid">
|
||||
<section>
|
||||
<header><span>LOADOUTS</span><a href="/builder">NEW BUILD +</a></header>
|
||||
<div class="content-control-list">
|
||||
{#each data.ownedContent.builds as build}
|
||||
<a href={`/builder?build=${encodeURIComponent(build.slug)}`}><span><b>{build.title}</b><small>UPDATED {dateLabel(build.updatedAt)}</small></span><em>{visibilityLabel(build.visibility)}</em><strong>EDIT →</strong></a>
|
||||
{:else}<p class="content-control-empty">No builds yet. Create one in the Loadout Lab.</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<header><span>GUIDES</span><a href="/guides/new">NEW GUIDE +</a></header>
|
||||
<div class="content-control-list">
|
||||
{#each data.ownedContent.guides as guide}
|
||||
<a href={`/guides/${encodeURIComponent(guide.slug)}/edit`}><span><b>{guide.title}</b><small>UPDATED {dateLabel(guide.updatedAt)}</small></span><em>{visibilityLabel(guide.visibility)}</em><strong>EDIT →</strong></a>
|
||||
{:else}<p class="content-control-empty">No Guides yet. Start a field guide when you are ready.</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
{#if data.ownedContent.canManageNews}
|
||||
<section>
|
||||
<header><span>NEWS POSTS</span><a href="/news/new">NEW ARTICLE +</a></header>
|
||||
<div class="content-control-list">
|
||||
{#each data.ownedContent.news as news}
|
||||
<a href={`/news/${encodeURIComponent(news.slug)}/edit`}><span><b>{news.title}</b><small>UPDATED {dateLabel(news.updatedAt)}</small></span><em>{visibilityLabel(news.visibility)}</em><strong>EDIT →</strong></a>
|
||||
{:else}<p class="content-control-empty">No news posts authored by this Agent.</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<section class="profile-comms" id="profile-comments">
|
||||
@@ -392,6 +438,7 @@
|
||||
.profile-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.profile-section>header{min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.profile-section>header>div{display:grid;gap:2px}.profile-section>header :is(h1,h2,p){margin:0}.profile-section>header h1,.profile-section>header h2{font-size:var(--shd-type-body);letter-spacing:.4px}.profile-section>header>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
|
||||
.bio-content{min-height:110px;padding:18px}:global(form.profile-bio-form){display:grid;gap:10px;padding:16px}.profile-bio-actions{display:flex;justify-content:flex-end;gap:7px}.profile-bio-actions button{min-height:40px}.profile-owner-notice{display:grid;gap:4px;margin:12px 16px 0;padding:10px;border:1px solid #745b2b;background:#1c170e;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.profile-owner-notice strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.profile-owner-notice.error{border-color:#70423b;background:#211414}.profile-owner-notice.error strong{color:var(--shd-danger)}.owner-profile-result{margin:10px 16px 0}.public-content-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:7px}
|
||||
.public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)}
|
||||
.content-control{margin-top:10px}.content-control-intro{margin:0;padding:10px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.content-control-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;padding:7px}.content-control-grid>section{min-width:0;border:1px solid var(--shd-border);background:#0d1011}.content-control-grid>section>header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.content-control-grid>section>header :is(span,a){font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.content-control-grid>section>header span{color:var(--shd-text-muted)}.content-control-grid>section>header a,.content-control-list strong{color:var(--shd-orange)}.content-control-list{display:grid}.content-control-list>a{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;padding:10px;border-top:1px solid var(--shd-border);color:var(--shd-text)}.content-control-list>a:first-child{border-top:0}.content-control-list>a>span{min-width:0;display:grid;gap:3px}.content-control-list b{overflow:hidden;font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.content-control-list small,.content-control-list em,.content-control-list strong{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px}.content-control-list small{color:var(--shd-text-dim)}.content-control-list em{justify-self:start;padding:2px 5px;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-style:normal}.content-control-list strong{grid-column:2;grid-row:1 / span 2;align-self:center}.content-control-list>a:hover,.content-control-list>a:focus-visible{background:var(--shd-surface-active);outline:1px solid var(--shd-orange);outline-offset:-1px}.content-control-empty{min-height:92px;display:grid;place-content:center;margin:0;padding:12px;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);text-align:center}
|
||||
.public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px}
|
||||
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:10px;padding:11px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;padding:0;border:0;background:transparent}.authored-comment-list article>header>div{min-width:0;display:grid;align-content:center;gap:2px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{align-self:center;color:var(--shd-text-muted);text-align:right;white-space:nowrap}.authored-comment-list article>a{justify-self:end}
|
||||
.profile-comms{margin-top:16px;padding:0;background:transparent}.profile-comms-heading{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:end;justify-content:space-between;gap:16px;padding:0 0 12px;border:0;border-bottom:1px solid var(--shd-border);background:transparent}.profile-comms-heading>div{display:grid;gap:3px}.profile-comms-heading :is(h2,p){margin:0}.profile-comms-heading h2{font-size:1.25rem}.profile-comms-heading>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.profile-comment-start{width:min(920px,100%);padding-top:16px}.profile-comment-trigger{width:100%;min-height:48px;padding:0 14px;border:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);text-align:left;cursor:pointer}.profile-comment-trigger:hover,.profile-comment-trigger:focus-visible,.profile-comment-trigger[aria-expanded=true]{border-color:var(--shd-orange);color:var(--shd-text);background:var(--shd-surface-active)}:global(form.profile-comment-form){width:min(920px,100%);display:grid;gap:9px;padding:14px 0 0;border:0;background:transparent}.profile-comment-form-actions{display:flex;justify-content:flex-end;gap:7px}.profile-comment-form-actions button{min-height:38px}.profile-comment-list{width:min(980px,100%);display:grid;margin-top:26px;overflow:visible}.profile-comment-list>article{--thread-depth:0;position:relative;display:grid;gap:8px;margin:18px 0 0 calc(var(--thread-depth) * 22px);padding:0;scroll-margin-top:96px;background:transparent}.profile-comment-list>article:target{outline:1px solid var(--shd-orange);outline-offset:8px}.profile-comment-list>article.reply{padding-left:14px;border-left:1px solid var(--shd-border-strong)}.profile-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:center;justify-content:flex-start;gap:12px;padding:0;border:0;background:transparent}.profile-comment-list article>header :global(.agent-identity){flex:0 1 auto}.profile-comment-list article :global(.markdown-content){max-width:75ch;margin-left:47px}.comment-actions{display:flex;align-items:center;gap:14px;min-height:32px;margin-left:47px}.comment-reply{min-height:32px;padding:0;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;cursor:pointer}.comment-reply:hover,.comment-reply:focus-visible,.comment-reply[aria-expanded=true]{color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.reply-composer{margin:6px 0 0 47px}.reply-composer :global(form.profile-comment-form){padding-top:0}.removed-message{display:grid;gap:3px;padding:4px 0}.removed-message strong{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.removed-message span{color:var(--shd-text-dim);font-size:var(--shd-type-supporting);font-style:italic}.comment-delete-confirm{width:min(520px,calc(100% - 47px));display:grid;gap:8px;margin-left:47px;padding:10px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{width:min(920px,100%);display:grid;gap:7px;padding:16px 0 0}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}.profile-comms .public-empty{min-height:100px;place-content:start;padding:24px 0;text-align:left}.profile-comms .public-empty span{display:none}
|
||||
@@ -399,6 +446,8 @@
|
||||
.profile-comment-list article :global(.markdown-content),.comment-actions{margin-left:53px}.reply-composer{margin-left:53px}.comment-delete-confirm{width:min(520px,calc(100% - 53px));margin-left:53px}
|
||||
.pinned-comment-label{margin-left:auto;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
|
||||
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.agent-main-column{grid-column:1}.agent-activity{position:static;grid-column:1;grid-template-columns:repeat(2,minmax(0,1fr))}.agent-activity .profile-section:last-child{grid-column:1/-1}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
|
||||
@media(max-width:1000px){.content-control-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:650px){.content-control-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:650px){.agent-command{grid-template-columns:1fr;align-items:stretch}.agent-command-actions{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto}.agent-command-actions :global(.profile-visibility-form),.agent-command-actions .visibility-toggle{width:100%}.agent-command-actions .visibility-toggle{min-height:44px;justify-content:center}.agent-command-actions :global(.content-report){grid-column:1/-1}.agent-command-actions :global(.content-report summary){width:100%;justify-content:center}.mobile-account-jump{grid-column:1/-1;min-height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.mobile-account-jump:hover,.mobile-account-jump:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange);background:var(--shd-surface-active)}.agent-main-column{display:contents}.agent-primary{grid-column:1;grid-row:1}.agent-activity{grid-column:1;grid-row:2;grid-template-columns:1fr}.profile-comms{grid-column:1;grid-row:3;margin-top:16px}.agent-activity .profile-section:last-child{grid-column:auto}.agent-published-grid{grid-template-columns:1fr}.public-content-grid{grid-template-columns:1fr}.profile-section>header,.profile-comms-heading{align-items:flex-start;flex-direction:column}.agent-bio>header{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start}.agent-bio .profile-header-actions{width:auto}.bio-edit-trigger{min-height:44px;padding:0 5px;border-color:transparent}.bio-edit-trigger:hover,.bio-edit-trigger:focus-visible,.bio-edit-trigger[aria-expanded=true]{border-color:var(--shd-border-strong)}.profile-bio-actions{display:grid;grid-template-columns:1fr 1fr}.profile-bio-actions button{min-height:44px}.profile-comment-trigger,.profile-comment-form-actions button,.comment-reply,.comment-delete-confirm button{min-height:44px}.profile-comment-form-actions{display:grid;grid-template-columns:1fr 1fr}.profile-comment-list>article{margin-left:calc(var(--thread-depth) * 12px)}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
|
||||
@media(prefers-reduced-motion:reduce){.public-content-card,.profile-comment-trigger,.comment-reply{transition:none}}
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,10 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
|
||||
}
|
||||
try {
|
||||
const authoringRequest = parseAiCalculationAuthoringRequest(payload);
|
||||
const release = reserveAiMechanicGeneration(operator.id);
|
||||
const release = reserveAiMechanicGeneration(
|
||||
operator.id,
|
||||
operator.role === 'admin',
|
||||
);
|
||||
let response;
|
||||
try {
|
||||
response = await generateAiCalculationDraft(
|
||||
|
||||
@@ -58,7 +58,10 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
|
||||
}
|
||||
try {
|
||||
const authoringRequest = parseAiMechanicAuthoringRequest(payload);
|
||||
const release = reserveAiMechanicGeneration(operator.id);
|
||||
const release = reserveAiMechanicGeneration(
|
||||
operator.id,
|
||||
operator.role === 'admin',
|
||||
);
|
||||
let response;
|
||||
try {
|
||||
response = await generateAiMechanicDraft(authoringRequest, operator.id);
|
||||
|
||||
@@ -3,10 +3,11 @@ import { gameDataIconUrl } from '$lib/server/game-data-icons';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { parseLoadoutDraft } from '$lib/builder/loadout';
|
||||
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import { publicOrigin } from '$lib/server/public-origin';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
||||
|
||||
const validVisibility = new Set(['private', 'unlisted', 'public']);
|
||||
const slugify = (value: string) =>
|
||||
@@ -28,6 +29,102 @@ function parseLoadout(raw: FormDataEntryValue | null) {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSkillChipSelections(
|
||||
database: Awaited<ReturnType<typeof getDatabase>>,
|
||||
loadout: LoadoutDraft,
|
||||
) {
|
||||
if (!loadout.chips.some((chip) => chip.setId)) return null;
|
||||
const [
|
||||
sets,
|
||||
focusAbilities,
|
||||
primaryOptions,
|
||||
primaryValues,
|
||||
secondaryOptions,
|
||||
secondaryValues,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,specialization_ability_id FROM skill_chip_sets WHERE active=1',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT ability_id,ability_slot FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot',
|
||||
)
|
||||
).all(loadout.specialization.focusId),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_primary_options',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_primary_quality_values')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_secondary_options',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_secondary_quality_values',
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
const primaryIds = new Set(
|
||||
primaryOptions.map((row) => String(row.stat_attribute_id)),
|
||||
);
|
||||
const secondaryIds = new Set(
|
||||
secondaryOptions.map((row) => String(row.stat_attribute_id)),
|
||||
);
|
||||
const rangeFor = (rows: any[], statId: string, quality: string) =>
|
||||
rows.find(
|
||||
(row) =>
|
||||
String(row.stat_attribute_id) === statId && row.quality === quality,
|
||||
);
|
||||
const inRange = (value: number, range: any) =>
|
||||
range &&
|
||||
value >= Number(range.min_value) &&
|
||||
value <= Number(range.max_value);
|
||||
|
||||
for (let index = 0; index < loadout.chips.length; index += 1) {
|
||||
const chip = loadout.chips[index];
|
||||
if (!chip.setId) continue;
|
||||
const definition = sets.find((row) => String(row.id) === chip.setId);
|
||||
const expectedAbility = focusAbilities.find(
|
||||
(row) => Number(row.ability_slot) === Math.floor(index / 3) + 1,
|
||||
);
|
||||
if (
|
||||
!definition ||
|
||||
!expectedAbility ||
|
||||
String(definition.specialization_ability_id) !==
|
||||
String(expectedAbility.ability_id)
|
||||
)
|
||||
return `Skill Chip ${index + 1} is not compatible with its assigned ability.`;
|
||||
const primaryRange = rangeFor(
|
||||
primaryValues,
|
||||
chip.primary.statId,
|
||||
chip.quality,
|
||||
);
|
||||
if (
|
||||
!primaryIds.has(chip.primary.statId) ||
|
||||
!inRange(chip.primary.value, primaryRange)
|
||||
)
|
||||
return `Skill Chip ${index + 1} has an invalid primary Attribute value.`;
|
||||
if (
|
||||
new Set(chip.secondary.map((roll) => roll.statId)).size !==
|
||||
chip.secondary.length
|
||||
)
|
||||
return `Skill Chip ${index + 1} must use distinct secondary Attributes.`;
|
||||
for (const roll of chip.secondary) {
|
||||
const range = rangeFor(secondaryValues, roll.statId, chip.quality);
|
||||
if (!secondaryIds.has(roll.statId) || !inRange(roll.value, range))
|
||||
return `Skill Chip ${index + 1} has an invalid secondary Attribute value.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const database = await getDatabase();
|
||||
const viewer = await getSessionUser(cookies);
|
||||
@@ -70,8 +167,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
talentTemplates,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipPrimary,
|
||||
chipSecondary,
|
||||
chipPrimaryOptions,
|
||||
chipPrimaryQualityValues,
|
||||
chipSecondaryOptions,
|
||||
chipSecondaryQualityValues,
|
||||
chipTalents,
|
||||
chipTalentEffects,
|
||||
protocols,
|
||||
@@ -162,9 +261,25 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_secondary_options')
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_primary_options ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id,quality,min_value,max_value FROM skill_chip_primary_quality_values ORDER BY stat_attribute_id,quality',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_secondary_options ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id,quality,min_value,max_value FROM skill_chip_secondary_quality_values ORDER BY stat_attribute_id,quality',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
(
|
||||
@@ -329,6 +444,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
weaponBonuses,
|
||||
),
|
||||
talents: configuredTalents,
|
||||
skillChipProfile: {
|
||||
primaryOptions: hydrateSkillChipProfileRows(
|
||||
chipPrimaryOptions,
|
||||
chipPrimaryQualityValues,
|
||||
),
|
||||
secondaryOptions: hydrateSkillChipProfileRows(
|
||||
chipSecondaryOptions,
|
||||
chipSecondaryQualityValues,
|
||||
),
|
||||
},
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find(
|
||||
(item) => item.skill_chip_set_id === set.id,
|
||||
@@ -339,12 +464,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
twoPiece:
|
||||
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
||||
null,
|
||||
primaryStatIds: chipPrimary
|
||||
.filter((item) => item.skill_chip_set_id === set.id)
|
||||
.map((item) => String(item.stat_attribute_id)),
|
||||
secondaryStatIds: chipSecondary
|
||||
.filter((item) => item.skill_chip_set_id === set.id)
|
||||
.map((item) => String(item.stat_attribute_id)),
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
@@ -473,6 +592,8 @@ export const actions: Actions = {
|
||||
return fail(400, { buildError: 'Choose a valid build visibility.' });
|
||||
|
||||
const database = await getDatabase();
|
||||
const skillChipError = await validateSkillChipSelections(database, loadout);
|
||||
if (skillChipError) return fail(400, { buildError: skillChipError });
|
||||
const now = new Date().toISOString();
|
||||
let buildId = id;
|
||||
let slug = '';
|
||||
|
||||
+110
-25
@@ -15,8 +15,8 @@
|
||||
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
||||
import { toast } from '$lib/toast';
|
||||
import {
|
||||
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
|
||||
type LoadoutDraft, type Roll
|
||||
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, skillChipQualities, skillChipSecondaryCapacity, weaponBaseRange,
|
||||
type LoadoutDraft, type Roll, type SkillChipQuality
|
||||
} from '$lib/builder/loadout';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -35,8 +35,23 @@
|
||||
const coreKeys = ['firepower','toughness','engineering'] as const;
|
||||
const gearAttributes = $derived(data.attributes.filter((item) => item.gear_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const weaponAttributes = $derived(data.attributes.filter((item) => item.weapon_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const chipAttributes = $derived(data.attributes.filter((item) => item.skill_chip_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
|
||||
const chipPrimaryAttributes = $derived(data.skillChipProfile.primaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const chipSecondaryAttributes = $derived(data.skillChipProfile.secondaryOptions.map((option: any) => attribute(option.statId)).filter(Boolean).map((item: any) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
|
||||
const chipRange = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) =>
|
||||
data.skillChipProfile[`${kind}Options`].find((option: any) => option.statId === statId)?.ranges?.[quality] ?? null;
|
||||
type ChipRange = { min: number; max: number };
|
||||
const validChipRange = (range: any): range is ChipRange => Number.isFinite(Number(range?.min)) && Number.isFinite(Number(range?.max)) && Number(range.max) >= Number(range.min);
|
||||
const chipRangeMin = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.min ?? 0);
|
||||
const chipRangeMax = (kind: 'primary' | 'secondary', statId: string, quality: SkillChipQuality) => Number(chipRange(kind, statId, quality)?.max ?? 0);
|
||||
const chipProfileConfigured = $derived(
|
||||
data.skillChipProfile.primaryOptions.length === 4
|
||||
&& chipPrimaryAttributes.length === data.skillChipProfile.primaryOptions.length
|
||||
&& data.skillChipProfile.primaryOptions.every((option: any) => skillChipQualities.every((quality) => validChipRange(option.ranges?.[quality])))
|
||||
&& data.skillChipProfile.secondaryOptions.length >= 2
|
||||
&& chipSecondaryAttributes.length === data.skillChipProfile.secondaryOptions.length
|
||||
&& data.skillChipProfile.secondaryOptions.every((option: any) => ['Specialized','Superior','High-end'].every((quality) => validChipRange(option.ranges?.[quality])))
|
||||
);
|
||||
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
|
||||
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
|
||||
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
|
||||
@@ -80,13 +95,35 @@
|
||||
coreKeys.reduce((total, key) => total + Number(selection[key] ?? 0), 0);
|
||||
const attributeRange = (stat: any, source: 'gear' | 'weapon', level: number) =>
|
||||
scaledRange(stat?.[`${source}_min`], stat?.[`${source}_max`], level, stat?.[`${source}_reference_level`], source);
|
||||
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon' | 'chip', level = 50) => {
|
||||
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon', level = 50) => {
|
||||
roll.statId = id;
|
||||
const stat = attribute(id);
|
||||
const min = source === 'chip'
|
||||
? stat?.skill_chip_min
|
||||
: attributeRange(stat, source, level).min;
|
||||
roll.value = Number(min ?? 0);
|
||||
roll.value = Number(attributeRange(stat, source, level).min ?? 0);
|
||||
};
|
||||
const setChipRoll = (roll: Roll, id: string, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
|
||||
roll.statId = id;
|
||||
const range = chipRange(kind, id, quality);
|
||||
roll.value = validChipRange(range) ? Number(range.min) : 0;
|
||||
};
|
||||
const chipRollConfigured = (roll: Roll, kind: 'primary' | 'secondary', quality: SkillChipQuality) => {
|
||||
const options = data.skillChipProfile[`${kind}Options`];
|
||||
const range = chipRange(kind, roll.statId, quality);
|
||||
const value = Number(roll.value);
|
||||
return options.some((option: any) => option.statId === roll.statId)
|
||||
&& validChipRange(range)
|
||||
&& Number.isFinite(value)
|
||||
&& value >= Number(range.min)
|
||||
&& value <= Number(range.max);
|
||||
};
|
||||
const chipSelectionConfigured = (chip: LoadoutDraft['chips'][number]) => {
|
||||
const capacity = skillChipSecondaryCapacity[chip.quality];
|
||||
const secondaryIds = chip.secondary.map((roll) => roll.statId);
|
||||
return Boolean(data.chipSets.some((item: any) => item.id === chip.setId))
|
||||
&& chipProfileConfigured
|
||||
&& chipRollConfigured(chip.primary, 'primary', chip.quality)
|
||||
&& chip.secondary.length === capacity
|
||||
&& new Set(secondaryIds).size === secondaryIds.length
|
||||
&& chip.secondary.every((roll) => chipRollConfigured(roll, 'secondary', chip.quality));
|
||||
};
|
||||
const rankPointCost = (rank: number) => Number(data.specializationProgression.rankProfiles.find((profile: any) => Number(profile.rank) === rank)?.point_cost ?? rank);
|
||||
const selectedSpecializationPoints = $derived(selectedFocus?.nodes.reduce((total: number, node: any) => {
|
||||
@@ -222,15 +259,41 @@
|
||||
selection.rolls = [blankRoll(), blankRoll()];
|
||||
};
|
||||
const configureChip = (index: number, id: string) => {
|
||||
const chip = draft.chips[index], item = data.chipSets.find((entry) => entry.id === id);
|
||||
const chip = draft.chips[index];
|
||||
chip.setId = id;
|
||||
chip.primary = { statId: item?.primaryStatIds[0] ?? '', value: 0 };
|
||||
chip.secondary = [
|
||||
{ statId: item?.secondaryStatIds[0] ?? '', value: 0 },
|
||||
{ statId: item?.secondaryStatIds[1] ?? '', value: 0 }
|
||||
];
|
||||
if (chip.primary.statId) setRollStat(chip.primary, chip.primary.statId, 'chip');
|
||||
for (const roll of chip.secondary) if (roll.statId) setRollStat(roll, roll.statId, 'chip');
|
||||
chip.primary = blankRoll();
|
||||
chip.secondary = [];
|
||||
changeChipQuality(index, chip.quality);
|
||||
};
|
||||
const changeChipQuality = (index: number, quality: SkillChipQuality) => {
|
||||
if (!skillChipQualities.includes(quality)) return;
|
||||
const chip = draft.chips[index];
|
||||
chip.quality = quality;
|
||||
const primaryOption = data.skillChipProfile.primaryOptions.find((option: any) => option.statId === chip.primary.statId && validChipRange(option.ranges?.[quality]))
|
||||
?? data.skillChipProfile.primaryOptions.find((option: any) => validChipRange(option.ranges?.[quality]));
|
||||
if (primaryOption) {
|
||||
const range = primaryOption.ranges[quality];
|
||||
chip.primary = {
|
||||
statId: primaryOption.statId,
|
||||
value: clamp(chip.primary.statId === primaryOption.statId ? chip.primary.value : range.min, range.min, range.max)
|
||||
};
|
||||
} else chip.primary = blankRoll();
|
||||
const capacity = skillChipSecondaryCapacity[quality];
|
||||
const next: Roll[] = [];
|
||||
for (const roll of chip.secondary) {
|
||||
if (next.length >= capacity || next.some((entry) => entry.statId === roll.statId)) continue;
|
||||
const range = chipRange('secondary', roll.statId, quality);
|
||||
if (!validChipRange(range)) continue;
|
||||
next.push({ statId: roll.statId, value: clamp(roll.value, range.min, range.max) });
|
||||
}
|
||||
for (const option of data.skillChipProfile.secondaryOptions) {
|
||||
if (next.length >= capacity) break;
|
||||
if (next.some((entry) => entry.statId === option.statId)) continue;
|
||||
const range = option.ranges?.[quality];
|
||||
if (validChipRange(range)) next.push({ statId: option.statId, value: Number(range.min) });
|
||||
}
|
||||
while (next.length < capacity) next.push(blankRoll());
|
||||
chip.secondary = next;
|
||||
};
|
||||
const changeWeaponTier = (index: number, tier: number) => {
|
||||
const selection = draft.weapons[index], item = weaponItem(index);
|
||||
@@ -297,7 +360,8 @@
|
||||
});
|
||||
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
|
||||
const ability = selectedFocus?.abilities[index];
|
||||
const configuredChips = draft.chips.slice(index * 3, index * 3 + 3).filter((chip) => chip.setId);
|
||||
const abilityChips = draft.chips.slice(index * 3, index * 3 + 3);
|
||||
const configuredChips = abilityChips.filter((chip) => chip.setId);
|
||||
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
|
||||
return {
|
||||
id: `ability:${index}`,
|
||||
@@ -306,7 +370,7 @@
|
||||
meta: ability?.attack?.damage_type ?? '',
|
||||
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
|
||||
iconUrl: ability?.iconUrl,
|
||||
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : undefined
|
||||
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : abilityChips.some((chip) => !chipSelectionConfigured(chip)) ? 'CONFIG REQUIRED' : undefined
|
||||
};
|
||||
}));
|
||||
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
|
||||
@@ -357,14 +421,15 @@
|
||||
&& selectedFocus.abilities.length === 3
|
||||
&& draft.weapons.every((item) => item.itemId)
|
||||
&& gearSlots.every((slot) => draft.gear[slot].itemId)
|
||||
&& draft.chips.every((item) => item.setId)
|
||||
&& draft.chips.every(chipSelectionConfigured)
|
||||
&& draft.protocol.itemId
|
||||
));
|
||||
|
||||
onMount(() => {
|
||||
if (data.initialBuild) return;
|
||||
localStorage.removeItem('shd-build-loadout-draft-v3');
|
||||
const stored = localStorage.getItem('shd-build-loadout-draft-v4');
|
||||
localStorage.removeItem('shd-build-loadout-draft-v4');
|
||||
if (data.initialBuild) return;
|
||||
const stored = localStorage.getItem('shd-build-loadout-draft-v5');
|
||||
if (!stored) return;
|
||||
try {
|
||||
const parsed = parseLoadoutDraft(JSON.parse(stored));
|
||||
@@ -372,9 +437,9 @@
|
||||
draft = parsed;
|
||||
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
|
||||
saved = true;
|
||||
} else localStorage.removeItem('shd-build-loadout-draft-v4');
|
||||
} else localStorage.removeItem('shd-build-loadout-draft-v5');
|
||||
} catch {
|
||||
localStorage.removeItem('shd-build-loadout-draft-v4');
|
||||
localStorage.removeItem('shd-build-loadout-draft-v5');
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
@@ -392,7 +457,7 @@
|
||||
: 'Build published to the SHD archive.';
|
||||
});
|
||||
function saveDraft() {
|
||||
localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft));
|
||||
localStorage.setItem('shd-build-loadout-draft-v5', JSON.stringify(draft));
|
||||
saved = true;
|
||||
toast.success('Draft saved locally', 'This loadout is available on this device.');
|
||||
}
|
||||
@@ -548,7 +613,27 @@
|
||||
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
|
||||
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← RETURN TO {ability?.name?.toUpperCase() ?? 'ABILITY'}</button>
|
||||
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
|
||||
{#if item}<div class="builder-field-grid"><label>QUALITY<select bind:value={chip.quality}><option>Superior</option><option>High-end</option></select></label></div><div class="builder-chip-rolls"><div><BuilderPicker items={chipAttributes.filter((entry) => item.primaryStatIds.includes(entry.id))} bind:value={chip.primary.statId} onchange={(id) => setRollStat(chip.primary,id,'chip')} label="PRIMARY ATTRIBUTE"/><label>VALUE<input type="number" min={attribute(chip.primary.statId)?.skill_chip_min ?? 0} max={attribute(chip.primary.statId)?.skill_chip_max ?? 0} bind:value={chip.primary.value} onblur={() => chip.primary.value = clamp(chip.primary.value,attribute(chip.primary.statId)?.skill_chip_min,attribute(chip.primary.statId)?.skill_chip_max)}/></label></div>{#each chip.secondary as roll,index}<div><BuilderPicker items={chipAttributes.filter((entry) => item.secondaryStatIds.includes(entry.id))} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'chip')} disabledIds={chip.secondary.filter((_,i) => i !== index).map((entry) => entry.statId)} label={`SECONDARY ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" min={attribute(roll.statId)?.skill_chip_min ?? 0} max={attribute(roll.statId)?.skill_chip_max ?? 0} bind:value={roll.value} onblur={() => roll.value = clamp(roll.value,attribute(roll.statId)?.skill_chip_min,attribute(roll.statId)?.skill_chip_max)}/></label></div>{/each}</div>{/if}
|
||||
{#if !chipProfileConfigured}<p class="error">SHARED SKILL CHIP PROFILE · CONFIG REQUIRED. Complete every global quality range in ISAC before this loadout can be complete.</p>{/if}
|
||||
{#if item}
|
||||
<div class="builder-field-grid">
|
||||
<SegmentedChoice legend="QUALITY" name={`chip-${index}-quality`} options={skillChipQualities.map((quality) => ({ value: quality, label: quality.toUpperCase() }))} value={chip.quality} onchange={(values) => changeChipQuality(index, values[0] as SkillChipQuality)} required/>
|
||||
</div>
|
||||
<div class="builder-section-title"><div><span>ABILITY-SCOPED PRIMARY</span><small>{ability?.name?.toUpperCase() ?? 'ASSIGNED ABILITY'} ONLY · EXCLUDED FROM KNOWN AGENT STATS</small></div><b>NOT YET DEFINED</b></div>
|
||||
<p class="builder-scaling-note">The primary roll belongs to this ability. Its calculation application is NOT YET DEFINED, so it does not contribute to current Agent totals.</p>
|
||||
<div class="builder-chip-rolls">
|
||||
<div>
|
||||
<BuilderPicker items={chipPrimaryAttributes} bind:value={chip.primary.statId} onchange={(id) => setChipRoll(chip.primary,id,'primary',chip.quality)} label="PRIMARY ATTRIBUTE"/>
|
||||
{#if validChipRange(chipRange('primary', chip.primary.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('primary', chip.primary.statId, chip.quality)} max={chipRangeMax('primary', chip.primary.statId, chip.quality)} step={0.01} bind:value={chip.primary.value}/>{:else}<p class="error">PRIMARY RANGE · CONFIG REQUIRED</p>{/if}
|
||||
</div>
|
||||
{#each chip.secondary as roll, secondaryIndex}
|
||||
<div>
|
||||
<BuilderPicker items={chipSecondaryAttributes} bind:value={roll.statId} onchange={(id) => setChipRoll(roll,id,'secondary',chip.quality)} disabledIds={chip.secondary.filter((_,i) => i !== secondaryIndex).map((entry) => entry.statId).filter(Boolean)} label={`SECONDARY ATTRIBUTE ${secondaryIndex + 1}`}/>
|
||||
{#if validChipRange(chipRange('secondary', roll.statId, chip.quality))}<RangeValueControl label="VALUE" min={chipRangeMin('secondary', roll.statId, chip.quality)} max={chipRangeMax('secondary', roll.statId, chip.quality)} step={0.01} bind:value={roll.value}/>{:else}<p class="error">SECONDARY RANGE · CONFIG REQUIRED</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if skillChipSecondaryCapacity[chip.quality] === 0}<p class="builder-scaling-note">STANDARD QUALITY · NO SECONDARY ATTRIBUTES</p>{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
{@const protocol = protocolItem()}
|
||||
<div class="loadout-editor-head"><div><span>AGENT SYSTEM</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createDraft, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
insertBuild: vi.fn(),
|
||||
getSessionUser: vi.fn(async () => ({
|
||||
id: 'agent-1',
|
||||
displayName: 'Test Agent',
|
||||
})),
|
||||
database: {
|
||||
prepare: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: mocks.getSessionUser,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const primaryStatId = 'skill-duration';
|
||||
const secondaryStatIds = ['critical-hit-chance', 'critical-hit-damage'];
|
||||
|
||||
const validLoadout = (): LoadoutDraft => {
|
||||
const loadout = createDraft();
|
||||
loadout.buildName = 'Validated Skill Chips';
|
||||
loadout.specialization.focusId = 'focus-1';
|
||||
loadout.chips[0] = {
|
||||
setId: 'chip-set-1',
|
||||
quality: 'Superior',
|
||||
primary: { statId: primaryStatId, value: 15 },
|
||||
secondary: [
|
||||
{ statId: secondaryStatIds[0], value: 2 },
|
||||
{ statId: secondaryStatIds[1], value: 4 },
|
||||
],
|
||||
};
|
||||
return loadout;
|
||||
};
|
||||
|
||||
const saveRequest = (loadout: LoadoutDraft) => {
|
||||
const form = new FormData();
|
||||
form.set('loadout', JSON.stringify(loadout));
|
||||
form.set('visibility', 'private');
|
||||
return new Request('http://localhost/builder?/save', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
const runSave = (loadout: LoadoutDraft) =>
|
||||
actions.save?.({
|
||||
cookies: {} as never,
|
||||
request: saveRequest(loadout),
|
||||
} as never);
|
||||
|
||||
describe('Builder Skill Chip save validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||
all: vi.fn(async () => {
|
||||
if (sql.includes('FROM skill_chip_sets'))
|
||||
return [
|
||||
{
|
||||
id: 'chip-set-1',
|
||||
specialization_ability_id: 'ability-1',
|
||||
},
|
||||
];
|
||||
if (sql.includes('FROM specialization_focus_abilities'))
|
||||
return [{ ability_id: 'ability-1', ability_slot: 1 }];
|
||||
if (sql.includes('FROM skill_chip_primary_options'))
|
||||
return [{ stat_attribute_id: primaryStatId }];
|
||||
if (sql.includes('FROM skill_chip_primary_quality_values'))
|
||||
return [
|
||||
{
|
||||
stat_attribute_id: primaryStatId,
|
||||
quality: 'Superior',
|
||||
min_value: 10,
|
||||
max_value: 20,
|
||||
},
|
||||
];
|
||||
if (sql.includes('FROM skill_chip_secondary_options'))
|
||||
return secondaryStatIds.map((stat_attribute_id) => ({
|
||||
stat_attribute_id,
|
||||
}));
|
||||
if (sql.includes('FROM skill_chip_secondary_quality_values'))
|
||||
return secondaryStatIds.map((stat_attribute_id) => ({
|
||||
stat_attribute_id,
|
||||
quality: 'Superior',
|
||||
min_value: 1,
|
||||
max_value: 5,
|
||||
}));
|
||||
return [];
|
||||
}),
|
||||
run: mocks.insertBuild,
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects a selected Skill Chip assigned to a different focus ability', async () => {
|
||||
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||
all: vi.fn(async () => {
|
||||
if (sql.includes('FROM skill_chip_sets'))
|
||||
return [
|
||||
{
|
||||
id: 'chip-set-1',
|
||||
specialization_ability_id: 'ability-2',
|
||||
},
|
||||
];
|
||||
if (sql.includes('FROM specialization_focus_abilities'))
|
||||
return [{ ability_id: 'ability-1', ability_slot: 1 }];
|
||||
return [];
|
||||
}),
|
||||
run: mocks.insertBuild,
|
||||
}));
|
||||
|
||||
const result = await runSave(validLoadout());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 400,
|
||||
data: {
|
||||
buildError: 'Skill Chip 1 is not compatible with its assigned ability.',
|
||||
},
|
||||
});
|
||||
expect(mocks.insertBuild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'primary',
|
||||
mutate: (loadout: LoadoutDraft) => {
|
||||
loadout.chips[0].primary.value = 21;
|
||||
},
|
||||
message: 'Skill Chip 1 has an invalid primary Attribute value.',
|
||||
},
|
||||
{
|
||||
label: 'secondary',
|
||||
mutate: (loadout: LoadoutDraft) => {
|
||||
loadout.chips[0].secondary[1].value = 6;
|
||||
},
|
||||
message: 'Skill Chip 1 has an invalid secondary Attribute value.',
|
||||
},
|
||||
])('rejects an out-of-range $label value', async ({ mutate, message }) => {
|
||||
const loadout = validLoadout();
|
||||
mutate(loadout);
|
||||
|
||||
const result = await runSave(loadout);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 400,
|
||||
data: { buildError: message },
|
||||
});
|
||||
expect(mocks.insertBuild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects duplicate secondary Attributes', async () => {
|
||||
const loadout = validLoadout();
|
||||
loadout.chips[0].secondary[1].statId = secondaryStatIds[0];
|
||||
|
||||
const result = await runSave(loadout);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 400,
|
||||
data: {
|
||||
buildError: 'Skill Chip 1 must use distinct secondary Attributes.',
|
||||
},
|
||||
});
|
||||
expect(mocks.insertBuild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts primary and secondary values within the configured profiles', async () => {
|
||||
const result = await runSave(validLoadout());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
buildSaved: true,
|
||||
buildToken: expect.any(String),
|
||||
savedBuild: {
|
||||
visibility: 'private',
|
||||
title: 'Validated Skill Chips',
|
||||
},
|
||||
});
|
||||
expect(mocks.insertBuild).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@
|
||||
</section>
|
||||
<section>
|
||||
<header><span>AGENT SYSTEMS</span><b>10 SLOTS</b></header>
|
||||
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span>{#if item}<em>{rollLine([selection.primary, ...selection.secondary]) || 'ATTRIBUTES NOT CONFIGURED'}</em>{/if}</article>{/each}
|
||||
{#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}<article><small>SKILL CHIP 0{index+1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? selection.quality : 'NOT CONFIGURED'}</span>{#if item}<em>ABILITY-SCOPED PRIMARY · {rollLine([selection.primary]) || 'NOT CONFIGURED'} · NOT YET DEFINED{selection.secondary.length ? ` · SECONDARY · ${rollLine(selection.secondary)}` : ''}</em>{/if}</article>{/each}
|
||||
{#if protocol(data.loadout.protocol.itemId)}{@const protocolItem=protocol(data.loadout.protocol.itemId)}<article><small>OS PROTOCOL</small><strong>{protocolItem.name}</strong><span>LEVEL {data.loadout.protocol.level} · {data.loadout.protocol.talentUpgrades}/5 UPGRADES</span></article>{:else}<article><small>OS PROTOCOL</small><strong>EMPTY SLOT</strong><span>NOT CONFIGURED</span></article>{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,10 @@ import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { removePublicComment } from '$lib/server/comment-removal';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
import type { Actions } from './$types';
|
||||
import {
|
||||
publicWeaponModSlots,
|
||||
weaponModSlotDefinitionByName,
|
||||
} from '$lib/weapons/mod-slots';
|
||||
|
||||
const number = (value: unknown) => {
|
||||
const numeric = Number(value);
|
||||
@@ -336,8 +340,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
|
||||
native.damage_min,native.damage_max,native.reference_level,
|
||||
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count,
|
||||
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count,
|
||||
(SELECT COUNT(*) FROM weapon_category_attribute_bonuses bonuses WHERE bonuses.weapon_category=item.category) AS bonus_count
|
||||
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count
|
||||
FROM weapons item
|
||||
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
|
||||
LEFT JOIN managed_images render ON render.source_entity_type='weapon_render' AND render.source_entity_id=item.id
|
||||
@@ -386,7 +389,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
rowsSql = `SELECT item.*,weapons.name AS exotic_weapon,
|
||||
render.id AS render_id,render.updated_at AS render_updated_at,
|
||||
GROUP_CONCAT(DISTINCT categories.weapon_category) AS categories,
|
||||
(SELECT COUNT(*) FROM weapon_mod_secondary_options secondary WHERE secondary.weapon_mod_id=item.id) AS secondary_count,
|
||||
(SELECT COUNT(*) FROM weapon_mod_secondary_options) AS secondary_count,
|
||||
(SELECT COUNT(*) FROM weapon_mod_effects effects WHERE effects.weapon_mod_id=item.id) AS effect_count
|
||||
FROM weapon_mods item
|
||||
LEFT JOIN weapons ON weapons.id=item.exotic_weapon_id
|
||||
@@ -398,7 +401,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
case 'gear-mods':
|
||||
countSql = `SELECT COUNT(*) AS total FROM gear_mods item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
|
||||
(SELECT COUNT(*) FROM gear_mod_secondary_options secondary WHERE secondary.gear_mod_id=item.id) AS secondary_count,
|
||||
(SELECT COUNT(*) FROM gear_mod_secondary_options) AS secondary_count,
|
||||
(SELECT MIN(primary_level_1_min) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_1_floor,
|
||||
(SELECT MAX(primary_level_20_max) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_20_ceiling
|
||||
FROM gear_mods item
|
||||
@@ -409,7 +412,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
case 'os-protocols':
|
||||
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit,
|
||||
primary_effect.base_value,talents.name AS talent_name,icons.updated_at AS icon_updated_at,
|
||||
primary_effect.base_value,talents.name AS talent_name,talents.description_template AS talent_description,icons.updated_at AS icon_updated_at,
|
||||
render.id AS render_id,render.updated_at AS render_updated_at
|
||||
FROM os_protocols item
|
||||
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
|
||||
@@ -500,8 +503,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
||||
talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name,
|
||||
(SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count,
|
||||
(SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count,
|
||||
(SELECT COUNT(*) FROM skill_chip_primary_options) AS primary_count,
|
||||
(SELECT COUNT(*) FROM skill_chip_secondary_options) AS secondary_count,
|
||||
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||||
FROM skill_chip_sets item
|
||||
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
||||
@@ -549,6 +552,17 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
const rows = await (
|
||||
await database.prepare(rowsSql)
|
||||
).all(...queryArgs, pagination.perPage, pagination.offset);
|
||||
const protocolTalentEffects =
|
||||
category.slug === 'os-protocols' && rows.length
|
||||
? await (
|
||||
await database.prepare(`SELECT talents.protocol_id,effects.base_value,attributes.unit
|
||||
FROM os_protocol_talents talents
|
||||
JOIN os_protocol_talent_effects effects ON effects.talent_id=talents.id
|
||||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||||
WHERE talents.protocol_id IN (${rows.map(() => '?').join(',')})
|
||||
ORDER BY talents.protocol_id,effects.sort_order`)
|
||||
).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const weaponRelations =
|
||||
category.slug === 'weapons' && rows.length
|
||||
? await (
|
||||
@@ -563,6 +577,22 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)
|
||||
).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const weaponModSlotRows =
|
||||
category.slug === 'weapons' && rows.length
|
||||
? await (
|
||||
await database.prepare(`SELECT weapon_id,tier,slot_type FROM weapon_mod_slots
|
||||
WHERE weapon_id IN (${rows.map(() => '?').join(',')})
|
||||
ORDER BY weapon_id,tier,slot_type`)
|
||||
).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const weaponModSlotIconRows =
|
||||
category.slug === 'weapons'
|
||||
? await (
|
||||
await database.prepare(
|
||||
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='weapon_mod_slot'",
|
||||
)
|
||||
).all()
|
||||
: [];
|
||||
const gearRelations =
|
||||
category.slug === 'gear' && rows.length
|
||||
? await (
|
||||
@@ -769,6 +799,36 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
const relatedTalents = weaponRelations.filter(
|
||||
(relation) => relation.weapon_id === row.id,
|
||||
);
|
||||
const modSlots = publicWeaponModSlots(
|
||||
Number(row.native_tier),
|
||||
[0, 1, 2].map((tier) => ({
|
||||
tier,
|
||||
slots: weaponModSlotRows
|
||||
.filter(
|
||||
(slot) =>
|
||||
slot.weapon_id === row.id && Number(slot.tier) === tier,
|
||||
)
|
||||
.map((slot) => String(slot.slot_type)),
|
||||
})),
|
||||
).map((slot) => {
|
||||
const definition = weaponModSlotDefinitionByName(slot.slot);
|
||||
const icon = definition
|
||||
? weaponModSlotIconRows.find(
|
||||
(row) => row.entity_id === definition.id,
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
...slot,
|
||||
iconUrl:
|
||||
definition && icon
|
||||
? gameDataIconUrl(
|
||||
'weapon_mod_slot',
|
||||
definition.id,
|
||||
icon.updated_at,
|
||||
)
|
||||
: '',
|
||||
};
|
||||
});
|
||||
const talentItems = (slot: number) =>
|
||||
relatedTalents
|
||||
.filter((relation) => Number(relation.talent_slot) === slot)
|
||||
@@ -801,11 +861,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||||
: '',
|
||||
gameIcon: weaponIcon(row.category),
|
||||
headerBadge: `T${row.native_tier}`,
|
||||
headerBadges: [
|
||||
`T${row.native_tier}`,
|
||||
...(Number(row.is_exotic) ? ['EXOTIC'] : []),
|
||||
],
|
||||
headerMetaIcon: weaponIcon(row.damage_type),
|
||||
badges: Number(row.is_exotic)
|
||||
? [{ label: 'EXOTIC', tone: 'exotic' }]
|
||||
: [],
|
||||
factLayout: 'stack' as const,
|
||||
facts: [
|
||||
{
|
||||
@@ -831,7 +891,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
],
|
||||
},
|
||||
],
|
||||
lines: [{ label: 'BASE BONUSES', value: String(row.bonus_count) }],
|
||||
weaponModSlots: modSlots,
|
||||
iconGroups: [
|
||||
{
|
||||
label: 'TALENT 1 POOL',
|
||||
@@ -914,28 +974,28 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||||
: '',
|
||||
iconUrl: iconUrl('os_protocol', row.id, row.icon_updated_at),
|
||||
badges: [
|
||||
{
|
||||
label: String(row.quality),
|
||||
tone: String(row.quality).toLowerCase().replace('-', ''),
|
||||
},
|
||||
],
|
||||
facts: [
|
||||
{ label: 'LEVELS', value: '01–50' },
|
||||
{
|
||||
label: 'PRIMARY EFFECT',
|
||||
value: row.primary_stat
|
||||
? `${row.primary_stat} · ${valueWithUnit(row.base_value, row.primary_unit)}`
|
||||
: 'NOT CONFIGURED',
|
||||
},
|
||||
{ label: 'TALENT UPGRADES', value: '0–5' },
|
||||
],
|
||||
lines: [
|
||||
{
|
||||
label: 'UNIQUE TALENT',
|
||||
value: list(row.talent_name, 'NOT CONFIGURED'),
|
||||
},
|
||||
],
|
||||
topBorderTone: String(row.quality).toLowerCase().replace('-', ''),
|
||||
headerBadge: String(row.quality).toUpperCase(),
|
||||
headerBadgeTone: String(row.quality).toLowerCase().replace('-', ''),
|
||||
factLayout: 'stack' as const,
|
||||
facts: [],
|
||||
protocolPreview: {
|
||||
primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'),
|
||||
primaryUnit: String(row.primary_unit ?? ''),
|
||||
primaryBaseValue: Number(row.base_value),
|
||||
talentDescription: row.talent_description
|
||||
? String(row.talent_description)
|
||||
: undefined,
|
||||
talentEffects: protocolTalentEffects
|
||||
.filter((effect) => effect.protocol_id === row.id)
|
||||
.map((effect) => ({
|
||||
baseValue: Number(effect.base_value),
|
||||
unit: String(effect.unit ?? ''),
|
||||
})),
|
||||
},
|
||||
description: row.talent_description
|
||||
? String(row.talent_description)
|
||||
: undefined,
|
||||
};
|
||||
case 'talents':
|
||||
return talentCardItem(row, mechanicsFor(row.id));
|
||||
|
||||
@@ -34,8 +34,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'gearMax',
|
||||
'weaponMin',
|
||||
'weaponMax',
|
||||
'chipMin',
|
||||
'chipMax',
|
||||
'capValue',
|
||||
],
|
||||
},
|
||||
@@ -47,8 +45,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
gear_max: stat.gearMax || null,
|
||||
weapon_min: stat.weaponMin || null,
|
||||
weapon_max: stat.weaponMax || null,
|
||||
skill_chip_min: stat.chipMin || null,
|
||||
skill_chip_max: stat.chipMax || null,
|
||||
cap_value: stat.capValue || null,
|
||||
}));
|
||||
const summary = await (
|
||||
@@ -211,14 +207,9 @@ export const actions: Actions = {
|
||||
let values: Array<number | null>;
|
||||
try {
|
||||
values = [
|
||||
...[
|
||||
'gearMin',
|
||||
'gearMax',
|
||||
'weaponMin',
|
||||
'weaponMax',
|
||||
'chipMin',
|
||||
'chipMax',
|
||||
].map((key) => number(key)),
|
||||
...['gearMin', 'gearMax', 'weaponMin', 'weaponMax'].map((key) =>
|
||||
number(key),
|
||||
),
|
||||
number('capValue', false),
|
||||
];
|
||||
} catch {
|
||||
@@ -226,7 +217,7 @@ export const actions: Actions = {
|
||||
error: 'Ranges must be numbers. Optional caps cannot be negative.',
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < 6; index += 2)
|
||||
for (let index = 0; index < 4; index += 2)
|
||||
if (
|
||||
(values[index] === null) !== (values[index + 1] === null) ||
|
||||
(values[index] !== null &&
|
||||
@@ -253,7 +244,7 @@ export const actions: Actions = {
|
||||
if (id)
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`,
|
||||
`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,cap_value=?,updated_at=? WHERE id=?`,
|
||||
)
|
||||
).run(
|
||||
name,
|
||||
@@ -269,7 +260,7 @@ export const actions: Actions = {
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
statId,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
|
||||
const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value }));
|
||||
const unitOptions = [{value:'',label:'None'},{value:'%',label:'%'},{value:'s',label:'s'},{value:'Shots',label:'Shots'},{value:'m',label:'m'}];
|
||||
const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
|
||||
const blank = () => ({id:'',name:'',description:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',cap_value:''});
|
||||
const value = (input: unknown) => input ?? '';
|
||||
const attributeMeta = (stat: any) => stat.pending
|
||||
? `PROPOSED BY ${stat.proposerName}`
|
||||
@@ -32,7 +32,7 @@
|
||||
</script>
|
||||
{#snippet attributeEditor()}
|
||||
{#if editing}
|
||||
<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>
|
||||
<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
|
||||
|
||||
@@ -241,7 +241,7 @@ export const actions: Actions = {
|
||||
});
|
||||
await (
|
||||
await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
|
||||
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`)
|
||||
weapon_min=?,weapon_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`)
|
||||
).run(
|
||||
before.name,
|
||||
before.description ?? null,
|
||||
@@ -253,8 +253,6 @@ export const actions: Actions = {
|
||||
before.gear_max,
|
||||
before.weapon_min,
|
||||
before.weapon_max,
|
||||
before.skill_chip_min,
|
||||
before.skill_chip_max,
|
||||
before.cap_value,
|
||||
before.active,
|
||||
new Date().toISOString(),
|
||||
|
||||
@@ -12,6 +12,12 @@ import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
import { conditionalRuleSnapshot } from '$lib/server/conditional-effects';
|
||||
import {
|
||||
getSkillChipPrimaryProfiles,
|
||||
getSkillChipSecondaryPool,
|
||||
} from '$lib/server/skill-chip-profiles';
|
||||
import { gameDataIconMetadata } from '$lib/server/game-data-icons';
|
||||
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
|
||||
|
||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
||||
const modules = [
|
||||
@@ -174,16 +180,6 @@ async function skillChipProposalSnapshot(database: any, id: string) {
|
||||
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
primaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
secondaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
talent: talent ?? null,
|
||||
talentEffects: talent
|
||||
? await (
|
||||
@@ -317,16 +313,35 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
? ((await (
|
||||
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
|
||||
).get(String(proposal.entity_id))) ?? null)
|
||||
: proposal.module === 'skillchips' && proposal.entity_id
|
||||
? await skillChipProposalSnapshot(
|
||||
database,
|
||||
String(proposal.entity_id),
|
||||
)
|
||||
: proposal.module === 'calculations'
|
||||
? await calculationProposalSnapshot(database, proposal)
|
||||
: proposal.module === 'specializations'
|
||||
? await specializationProposalSnapshot(database, proposal)
|
||||
: null,
|
||||
: proposal.module === 'skillchips' &&
|
||||
proposal.action === 'savePrimaryProfile'
|
||||
? await getSkillChipPrimaryProfiles(database)
|
||||
: proposal.module === 'skillchips' &&
|
||||
proposal.action === 'saveSecondaryPool'
|
||||
? await getSkillChipSecondaryPool(database)
|
||||
: proposal.module === 'weaponmods' &&
|
||||
proposal.action === 'saveSlotIcon' &&
|
||||
proposal.entity_id
|
||||
? {
|
||||
slot: weaponModSlotDefinitionById(
|
||||
String(proposal.entity_id),
|
||||
),
|
||||
icon: await gameDataIconMetadata(
|
||||
database,
|
||||
'weapon_mod_slot',
|
||||
String(proposal.entity_id),
|
||||
),
|
||||
}
|
||||
: proposal.module === 'skillchips' && proposal.entity_id
|
||||
? await skillChipProposalSnapshot(
|
||||
database,
|
||||
String(proposal.entity_id),
|
||||
)
|
||||
: proposal.module === 'calculations'
|
||||
? await calculationProposalSnapshot(database, proposal)
|
||||
: proposal.module === 'specializations'
|
||||
? await specializationProposalSnapshot(database, proposal)
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
const summaryRows = await (
|
||||
|
||||
@@ -21,8 +21,16 @@ import {
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import {
|
||||
MOD_QUALITIES,
|
||||
normalizeModSecondaryPool,
|
||||
} from '$lib/mods/secondary-pool';
|
||||
import {
|
||||
getModSecondaryPool,
|
||||
replaceModSecondaryPool,
|
||||
} from '$lib/server/mod-secondary-pools';
|
||||
|
||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const;
|
||||
const qualities = MOD_QUALITIES;
|
||||
type QualityValue = {
|
||||
quality: string;
|
||||
primaryLevel1Min: number;
|
||||
@@ -30,11 +38,6 @@ type QualityValue = {
|
||||
primaryLevel20Min: number;
|
||||
primaryLevel20Max: number;
|
||||
};
|
||||
type SecondaryRange = {
|
||||
statId: string;
|
||||
values: Record<string, { min: number; max: number }>;
|
||||
};
|
||||
|
||||
function parseJson<T>(value: FormDataEntryValue | null, fallback: T): T {
|
||||
try {
|
||||
return JSON.parse(String(value ?? '')) as T;
|
||||
@@ -52,16 +55,6 @@ async function snapshot(id: string) {
|
||||
'SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality',
|
||||
)
|
||||
).all(id),
|
||||
secondaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM gear_mod_secondary_options WHERE gear_mod_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
secondaryQualityValues: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_mod_secondary_quality_values WHERE gear_mod_id=? ORDER BY stat_attribute_id,quality',
|
||||
)
|
||||
).all(id),
|
||||
render:
|
||||
(await (
|
||||
await database.prepare(
|
||||
@@ -87,10 +80,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
asc: 'name COLLATE NOCASE ASC,id ASC',
|
||||
desc: 'name COLLATE NOCASE DESC,id DESC',
|
||||
},
|
||||
secondaries: {
|
||||
asc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) ASC,name COLLATE NOCASE ASC',
|
||||
desc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) DESC,name COLLATE NOCASE ASC',
|
||||
},
|
||||
state: {
|
||||
asc: 'active ASC,name COLLATE NOCASE ASC',
|
||||
desc: 'active DESC,name COLLATE NOCASE ASC',
|
||||
@@ -140,7 +129,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
module: 'gearmods',
|
||||
action: 'save',
|
||||
kind: 'gearmod',
|
||||
fields: ['qualityValues', 'secondaryRanges'],
|
||||
fields: ['qualityValues'],
|
||||
},
|
||||
]),
|
||||
]);
|
||||
@@ -161,38 +150,29 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const [mods, qualityRows, secondaryOptions, secondaryValues, stats, renders] =
|
||||
await Promise.all([
|
||||
databaseLimit
|
||||
? (
|
||||
await database.prepare(`SELECT * FROM gear_mods ${where}
|
||||
const [mods, qualityRows, stats, renders, secondaryPool] = await Promise.all([
|
||||
databaseLimit
|
||||
? (
|
||||
await database.prepare(`SELECT * FROM gear_mods ${where}
|
||||
ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)
|
||||
).all(...args, databaseLimit, databaseOffset)
|
||||
: [],
|
||||
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM gear_mod_secondary_options')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_mod_secondary_quality_values',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'",
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
).all(...args, databaseLimit, databaseOffset)
|
||||
: [],
|
||||
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'",
|
||||
)
|
||||
).all(),
|
||||
getModSecondaryPool(database, 'gear'),
|
||||
]);
|
||||
const parsedPending = pendingOnPage.map((item) => ({
|
||||
...item,
|
||||
qualityValues: parseJson(String(item.qualityValues), []),
|
||||
secondaryRanges: parseJson(String(item.secondaryRanges), []),
|
||||
}));
|
||||
return {
|
||||
mods: [
|
||||
@@ -208,23 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
primaryLevel20Min: Number(row.primary_level_20_min),
|
||||
primaryLevel20Max: Number(row.primary_level_20_max),
|
||||
})),
|
||||
secondaryRanges: secondaryOptions
|
||||
.filter((option) => option.gear_mod_id === mod.id)
|
||||
.map((option) => ({
|
||||
statId: String(option.stat_attribute_id),
|
||||
values: Object.fromEntries(
|
||||
secondaryValues
|
||||
.filter(
|
||||
(row) =>
|
||||
row.gear_mod_id === mod.id &&
|
||||
row.stat_attribute_id === option.stat_attribute_id,
|
||||
)
|
||||
.map((row) => [
|
||||
String(row.quality),
|
||||
{ min: Number(row.min_value), max: Number(row.max_value) },
|
||||
]),
|
||||
),
|
||||
})),
|
||||
renderUrl: (() => {
|
||||
const render = renders.find(
|
||||
(item) => item.source_entity_id === mod.id,
|
||||
@@ -235,6 +198,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
})(),
|
||||
})),
|
||||
],
|
||||
secondaryPool,
|
||||
stats: [...pendingStats, ...stats],
|
||||
qualities,
|
||||
summary: {
|
||||
@@ -251,6 +215,60 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveSecondaryPool: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
if (String(form.get('id') ?? '') !== 'gear-mod-secondary-pool') {
|
||||
return fail(400, {
|
||||
gearModSecondaryPoolError:
|
||||
'The shared Gear Mod pool target is invalid.',
|
||||
});
|
||||
}
|
||||
const normalized = normalizeModSecondaryPool(
|
||||
parseJson<unknown>(form.get('secondaryRanges'), null),
|
||||
);
|
||||
if (normalized.error) {
|
||||
return fail(400, { gearModSecondaryPoolError: normalized.error });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const statIds = normalized.pool.map((range) => range.statId);
|
||||
const validStats = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
|
||||
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)
|
||||
).get(...statIds);
|
||||
if (Number(validStats?.count) !== statIds.length) {
|
||||
return fail(400, {
|
||||
gearModSecondaryPoolError:
|
||||
'One or more selected Attributes are unavailable.',
|
||||
});
|
||||
}
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const before = await getModSecondaryPool(database, 'gear');
|
||||
await replaceModSecondaryPool(database, 'gear', normalized.pool);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'gear_mod_secondary_pool.updated',
|
||||
entityId: 'gear-mod-secondary-pool',
|
||||
before,
|
||||
after: await getModSecondaryPool(database, 'gear'),
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
gearModSecondaryPoolSaved: true,
|
||||
gearModSecondaryPoolMode: 'edit',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
@@ -275,14 +293,16 @@ export const actions: Actions = {
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
if (form.has('secondaryRanges')) {
|
||||
return fail(400, {
|
||||
gearModError:
|
||||
'Secondary Attributes must be submitted through the shared pool editor.',
|
||||
});
|
||||
}
|
||||
const qualityValues = parseJson<QualityValue[]>(
|
||||
form.get('qualityValues'),
|
||||
[],
|
||||
);
|
||||
const secondaryRanges = parseJson<SecondaryRange[]>(
|
||||
form.get('secondaryRanges'),
|
||||
[],
|
||||
);
|
||||
if (name.length < 2 || name.length > 100)
|
||||
return fail(400, {
|
||||
gearModError: 'Gear Mod names must be 2–100 characters.',
|
||||
@@ -311,30 +331,6 @@ export const actions: Actions = {
|
||||
'Configure valid primary Level 1 and Level 20 ranges for all four qualities.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
secondaryRanges.length < 2 ||
|
||||
secondaryRanges.length > 50 ||
|
||||
new Set(secondaryRanges.map((range) => range.statId)).size !==
|
||||
secondaryRanges.length ||
|
||||
secondaryRanges.some(
|
||||
(range) =>
|
||||
!range.statId ||
|
||||
qualities.some((quality) => {
|
||||
const values = range.values?.[quality];
|
||||
return (
|
||||
!values ||
|
||||
!Number.isFinite(Number(values.min)) ||
|
||||
!Number.isFinite(Number(values.max)) ||
|
||||
Number(values.max) < Number(values.min)
|
||||
);
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
gearModError:
|
||||
'Configure at least two unique possible secondary attributes with valid ranges for every quality.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (
|
||||
await (
|
||||
@@ -347,15 +343,6 @@ export const actions: Actions = {
|
||||
gearModError: 'That Gear Mod name is already registered.',
|
||||
});
|
||||
}
|
||||
const statIds = secondaryRanges.map((range) => range.statId);
|
||||
const validStats = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
|
||||
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)
|
||||
).get(...statIds);
|
||||
if (Number(validStats?.count) !== statIds.length)
|
||||
return fail(400, {
|
||||
gearModError: 'One or more selected attributes are unavailable.',
|
||||
});
|
||||
const modId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await snapshot(id) : null;
|
||||
@@ -394,29 +381,6 @@ export const actions: Actions = {
|
||||
row.primaryLevel20Min,
|
||||
row.primaryLevel20Max,
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM gear_mod_secondary_options WHERE gear_mod_id=?',
|
||||
)
|
||||
).run(modId);
|
||||
for (const range of secondaryRanges) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO gear_mod_secondary_options(gear_mod_id,stat_attribute_id) VALUES(?,?)',
|
||||
)
|
||||
).run(modId, range.statId);
|
||||
for (const quality of qualities)
|
||||
await (
|
||||
await database.prepare(`INSERT INTO gear_mod_secondary_quality_values
|
||||
(gear_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`)
|
||||
).run(
|
||||
modId,
|
||||
range.statId,
|
||||
quality,
|
||||
range.values[quality].min,
|
||||
range.values[quality].max,
|
||||
);
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
|
||||
@@ -5,26 +5,21 @@
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import ModSecondaryPoolEditor from '$lib/components/ModSecondaryPoolEditor.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const qualityRow = (quality: string) => ({
|
||||
quality, primaryLevel1Min: 0, primaryLevel1Max: 0, primaryLevel20Min: 0, primaryLevel20Max: 0
|
||||
});
|
||||
const blankSecondary = () => ({
|
||||
statId: '',
|
||||
values: Object.fromEntries(data.qualities.map((quality: string) => [quality, { min: 0, max: 0 }]))
|
||||
});
|
||||
const blank = () => ({
|
||||
id: '',
|
||||
name: '',
|
||||
active: 1,
|
||||
renderUrl: '',
|
||||
qualityValues: data.qualities.map(qualityRow),
|
||||
secondaryRanges: [blankSecondary(), blankSecondary()]
|
||||
qualityValues: data.qualities.map(qualityRow)
|
||||
});
|
||||
let editing = $state<any | null>(null);
|
||||
let levelPreview = $state(1);
|
||||
@@ -32,7 +27,6 @@
|
||||
let handledResultToken = $state('');
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
|
||||
const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview));
|
||||
const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
|
||||
const display = (value: unknown) => Number.isFinite(Number(value))
|
||||
@@ -44,22 +38,11 @@
|
||||
qualityValues: data.qualities.map((quality: string) => ({
|
||||
...qualityRow(quality),
|
||||
...(item.qualityValues.find((row: any) => row.quality === quality) ?? {})
|
||||
})),
|
||||
secondaryRanges: item.secondaryRanges.length
|
||||
? item.secondaryRanges.map((range: any) => ({ statId: range.statId, values: structuredClone(range.values) }))
|
||||
: [blankSecondary(), blankSecondary()]
|
||||
}))
|
||||
};
|
||||
levelPreview = 1;
|
||||
qualityPreview = 'High-end';
|
||||
};
|
||||
const addSecondary = () => {
|
||||
if (editing.secondaryRanges.length < 50) editing.secondaryRanges = [...editing.secondaryRanges, blankSecondary()];
|
||||
};
|
||||
const removeSecondary = (index: number) => {
|
||||
if (editing.secondaryRanges.length > 2) {
|
||||
editing.secondaryRanges = editing.secondaryRanges.filter((_: any, itemIndex: number) => itemIndex !== index);
|
||||
}
|
||||
};
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.mods.find((item: any) => item.id === data.requestedItemId);
|
||||
@@ -87,6 +70,8 @@
|
||||
<button class="primary" onclick={() => editing = blank()}>ADD GEAR MOD <span>+</span></button>
|
||||
</div>
|
||||
|
||||
<ModSecondaryPoolEditor kind="Gear Mod" pool={data.secondaryPool} stats={data.stats} error={form?.gearModSecondaryPoolError} saved={form?.gearModSecondaryPoolSaved} resultToken={form?.resultToken}/>
|
||||
|
||||
<KpiFilters active={data.filter} items={[
|
||||
{value:'',label:'TOTAL MODS',count:data.summary.total,description:'Show all Gear Mod definitions'},
|
||||
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to the builder'},
|
||||
@@ -103,7 +88,6 @@
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
|
||||
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
|
||||
<div class="gear-mod-identity">
|
||||
<label class="field">GEAR MOD NAME<input name="name" bind:value={editing.name} maxlength="100" required/></label>
|
||||
<div class="weapon-mod-primary-types gear-mod-primary-types">
|
||||
@@ -143,34 +127,12 @@
|
||||
{#if previewRow}
|
||||
<div class="weapon-mod-preview-values">
|
||||
<span><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span>
|
||||
<span><small>SECONDARY ATTRIBUTE POOL</small><b>{editing.secondaryRanges.length} OPTIONS</b></span>
|
||||
<span><small>SHARED SECONDARY POOL</small><b>{data.secondaryPool.length} OPTIONS</b></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="weapon-mod-secondary-section">
|
||||
<div class="selection-head">
|
||||
<div><span>SECONDARY ATTRIBUTE POOL</span><small>THE BUILDER SELECTS TWO DISTINCT ATTRIBUTES · EVERY OPTION REQUIRES A RANGE AT EACH QUALITY</small></div>
|
||||
<button type="button" class="row-action" onclick={addSecondary} disabled={editing.secondaryRanges.length >= 50}>ADD ATTRIBUTE +</button>
|
||||
</div>
|
||||
<div class="weapon-mod-secondary-options">
|
||||
{#each editing.secondaryRanges as range,index}
|
||||
<div>
|
||||
<strong>{String(index + 1).padStart(2,'0')}</strong>
|
||||
<SearchableAssignment items={data.stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={editing.secondaryRanges.filter((_: any,itemIndex: number) => itemIndex !== index).map((item: any) => item.statId)} bind:value={range.statId}/>
|
||||
{#each data.qualities as quality}
|
||||
<label>
|
||||
<span class={qualityClass(quality)}>{quality.toUpperCase()}</span>
|
||||
<div><input type="number" step="any" bind:value={range.values[quality].min} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].max} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} maximum`} required/></div>
|
||||
</label>
|
||||
{/each}
|
||||
<button type="button" aria-label={`Remove secondary attribute option ${index + 1}`} onclick={() => removeSecondary(index)} disabled={editing.secondaryRanges.length <= 2}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if form?.gearModError}<p class="error">{form.gearModError}</p>{/if}
|
||||
<div class="form-actions split-actions">
|
||||
<div>{#if editing.id}<button class="moderation-action danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE GEAR MOD' : 'UNARCHIVE GEAR MOD'}</button>{/if}</div>
|
||||
@@ -186,12 +148,12 @@
|
||||
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row gear-mod-row table-heading"><SortableColumnHeader label="GEAR MOD" column="name" sort={data.sort}/><span>PRIMARY</span><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><span>QUALITY</span><span>LEVELS</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
<div class="dashboard-row gear-mod-row table-heading"><SortableColumnHeader label="GEAR MOD" column="name" sort={data.sort}/><span>PRIMARY</span><span>SECONDARIES</span><span>QUALITY</span><span>LEVELS</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.mods as item}
|
||||
<div class="dashboard-row gear-mod-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}>
|
||||
<div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div></div>
|
||||
<span><span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>5 OPTIONS</span>
|
||||
<span>{item.secondaryRanges.length} POSSIBLE<small> · SELECT 2</small></span>
|
||||
<span>{data.secondaryPool.length} SHARED<small> · 0 / 1 / 2 / 2</small></span>
|
||||
<span>STANDARD → HIGH-END</span>
|
||||
<span>01 → 20</span>
|
||||
<b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audits: [] as Array<Record<string, unknown>>,
|
||||
database: {
|
||||
exec: vi.fn(async () => undefined),
|
||||
prepare: vi.fn(async () => ({
|
||||
get: vi.fn(async (...statIds: unknown[]) => ({ count: statIds.length })),
|
||||
})),
|
||||
},
|
||||
getModSecondaryPool: vi.fn(),
|
||||
replaceModSecondaryPool: vi.fn(),
|
||||
requirePermission: vi.fn(async (user) => user),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
requireDataView: vi.fn(async (user) => user),
|
||||
requirePermission: mocks.requirePermission,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
auditRecord: vi.fn(),
|
||||
recordIsacAudit: vi.fn(async (event) => {
|
||||
mocks.audits.push(event);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/data-moderation', () => ({
|
||||
getPendingEntities: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/managed-images', () => ({
|
||||
applyGameDataRender: vi.fn(),
|
||||
ManagedImageValidationError: class extends Error {},
|
||||
managedImageUrl: vi.fn(() => ''),
|
||||
processManagedImage: vi.fn(),
|
||||
readGameDataRenderMutation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/mod-secondary-pools', () => ({
|
||||
getModSecondaryPool: mocks.getModSecondaryPool,
|
||||
replaceModSecondaryPool: mocks.replaceModSecondaryPool,
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const endpoints = (
|
||||
level1Min: number,
|
||||
level1Max: number,
|
||||
level20Min: number,
|
||||
level20Max: number,
|
||||
) => ({ level1Min, level1Max, level20Min, level20Max });
|
||||
|
||||
const pool = (suffix: string, offset = 0) => [
|
||||
{
|
||||
statId: `critical-hit-chance-${suffix}`,
|
||||
values: {
|
||||
Specialized: endpoints(1 + offset, 2 + offset, 3 + offset, 4 + offset),
|
||||
Superior: endpoints(2 + offset, 3 + offset, 4 + offset, 5 + offset),
|
||||
'High-end': endpoints(3 + offset, 4 + offset, 5 + offset, 6 + offset),
|
||||
},
|
||||
},
|
||||
{
|
||||
statId: `critical-hit-damage-${suffix}`,
|
||||
values: {
|
||||
Specialized: endpoints(4 + offset, 5 + offset, 6 + offset, 7 + offset),
|
||||
Superior: endpoints(5 + offset, 6 + offset, 7 + offset, 8 + offset),
|
||||
'High-end': endpoints(6 + offset, 7 + offset, 8 + offset, 9 + offset),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('Gear Mod shared secondary pool action', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.audits.length = 0;
|
||||
});
|
||||
|
||||
it('audits the actor, stable pool target, and complete before/after evidence', async () => {
|
||||
const before = pool('before');
|
||||
const after = pool('after', 1);
|
||||
mocks.getModSecondaryPool
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce(after);
|
||||
|
||||
const form = new FormData();
|
||||
form.set('id', 'gear-mod-secondary-pool');
|
||||
form.set('secondaryRanges', JSON.stringify(after));
|
||||
|
||||
const result = await actions.saveSecondaryPool?.({
|
||||
cookies: {} as never,
|
||||
request: new Request(
|
||||
'http://localhost/isac/gearmods?/saveSecondaryPool',
|
||||
{ method: 'POST', body: form },
|
||||
),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
gearModSecondaryPoolSaved: true,
|
||||
gearModSecondaryPoolMode: 'edit',
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||
{ id: 'operator-1' },
|
||||
'data.manage',
|
||||
);
|
||||
expect(mocks.replaceModSecondaryPool).toHaveBeenCalledWith(
|
||||
mocks.database,
|
||||
'gear',
|
||||
after,
|
||||
);
|
||||
expect(mocks.audits).toEqual([
|
||||
{
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: 'gear_mod_secondary_pool.updated',
|
||||
entityId: 'gear-mod-secondary-pool',
|
||||
before,
|
||||
after,
|
||||
database: mocks.database,
|
||||
},
|
||||
]);
|
||||
expect(mocks.database.exec).toHaveBeenNthCalledWith(1, 'BEGIN IMMEDIATE');
|
||||
expect(mocks.database.exec).toHaveBeenNthCalledWith(2, 'COMMIT');
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const attributes = await (
|
||||
await database.prepare(`
|
||||
SELECT id,name,description,unit,type,category,calculation_type,cap_value,
|
||||
gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max
|
||||
gear_min,gear_max,weapon_min,weapon_max
|
||||
FROM stat_attributes
|
||||
WHERE active=1
|
||||
ORDER BY category COLLATE NOCASE,type COLLATE NOCASE,name COLLATE NOCASE
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
gear_max?: number | null;
|
||||
weapon_min?: number | null;
|
||||
weapon_max?: number | null;
|
||||
skill_chip_min?: number | null;
|
||||
skill_chip_max?: number | null;
|
||||
};
|
||||
|
||||
let { data } = $props();
|
||||
@@ -131,7 +129,6 @@
|
||||
const sourceRanges = (attribute: AttributeVariable) => [
|
||||
{ label: 'GEAR', value: valueRange(attribute.gear_min, attribute.gear_max) },
|
||||
{ label: 'WEAPON', value: valueRange(attribute.weapon_min, attribute.weapon_max) },
|
||||
{ label: 'SKILL CHIP', value: valueRange(attribute.skill_chip_min, attribute.skill_chip_max) }
|
||||
].filter((source) => source.value);
|
||||
|
||||
const superiorFirepower = [
|
||||
|
||||
@@ -35,27 +35,15 @@ import {
|
||||
saveConditionalRuleInTransaction,
|
||||
} from '$lib/server/conditional-effects';
|
||||
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
|
||||
import {
|
||||
getOsProtocolProfileAttributeIds,
|
||||
type OsProtocolAttributeType,
|
||||
} from '$lib/server/os-protocol-profiles';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
|
||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
|
||||
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
|
||||
const secondaryRoles = ['Core', 'Damage', 'Health'];
|
||||
const expectedSecondaryAttributes: Record<string, Record<string, string>> = {
|
||||
Firepower: {
|
||||
Core: 'Firepower',
|
||||
Damage: 'All Weapon Damage',
|
||||
Health: 'Health',
|
||||
},
|
||||
Toughness: {
|
||||
Core: 'Toughness',
|
||||
Damage: 'All Weapon Damage',
|
||||
Health: 'Health',
|
||||
},
|
||||
Engineering: {
|
||||
Core: 'Engineering',
|
||||
Damage: 'Skill Damage',
|
||||
Health: 'Health',
|
||||
},
|
||||
};
|
||||
|
||||
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
|
||||
render
|
||||
@@ -88,15 +76,15 @@ const descriptionOutcomes = (
|
||||
Number(left.descriptionSlot) - Number(right.descriptionSlot),
|
||||
);
|
||||
|
||||
async function protocolSnapshot(id: string) {
|
||||
const database = await getDatabase();
|
||||
async function protocolSnapshot(id: string, suppliedDatabase?: Database) {
|
||||
const database = suppliedDatabase ?? (await getDatabase());
|
||||
const talent = await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM os_protocol_talents WHERE protocol_id=?',
|
||||
)
|
||||
).get(id);
|
||||
return {
|
||||
record: await auditRecord('os_protocols', id),
|
||||
record: await auditRecord('os_protocols', id, database),
|
||||
icon: await gameDataIconMetadata(database, 'os_protocol', id),
|
||||
render: renderAuditMetadata(
|
||||
await (
|
||||
@@ -129,8 +117,8 @@ async function protocolSnapshot(id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function profileSnapshot(id: string) {
|
||||
const database = await getDatabase();
|
||||
async function profileSnapshot(id: string, suppliedDatabase?: Database) {
|
||||
const database = suppliedDatabase ?? (await getDatabase());
|
||||
return {
|
||||
record:
|
||||
(await (
|
||||
@@ -292,6 +280,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'attributeType',
|
||||
'primaryStatId',
|
||||
'primaryBaseValue',
|
||||
'hasTalent',
|
||||
'talentName',
|
||||
'descriptionTemplate',
|
||||
'conditionalRule',
|
||||
@@ -341,17 +330,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
profile.quality === protocol.quality &&
|
||||
profile.primary_attribute_type === protocol.attributeType,
|
||||
) ?? null,
|
||||
talent: {
|
||||
name: protocol.talentName,
|
||||
description_template: protocol.descriptionTemplate,
|
||||
conditionalRule: (() => {
|
||||
try {
|
||||
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
talent:
|
||||
protocol.hasTalent === '1'
|
||||
? {
|
||||
name: protocol.talentName,
|
||||
description_template: protocol.descriptionTemplate,
|
||||
conditionalRule: (() => {
|
||||
try {
|
||||
return JSON.parse(String(protocol.conditionalRule ?? 'null'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
const configuredProtocols = await Promise.all(
|
||||
protocols.map(async (protocol) => {
|
||||
@@ -450,27 +442,26 @@ export const actions: Actions = {
|
||||
profileError: 'Choose a valid quality and attribute type.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const expected = expectedSecondaryAttributes[attributeType];
|
||||
const statRows = await (
|
||||
await database.prepare(
|
||||
`SELECT id,name FROM stat_attributes WHERE active=1 AND name IN (?,?,?) COLLATE NOCASE`,
|
||||
)
|
||||
).all(expected.Core, expected.Damage, expected.Health);
|
||||
const statByName = new Map(
|
||||
statRows.map((stat) => [
|
||||
String(stat.name).toLowerCase(),
|
||||
String(stat.id),
|
||||
]),
|
||||
const attributeIds = await getOsProtocolProfileAttributeIds(
|
||||
database,
|
||||
attributeType as OsProtocolAttributeType,
|
||||
);
|
||||
if (statRows.length !== 3)
|
||||
if (Object.values(attributeIds).some((id) => !id))
|
||||
return fail(400, {
|
||||
profileError: `Create active stats named ${Object.values(expected).join(', ')} before configuring this profile.`,
|
||||
profileError:
|
||||
'Create the required active core, damage, and Health Attributes before configuring this profile.',
|
||||
});
|
||||
const effects = secondaryRoles.map((role) => ({
|
||||
role,
|
||||
statId: statByName.get(expected[role].toLowerCase()) ?? '',
|
||||
value: Number(form.get(`value${role}`)),
|
||||
}));
|
||||
const effects = secondaryRoles.map((role) => {
|
||||
const rawValue = form.get(`value${role}`);
|
||||
return {
|
||||
role,
|
||||
statId: attributeIds[role as keyof typeof attributeIds] ?? '',
|
||||
value:
|
||||
typeof rawValue === 'string' && rawValue.trim()
|
||||
? Number(rawValue)
|
||||
: Number.NaN,
|
||||
};
|
||||
});
|
||||
if (
|
||||
effects.some(
|
||||
(effect) =>
|
||||
@@ -481,47 +472,58 @@ export const actions: Actions = {
|
||||
profileError:
|
||||
'Every shared secondary attribute requires a non-negative value.',
|
||||
});
|
||||
const existing = await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
|
||||
)
|
||||
).get(quality, attributeType);
|
||||
const profileId = String(existing?.id ?? randomUUID()),
|
||||
now = new Date().toISOString();
|
||||
const before = existing ? await profileSnapshot(profileId) : null;
|
||||
if (existing)
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?',
|
||||
const saveProfile = database.transactionAsync(async (transaction) => {
|
||||
const tx = transaction as unknown as Database;
|
||||
const existing = await (
|
||||
await tx.prepare(
|
||||
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
|
||||
)
|
||||
).run(now, profileId);
|
||||
else
|
||||
).get(quality, attributeType);
|
||||
const profileId = String(existing?.id ?? randomUUID());
|
||||
const now = new Date().toISOString();
|
||||
const before = existing ? await profileSnapshot(profileId, tx) : null;
|
||||
if (existing)
|
||||
await (
|
||||
await tx.prepare(
|
||||
'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(now, profileId);
|
||||
else
|
||||
await (
|
||||
await tx.prepare(
|
||||
'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
).run(profileId, quality, attributeType, now, now);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)',
|
||||
await tx.prepare(
|
||||
'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?',
|
||||
)
|
||||
).run(profileId, quality, attributeType, now, now);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?',
|
||||
)
|
||||
).run(profileId);
|
||||
for (const effect of effects)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
).run(randomUUID(), profileId, effect.role, effect.statId, effect.value);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: existing
|
||||
? 'os_protocol_profile.updated'
|
||||
: 'os_protocol_profile.created',
|
||||
entityId: profileId,
|
||||
before,
|
||||
after: await profileSnapshot(profileId),
|
||||
).run(profileId);
|
||||
for (const effect of effects)
|
||||
await (
|
||||
await tx.prepare(
|
||||
'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
profileId,
|
||||
effect.role,
|
||||
effect.statId,
|
||||
effect.value,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: existing
|
||||
? 'os_protocol_profile.updated'
|
||||
: 'os_protocol_profile.created',
|
||||
entityId: profileId,
|
||||
before,
|
||||
after: await profileSnapshot(profileId, tx),
|
||||
database: tx,
|
||||
});
|
||||
});
|
||||
await saveProfile.immediate();
|
||||
return { profileSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
save: async ({ request, cookies }) => {
|
||||
@@ -559,7 +561,12 @@ export const actions: Actions = {
|
||||
const quality = String(form.get('quality') ?? '');
|
||||
const attributeType = String(form.get('attributeType') ?? '');
|
||||
const primaryStatId = String(form.get('primaryStatId') ?? '');
|
||||
const primaryBaseValue = Number(form.get('primaryBaseValue'));
|
||||
const primaryBaseValueRaw = form.get('primaryBaseValue');
|
||||
const primaryBaseValue =
|
||||
typeof primaryBaseValueRaw === 'string' && primaryBaseValueRaw.trim()
|
||||
? Number(primaryBaseValueRaw)
|
||||
: Number.NaN;
|
||||
const hasTalent = form.get('hasTalent') === '1';
|
||||
const talentName = String(form.get('talentName') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
@@ -567,22 +574,27 @@ export const actions: Actions = {
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const database = await getDatabase();
|
||||
let conditionalRule;
|
||||
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
|
||||
null;
|
||||
try {
|
||||
const conditionalRuleRaw = form.get('conditionalRule');
|
||||
const activeStats = await (
|
||||
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
|
||||
).all();
|
||||
conditionalRule = parseConditionalRulePayload(
|
||||
conditionalRuleRaw,
|
||||
[{ key: 'base', variantKey: 'base' }],
|
||||
{
|
||||
allowedAttributeIds: new Set(
|
||||
activeStats.map((row) => String(row.id)),
|
||||
),
|
||||
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
||||
},
|
||||
);
|
||||
if (hasTalent) {
|
||||
const conditionalRuleRaw = form.get('conditionalRule');
|
||||
const activeStats = await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM stat_attributes WHERE active=1',
|
||||
)
|
||||
).all();
|
||||
conditionalRule = parseConditionalRulePayload(
|
||||
conditionalRuleRaw,
|
||||
[{ key: 'base', variantKey: 'base' }],
|
||||
{
|
||||
allowedAttributeIds: new Set(
|
||||
activeStats.map((row) => String(row.id)),
|
||||
),
|
||||
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return fail(400, {
|
||||
protocolError:
|
||||
@@ -608,29 +620,39 @@ export const actions: Actions = {
|
||||
protocolError:
|
||||
'Choose the protocol’s unique primary stat and a non-negative level-1 base value.',
|
||||
});
|
||||
const outcomes = mechanicOutcomes(conditionalRule);
|
||||
const numberedOutcomes = descriptionOutcomes(conditionalRule);
|
||||
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
|
||||
const numberedOutcomes = conditionalRule
|
||||
? descriptionOutcomes(conditionalRule)
|
||||
: [];
|
||||
const durationSeconds =
|
||||
conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)
|
||||
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
|
||||
?.expiresAfterSeconds ?? null;
|
||||
const maxStacks = conditionalRule.pools.length
|
||||
const maxStacks = conditionalRule?.pools.length
|
||||
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
|
||||
: null;
|
||||
const requiresCompleteMechanic =
|
||||
hasTalent && conditionalRule?.simulationReadiness !== 'captured_only';
|
||||
if (
|
||||
talentName.length < 2 ||
|
||||
talentName.length > 80 ||
|
||||
!descriptionTemplate ||
|
||||
!conditionalRule.clauses.length ||
|
||||
!outcomes.length
|
||||
hasTalent &&
|
||||
(talentName.length < 2 || talentName.length > 80 || !descriptionTemplate)
|
||||
)
|
||||
return fail(400, {
|
||||
protocolError:
|
||||
'The unique talent requires a name, description template, mechanic branch, and at least one outcome.',
|
||||
'The assigned Talent requires a name and description template.',
|
||||
});
|
||||
if (
|
||||
requiresCompleteMechanic &&
|
||||
(!conditionalRule?.clauses.length || !outcomes.length)
|
||||
)
|
||||
return fail(400, {
|
||||
protocolError:
|
||||
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
|
||||
});
|
||||
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
|
||||
(match) => Number(match[1]),
|
||||
);
|
||||
if (
|
||||
requiresCompleteMechanic &&
|
||||
placeholders.some(
|
||||
(index) =>
|
||||
!numberedOutcomes.some(
|
||||
@@ -642,23 +664,49 @@ export const actions: Actions = {
|
||||
protocolError:
|
||||
'Description placeholders must reference configured numbered outcomes.',
|
||||
});
|
||||
if (descriptionTemplate.includes('{duration}') && durationSeconds === null)
|
||||
if (
|
||||
requiresCompleteMechanic &&
|
||||
descriptionTemplate.includes('{duration}') &&
|
||||
durationSeconds === null
|
||||
)
|
||||
return fail(400, {
|
||||
protocolError:
|
||||
'The description uses {duration}, but no expiring state pool is defined.',
|
||||
});
|
||||
if (descriptionTemplate.includes('{max_stacks}') && !maxStacks)
|
||||
if (
|
||||
requiresCompleteMechanic &&
|
||||
descriptionTemplate.includes('{max_stacks}') &&
|
||||
!maxStacks
|
||||
)
|
||||
return fail(400, {
|
||||
protocolError:
|
||||
'The description uses {max_stacks}, but no state pool maximum is defined.',
|
||||
});
|
||||
if (
|
||||
!(await (
|
||||
const profileAttributeIds = await getOsProtocolProfileAttributeIds(
|
||||
database,
|
||||
attributeType as OsProtocolAttributeType,
|
||||
);
|
||||
const completeProfile =
|
||||
Object.values(profileAttributeIds).every(Boolean) &&
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
|
||||
`SELECT profile.id FROM os_protocol_secondary_profiles profile
|
||||
JOIN os_protocol_secondary_effects effect ON effect.profile_id=profile.id
|
||||
WHERE profile.quality=? AND profile.primary_attribute_type=?
|
||||
GROUP BY profile.id
|
||||
HAVING COUNT(effect.id)=3
|
||||
AND SUM(CASE WHEN effect.effect_role='Core' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
|
||||
AND SUM(CASE WHEN effect.effect_role='Damage' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1
|
||||
AND SUM(CASE WHEN effect.effect_role='Health' AND effect.stat_attribute_id=? THEN 1 ELSE 0 END)=1`,
|
||||
)
|
||||
).get(quality, attributeType))
|
||||
)
|
||||
).get(
|
||||
quality,
|
||||
attributeType,
|
||||
profileAttributeIds.Core,
|
||||
profileAttributeIds.Damage,
|
||||
profileAttributeIds.Health,
|
||||
));
|
||||
if (!completeProfile)
|
||||
return fail(400, {
|
||||
protocolError:
|
||||
'Configure the shared secondary profile for this quality and attribute type first.',
|
||||
@@ -679,11 +727,21 @@ export const actions: Actions = {
|
||||
).get(id)
|
||||
: null;
|
||||
if (
|
||||
await (
|
||||
existingTalent &&
|
||||
!hasTalent &&
|
||||
form.get('confirmTalentRemoval') !== 'REMOVE'
|
||||
)
|
||||
return fail(400, {
|
||||
protocolError:
|
||||
'Confirm removal of the existing Talent and mechanic program.',
|
||||
});
|
||||
if (
|
||||
hasTalent &&
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?',
|
||||
)
|
||||
).get(talentName, existingTalent?.id ?? 'new')
|
||||
).get(talentName, existingTalent?.id ?? 'new'))
|
||||
)
|
||||
return fail(409, {
|
||||
protocolError: 'That protocol talent name is already in use.',
|
||||
@@ -701,71 +759,83 @@ export const actions: Actions = {
|
||||
return fail(400, {
|
||||
protocolError: 'One or more selected attributes are unavailable.',
|
||||
});
|
||||
const protocolId = id || randomUUID(),
|
||||
const protocolId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
|
||||
talentId = String(existingTalent?.id ?? randomUUID()),
|
||||
now = new Date().toISOString();
|
||||
const before = id ? await protocolSnapshot(id) : null;
|
||||
if (id && !before?.record)
|
||||
return fail(404, { protocolError: 'OS Protocol not found.' });
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
const saveProtocol = database.transactionAsync(async (transaction) => {
|
||||
const tx = transaction as unknown as Database;
|
||||
const before = id ? await protocolSnapshot(id, tx) : null;
|
||||
if (id && !before?.record)
|
||||
return fail(404, { protocolError: 'OS Protocol not found.' });
|
||||
if (id) {
|
||||
await (
|
||||
await database.prepare(
|
||||
await tx.prepare(
|
||||
'UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(name, quality, attributeType, now, id);
|
||||
} else
|
||||
await (
|
||||
await database.prepare(
|
||||
await tx.prepare(
|
||||
'INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)',
|
||||
)
|
||||
).run(protocolId, name, quality, attributeType, now, now);
|
||||
await (
|
||||
await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
|
||||
await tx.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
|
||||
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)
|
||||
).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
|
||||
await (
|
||||
await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
||||
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
|
||||
).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
|
||||
)
|
||||
).run(talentId);
|
||||
let compatibilitySortOrder = 0;
|
||||
for (const outcome of numberedOutcomes) {
|
||||
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
|
||||
continue;
|
||||
compatibilitySortOrder += 1;
|
||||
if (hasTalent && conditionalRule) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
|
||||
await tx.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
||||
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
|
||||
).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
|
||||
await (
|
||||
await tx.prepare(
|
||||
'DELETE FROM os_protocol_talent_effects WHERE talent_id=?',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
).run(talentId);
|
||||
let compatibilitySortOrder = 0;
|
||||
for (const outcome of numberedOutcomes) {
|
||||
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
|
||||
continue;
|
||||
compatibilitySortOrder += 1;
|
||||
await (
|
||||
await tx.prepare(
|
||||
'INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
talentId,
|
||||
outcome.attributeId,
|
||||
outcome.variants.base.value,
|
||||
compatibilitySortOrder,
|
||||
);
|
||||
}
|
||||
await saveConditionalRuleInTransaction(
|
||||
tx,
|
||||
'os_protocol_talent',
|
||||
talentId,
|
||||
outcome.attributeId,
|
||||
outcome.variants.base.value,
|
||||
compatibilitySortOrder,
|
||||
conditionalRule,
|
||||
);
|
||||
} else if (existingTalent) {
|
||||
await (
|
||||
await tx.prepare(
|
||||
"DELETE FROM conditional_effect_rules WHERE source_type='os_protocol_talent' AND source_id=?",
|
||||
)
|
||||
).run(String(existingTalent.id));
|
||||
await (
|
||||
await tx.prepare('DELETE FROM os_protocol_talents WHERE id=?')
|
||||
).run(String(existingTalent.id));
|
||||
}
|
||||
await saveConditionalRuleInTransaction(
|
||||
database,
|
||||
'os_protocol_talent',
|
||||
talentId,
|
||||
conditionalRule,
|
||||
);
|
||||
await applyIconMutation(
|
||||
database,
|
||||
tx,
|
||||
'os_protocol',
|
||||
protocolId,
|
||||
iconMutation,
|
||||
operator.id,
|
||||
);
|
||||
await applyGameDataRender(
|
||||
database,
|
||||
tx,
|
||||
'os_protocol',
|
||||
protocolId,
|
||||
name,
|
||||
@@ -778,13 +848,13 @@ export const actions: Actions = {
|
||||
action: id ? 'os_protocol.updated' : 'os_protocol.created',
|
||||
entityId: protocolId,
|
||||
before,
|
||||
after: await protocolSnapshot(protocolId),
|
||||
after: await protocolSnapshot(protocolId, tx),
|
||||
database: tx,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const transactionResult = await saveProtocol.immediate();
|
||||
if (transactionResult) return transactionResult;
|
||||
return {
|
||||
protocolSaved: true,
|
||||
protocolMode: id ? 'edit' : 'add',
|
||||
|
||||
@@ -29,15 +29,16 @@
|
||||
simulationReadiness: 'captured_only',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: [{key:'primary',label:'Primary',event:'tick',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}]
|
||||
clauses: []
|
||||
});
|
||||
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
||||
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
|
||||
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
|
||||
let editing = $state<any | null>(null);
|
||||
let profileEditing = $state<any | null>(null);
|
||||
let talentUpgrade = $state(0);
|
||||
let protocolLevel = $state(1);
|
||||
let handledResultToken = $state('');
|
||||
let handledErrorForm = $state<unknown>(null);
|
||||
let iconResetToken = $state(0);
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
@@ -86,6 +87,8 @@
|
||||
editing = {
|
||||
...protocol,
|
||||
primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' },
|
||||
hasTalent: Boolean(protocol.talent),
|
||||
talentOpen: false,
|
||||
talent: protocol.talent ? {
|
||||
...protocol.talent,
|
||||
conditionalRule: protocol.talent.conditionalRule ?? blankRule()
|
||||
@@ -115,6 +118,10 @@
|
||||
talentUpgrade = 0;
|
||||
protocolLevel = 1;
|
||||
}
|
||||
if (form?.protocolError && form !== handledErrorForm) {
|
||||
handledErrorForm = form;
|
||||
if (editing?.hasTalent) editing.talentOpen = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -162,6 +169,7 @@
|
||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="hasTalent" value={editing.hasTalent ? '1' : '0'}/>
|
||||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)} />
|
||||
<div class="protocol-identity-grid">
|
||||
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="OS PROTOCOL RENDER" resetToken={renderResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
|
||||
@@ -178,21 +186,31 @@
|
||||
<div class="protocol-primary-editor"><div class="field"><span>PRIMARY STAT</span><SearchableAssignment items={data.stats} fieldName="primaryStatId" mode="single" compact subjectLabel="PRIMARY ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={editing.primaryEffect.stat_attribute_id}/></div><label class="field">LEVEL 1 BASE VALUE<input name="primaryBaseValue" type="number" step="any" min="0" bind:value={editing.primaryEffect.base_value} required/></label><div><span>LEVEL {protocolLevel} PREVIEW</span><b>{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}</b><small>LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}</small></div></div>
|
||||
|
||||
<section class="protocol-talent">
|
||||
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES FOR MARKED VALUES</small></div><b>{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
|
||||
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
|
||||
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.stats}
|
||||
valueColumns={talentValueColumns}
|
||||
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
|
||||
aiContext={{
|
||||
sourceKind: 'os_protocol_talent',
|
||||
sourceName: editing?.talent?.name ?? '',
|
||||
sourceDescription: editing?.talent?.description_template ?? ''
|
||||
}}
|
||||
allowProtocolUpgradeScaling
|
||||
bind:rule={editing.talent.conditionalRule}
|
||||
/>
|
||||
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OPTIONAL OWNED TALENT · FIVE LINEAR UPGRADES FOR MARKED VALUES</small></div><SegmentedChoice legend="TALENT ASSIGNMENT" name="talent-assignment" options={[{value:'0',label:'NOT ASSIGNED'},{value:'1',label:'ASSIGNED'}]} value={editing.hasTalent ? '1' : '0'} onchange={(values) => { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/></div>
|
||||
{#if editing.hasTalent}
|
||||
<details class="modeling-disclosure protocol-talent-disclosure" bind:open={editing.talentOpen}>
|
||||
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>{previewOutcomes().length} PROTOCOL OUTCOME{previewOutcomes().length === 1 ? '' : 'S'} · {editing.talent.conditionalRule.simulationReadiness === 'captured_only' ? 'CAPTURE ONLY' : 'MODELED'}</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
|
||||
<div class="modeling-content">
|
||||
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
|
||||
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.stats}
|
||||
valueColumns={talentValueColumns}
|
||||
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
|
||||
aiContext={{
|
||||
sourceKind: 'os_protocol_talent',
|
||||
sourceName: editing?.talent?.name ?? '',
|
||||
sourceDescription: editing?.talent?.description_template ?? ''
|
||||
}}
|
||||
allowProtocolUpgradeScaling
|
||||
bind:rule={editing.talent.conditionalRule}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
{:else}
|
||||
<p class="console-note">This OS Protocol has no Talent. Its primary and inherited secondary Attributes remain available.</p>
|
||||
{#if editing.talent.id}<label class="protocol-talent-removal"><input type="checkbox" name="confirmTalentRemoval" value="REMOVE" required/> CONFIRM REMOVAL OF THE EXISTING TALENT AND MECHANIC PROGRAM</label>{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{#if form?.protocolError}<p class="error">{form.protocolError}</p>{/if}
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0}>SAVE PROTOCOL →</button></div></div>
|
||||
@@ -206,7 +224,7 @@
|
||||
<div class="dashboard-table protocol-dashboard-table">
|
||||
<div class="dashboard-row protocol-row table-heading"><SortableColumnHeader label="OS PROTOCOL" column="name" sort={data.sort}/><SortableColumnHeader label="QUALITY" column="quality" sort={data.sort}/><SortableColumnHeader label="TYPE" column="type" sort={data.sort}/><SortableColumnHeader label="UNIQUE PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="UNIQUE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.protocols as protocol}
|
||||
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> {protocol.talent?.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CONFIG REQUIRED · '}5 UPGRADES</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{#if protocol.talent}<span class="mini-diamonds">◆◆◆◆◆</span> {protocol.talent.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CAPTURE ONLY · '}5 UPGRADES{:else}OPTIONAL{/if}</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
|
||||
{#if editing?.id === protocol.id}<RegistryInlineEditor id={`protocol-editor-${protocol.id}`} label={`Edit ${protocol.name}`}>{@render protocolEditor()}</RegistryInlineEditor>{/if}
|
||||
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const transaction = { prepare: vi.fn() };
|
||||
const database = {
|
||||
prepare: vi.fn(),
|
||||
transactionAsync: vi.fn(),
|
||||
};
|
||||
return {
|
||||
transaction,
|
||||
database,
|
||||
profileExists: false,
|
||||
effects: [] as Array<Record<string, unknown>>,
|
||||
getAttributeIds: vi.fn(),
|
||||
recordIsacAudit: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
requireDataView: vi.fn(async (user) => user),
|
||||
requirePermission: vi.fn(async (user) => user),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
auditRecord: vi.fn(),
|
||||
recordIsacAudit: mocks.recordIsacAudit,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/data-moderation', () => ({
|
||||
getPendingEntities: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/os-protocol-profiles', () => ({
|
||||
getOsProtocolProfileAttributeIds: mocks.getAttributeIds,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/game-data-icons', () => ({
|
||||
applyIconMutation: vi.fn(),
|
||||
gameDataIconMetadata: vi.fn(),
|
||||
gameDataIconUrl: vi.fn(() => ''),
|
||||
GameDataIconValidationError: class extends Error {},
|
||||
readIconMutation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/managed-images', () => ({
|
||||
applyGameDataRender: vi.fn(),
|
||||
ManagedImageValidationError: class extends Error {},
|
||||
managedImageUrl: vi.fn(() => ''),
|
||||
processManagedImage: vi.fn(),
|
||||
readGameDataRenderMutation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/conditional-effects', () => ({
|
||||
conditionalRuleSnapshot: vi.fn(),
|
||||
loadConditionalRuleDraft: vi.fn(),
|
||||
parseConditionalRulePayload: vi.fn(),
|
||||
saveConditionalRuleInTransaction: vi.fn(),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const request = (includeValues = true) => {
|
||||
const form = new FormData();
|
||||
form.set('quality', 'Standard');
|
||||
form.set('attributeType', 'Firepower');
|
||||
if (includeValues) {
|
||||
form.set('valueCore', '10');
|
||||
form.set('valueDamage', '20');
|
||||
form.set('valueHealth', '30');
|
||||
}
|
||||
return new Request('http://localhost/isac/protocols?/saveProfile', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
describe('OS Protocol shared profile action', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.profileExists = false;
|
||||
mocks.effects = [];
|
||||
mocks.getAttributeIds.mockResolvedValue({
|
||||
Core: 'firepower-flat',
|
||||
Damage: 'weapon-damage-flat',
|
||||
Health: 'health-flat',
|
||||
});
|
||||
mocks.transaction.prepare.mockImplementation(async (sql: string) => ({
|
||||
get: vi.fn(async () => {
|
||||
if (sql.includes('WHERE quality=?'))
|
||||
return mocks.profileExists ? { id: 'profile-1' } : null;
|
||||
if (sql.includes('WHERE id=?'))
|
||||
return mocks.profileExists
|
||||
? {
|
||||
id: 'profile-1',
|
||||
quality: 'Standard',
|
||||
primary_attribute_type: 'Firepower',
|
||||
}
|
||||
: null;
|
||||
return null;
|
||||
}),
|
||||
all: vi.fn(async () =>
|
||||
sql.includes('os_protocol_secondary_effects') ? mocks.effects : [],
|
||||
),
|
||||
run: vi.fn(async (...args: unknown[]) => {
|
||||
if (sql.includes('INSERT INTO os_protocol_secondary_profiles'))
|
||||
mocks.profileExists = true;
|
||||
if (sql.includes('DELETE FROM os_protocol_secondary_effects'))
|
||||
mocks.effects = [];
|
||||
if (sql.includes('INSERT INTO os_protocol_secondary_effects'))
|
||||
mocks.effects.push({
|
||||
profile_id: 'profile-1',
|
||||
effect_role: args[2],
|
||||
stat_attribute_id: args[3],
|
||||
value: args[4],
|
||||
});
|
||||
}),
|
||||
}));
|
||||
mocks.database.transactionAsync.mockImplementation(
|
||||
(callback: (transaction: typeof mocks.transaction) => Promise<void>) => {
|
||||
const run = vi.fn(() => callback(mocks.transaction));
|
||||
return Object.assign(run, { immediate: run });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('creates and audits all three profile roles in one owned transaction', async () => {
|
||||
const result = await actions.saveProfile?.({
|
||||
cookies: {} as never,
|
||||
request: request(),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
profileSaved: true,
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.database.transactionAsync).toHaveBeenCalledOnce();
|
||||
expect(mocks.effects).toHaveLength(3);
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: 'os_protocol_profile.created',
|
||||
before: null,
|
||||
after: expect.objectContaining({
|
||||
effects: expect.arrayContaining([
|
||||
expect.objectContaining({ effect_role: 'Core', value: 10 }),
|
||||
expect.objectContaining({ effect_role: 'Damage', value: 20 }),
|
||||
expect.objectContaining({ effect_role: 'Health', value: 30 }),
|
||||
]),
|
||||
}),
|
||||
database: mocks.transaction,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start persistence when a required Attribute is missing', async () => {
|
||||
mocks.getAttributeIds.mockResolvedValue({
|
||||
Core: 'firepower-flat',
|
||||
Damage: null,
|
||||
Health: 'health-flat',
|
||||
});
|
||||
|
||||
const result = await actions.saveProfile?.({
|
||||
cookies: {} as never,
|
||||
request: request(),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects missing numeric values instead of coercing them to zero', async () => {
|
||||
const result = await actions.saveProfile?.({
|
||||
cookies: {} as never,
|
||||
request: request(false),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getDatabase: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
requireDataView: vi.fn(async (user) => user),
|
||||
requirePermission: vi.fn(async (user) => user),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: mocks.getDatabase,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/data-moderation', () => ({
|
||||
getPendingEntities: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
let database: Database;
|
||||
|
||||
const profileRequest = () => {
|
||||
const form = new FormData();
|
||||
form.set('quality', 'Standard');
|
||||
form.set('attributeType', 'Firepower');
|
||||
form.set('valueCore', '10');
|
||||
form.set('valueDamage', '20');
|
||||
form.set('valueHealth', '30');
|
||||
return new Request('http://localhost/isac/protocols?/saveProfile', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
const protocolRequest = () => {
|
||||
const form = new FormData();
|
||||
form.set('id', '');
|
||||
form.set('name', 'Integration Protocol');
|
||||
form.set('quality', 'Standard');
|
||||
form.set('attributeType', 'Firepower');
|
||||
form.set('primaryStatId', 'weapon-damage-flat');
|
||||
form.set('primaryBaseValue', '5');
|
||||
form.set('hasTalent', '0');
|
||||
return new Request('http://localhost/isac/protocols?/save', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
describe('OS Protocol save actions with embedded Turso', () => {
|
||||
beforeEach(async () => {
|
||||
database = await connect(':memory:');
|
||||
mocks.getDatabase.mockResolvedValue(database);
|
||||
await database.exec(`
|
||||
CREATE TABLE stat_attributes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
unit TEXT NOT NULL,
|
||||
active INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE os_protocols (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
quality TEXT NOT NULL,
|
||||
primary_attribute_type TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX os_protocols_name_nocase_idx ON os_protocols(name COLLATE NOCASE);
|
||||
CREATE TABLE os_protocol_primary_effects (
|
||||
id TEXT PRIMARY KEY,
|
||||
protocol_id TEXT NOT NULL UNIQUE,
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
base_value REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE os_protocol_secondary_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
quality TEXT NOT NULL,
|
||||
primary_attribute_type TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(quality, primary_attribute_type)
|
||||
);
|
||||
CREATE TABLE os_protocol_secondary_effects (
|
||||
id TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
effect_role TEXT NOT NULL,
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
UNIQUE(profile_id, effect_role),
|
||||
UNIQUE(profile_id, stat_attribute_id)
|
||||
);
|
||||
CREATE TABLE os_protocol_talents (
|
||||
id TEXT PRIMARY KEY,
|
||||
protocol_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description_template TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE os_protocol_talent_effects (
|
||||
id TEXT PRIMARY KEY,
|
||||
talent_id TEXT NOT NULL,
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
base_value REAL NOT NULL,
|
||||
sort_order INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE game_data_icons (
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
storage_key TEXT,
|
||||
byte_size INTEGER,
|
||||
updated_at TEXT
|
||||
);
|
||||
CREATE TABLE managed_images (
|
||||
id TEXT PRIMARY KEY,
|
||||
category TEXT,
|
||||
tags_json TEXT,
|
||||
source_entity_type TEXT,
|
||||
source_entity_id TEXT
|
||||
);
|
||||
CREATE TABLE audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
before_json TEXT,
|
||||
after_json TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
for (const [id, name, unit] of [
|
||||
['firepower-flat', 'Firepower', ''],
|
||||
['firepower-percent', 'Firepower', '%'],
|
||||
['weapon-damage-flat', 'Weapon Damage', ''],
|
||||
['weapon-damage-percent', 'Weapon Damage', '%'],
|
||||
['health-flat', 'Health', ''],
|
||||
]) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO stat_attributes(id,name,unit,active) VALUES(?,?,?,1)',
|
||||
)
|
||||
).run(id, name, unit);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await database.close();
|
||||
});
|
||||
|
||||
it('saves a complete shared profile and then a no-Talent Protocol', async () => {
|
||||
const profileResult = await actions.saveProfile?.({
|
||||
cookies: {} as never,
|
||||
request: profileRequest(),
|
||||
} as never);
|
||||
|
||||
expect(profileResult).toMatchObject({ profileSaved: true });
|
||||
const profile = await (
|
||||
await database.prepare(
|
||||
"SELECT id FROM os_protocol_secondary_profiles WHERE quality='Standard' AND primary_attribute_type='Firepower'",
|
||||
)
|
||||
).get();
|
||||
expect(profile).toBeTruthy();
|
||||
expect(
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT effect_role,stat_attribute_id,value FROM os_protocol_secondary_effects WHERE profile_id=? ORDER BY effect_role',
|
||||
)
|
||||
).all(profile?.id),
|
||||
).toEqual([
|
||||
{
|
||||
effect_role: 'Core',
|
||||
stat_attribute_id: 'firepower-flat',
|
||||
value: 10,
|
||||
},
|
||||
{
|
||||
effect_role: 'Damage',
|
||||
stat_attribute_id: 'weapon-damage-flat',
|
||||
value: 20,
|
||||
},
|
||||
{
|
||||
effect_role: 'Health',
|
||||
stat_attribute_id: 'health-flat',
|
||||
value: 30,
|
||||
},
|
||||
]);
|
||||
|
||||
const protocolResult = await actions.save?.({
|
||||
cookies: {} as never,
|
||||
request: protocolRequest(),
|
||||
} as never);
|
||||
|
||||
expect(protocolResult).toMatchObject({
|
||||
protocolSaved: true,
|
||||
protocolMode: 'add',
|
||||
});
|
||||
expect(
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT name,quality,primary_attribute_type FROM os_protocols',
|
||||
)
|
||||
).get(),
|
||||
).toEqual({
|
||||
name: 'Integration Protocol',
|
||||
quality: 'Standard',
|
||||
primary_attribute_type: 'Firepower',
|
||||
});
|
||||
expect(
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT entity_type,action FROM audit_log ORDER BY created_at',
|
||||
)
|
||||
).all(),
|
||||
).toEqual([
|
||||
{
|
||||
entity_type: 'game_data',
|
||||
action: 'os_protocol_profile.created',
|
||||
},
|
||||
{ entity_type: 'game_data', action: 'os_protocol.created' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const capturedRule = {
|
||||
version: 1 as const,
|
||||
simulationReadiness: 'captured_only' as const,
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: [],
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const events: string[] = [];
|
||||
const database = {
|
||||
exec: vi.fn(async (sql: string) => {
|
||||
events.push(sql);
|
||||
}),
|
||||
prepare: vi.fn(),
|
||||
transactionAsync: vi.fn(),
|
||||
};
|
||||
database.transactionAsync.mockImplementation(
|
||||
(callback: (transaction: typeof database) => Promise<unknown>) => {
|
||||
const run = vi.fn(async () => {
|
||||
events.push('BEGIN');
|
||||
try {
|
||||
const result = await callback(database);
|
||||
events.push('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
events.push('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
return Object.assign(run, { immediate: run });
|
||||
},
|
||||
);
|
||||
return {
|
||||
events,
|
||||
talentExists: false,
|
||||
profileConfigured: true,
|
||||
auditRecord: vi.fn(),
|
||||
conditionalRuleSnapshot: vi.fn(),
|
||||
parseConditionalRulePayload: vi.fn(),
|
||||
recordIsacAudit: vi.fn(async () => {
|
||||
events.push('audit');
|
||||
}),
|
||||
saveConditionalRuleInTransaction: vi.fn(async () => {
|
||||
events.push('save conditional rule');
|
||||
}),
|
||||
database,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
requireDataView: vi.fn(async (user) => user),
|
||||
requirePermission: vi.fn(async (user) => user),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
auditRecord: mocks.auditRecord,
|
||||
recordIsacAudit: mocks.recordIsacAudit,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/data-moderation', () => ({
|
||||
getPendingEntities: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/os-protocol-profiles', () => ({
|
||||
getOsProtocolProfileAttributeIds: vi.fn(async () => ({
|
||||
Core: 'firepower',
|
||||
Damage: 'weapon-damage',
|
||||
Health: 'health',
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/game-data-icons', () => ({
|
||||
applyIconMutation: vi.fn(),
|
||||
gameDataIconMetadata: vi.fn(async () => null),
|
||||
gameDataIconUrl: vi.fn(() => ''),
|
||||
GameDataIconValidationError: class extends Error {},
|
||||
readIconMutation: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/managed-images', () => ({
|
||||
applyGameDataRender: vi.fn(),
|
||||
ManagedImageValidationError: class extends Error {},
|
||||
managedImageUrl: vi.fn(() => ''),
|
||||
processManagedImage: vi.fn(),
|
||||
readGameDataRenderMutation: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/conditional-effects', () => ({
|
||||
conditionalRuleSnapshot: mocks.conditionalRuleSnapshot,
|
||||
loadConditionalRuleDraft: vi.fn(),
|
||||
parseConditionalRulePayload: mocks.parseConditionalRulePayload,
|
||||
saveConditionalRuleInTransaction: mocks.saveConditionalRuleInTransaction,
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const protocolId = 'protocol-1';
|
||||
const talentId = 'talent-1';
|
||||
const protocolRecord = {
|
||||
id: protocolId,
|
||||
name: 'Adaptive Kernel',
|
||||
quality: 'Standard',
|
||||
primary_attribute_type: 'Firepower',
|
||||
active: 1,
|
||||
};
|
||||
const primaryEffect = {
|
||||
protocol_id: protocolId,
|
||||
stat_attribute_id: 'weapon-damage',
|
||||
base_value: 5,
|
||||
};
|
||||
const talentRecord = {
|
||||
id: talentId,
|
||||
protocol_id: protocolId,
|
||||
name: 'Escalation Loop',
|
||||
description_template: 'Captured Talent',
|
||||
};
|
||||
|
||||
const saveRequest = ({
|
||||
id = '',
|
||||
hasTalent = false,
|
||||
confirmRemoval = false,
|
||||
includePrimaryValue = true,
|
||||
}: {
|
||||
id?: string;
|
||||
hasTalent?: boolean;
|
||||
confirmRemoval?: boolean;
|
||||
includePrimaryValue?: boolean;
|
||||
} = {}) => {
|
||||
const form = new FormData();
|
||||
form.set('id', id);
|
||||
form.set('name', 'Adaptive Kernel');
|
||||
form.set('quality', 'Standard');
|
||||
form.set('attributeType', 'Firepower');
|
||||
form.set('primaryStatId', 'weapon-damage');
|
||||
if (includePrimaryValue) form.set('primaryBaseValue', '5');
|
||||
form.set('hasTalent', hasTalent ? '1' : '0');
|
||||
if (hasTalent) {
|
||||
form.set('talentName', 'Escalation Loop');
|
||||
form.set('descriptionTemplate', 'Captured Talent');
|
||||
form.set('conditionalRule', JSON.stringify(capturedRule));
|
||||
}
|
||||
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
|
||||
return new Request('http://localhost/isac/protocols?/save', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
const runSave = (options?: Parameters<typeof saveRequest>[0]) =>
|
||||
actions.save?.({
|
||||
cookies: {} as never,
|
||||
request: saveRequest(options),
|
||||
} as never);
|
||||
|
||||
describe('OS Protocol optional Talent action', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.events.length = 0;
|
||||
mocks.talentExists = false;
|
||||
mocks.profileConfigured = true;
|
||||
mocks.parseConditionalRulePayload.mockReturnValue(capturedRule);
|
||||
mocks.conditionalRuleSnapshot.mockResolvedValue({
|
||||
id: 'rule-1',
|
||||
source_id: talentId,
|
||||
});
|
||||
mocks.auditRecord.mockResolvedValue(protocolRecord);
|
||||
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||
get: vi.fn(async (...args: unknown[]) => {
|
||||
if (sql.includes('FROM os_protocol_secondary_profiles'))
|
||||
return mocks.profileConfigured ? { id: 'profile-1' } : null;
|
||||
if (sql.includes('FROM os_protocols WHERE name=')) return null;
|
||||
if (sql.includes('FROM os_protocol_talents WHERE protocol_id='))
|
||||
return mocks.talentExists ? talentRecord : null;
|
||||
if (sql.includes('FROM os_protocol_talents WHERE name=')) return null;
|
||||
if (sql.includes('COUNT(*) AS count FROM stat_attributes'))
|
||||
return { count: args.length };
|
||||
if (sql.includes('FROM managed_images')) return null;
|
||||
if (sql.includes('FROM os_protocol_primary_effects'))
|
||||
return primaryEffect;
|
||||
return null;
|
||||
}),
|
||||
all: vi.fn(async () => []),
|
||||
run: vi.fn(async () => {
|
||||
if (sql.includes('INSERT INTO os_protocol_talents'))
|
||||
mocks.talentExists = true;
|
||||
if (sql.includes('DELETE FROM conditional_effect_rules'))
|
||||
mocks.events.push('delete conditional rule');
|
||||
if (sql.includes('DELETE FROM os_protocol_talents')) {
|
||||
mocks.events.push('delete talent');
|
||||
mocks.talentExists = false;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('creates a protocol without a Talent', async () => {
|
||||
const result = await runSave();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
protocolSaved: true,
|
||||
protocolMode: 'add',
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.parseConditionalRulePayload).not.toHaveBeenCalled();
|
||||
expect(mocks.saveConditionalRuleInTransaction).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mocks.database.prepare.mock.calls.some(([sql]) =>
|
||||
String(sql).includes('INSERT INTO os_protocol_talents'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: 'os_protocol.created',
|
||||
before: null,
|
||||
after: expect.objectContaining({ talent: null, conditional: null }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a protocol whose shared profile is incomplete', async () => {
|
||||
mocks.profileConfigured = false;
|
||||
|
||||
const result = await runSave();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 400,
|
||||
data: {
|
||||
protocolError:
|
||||
'Configure the shared secondary profile for this quality and attribute type first.',
|
||||
},
|
||||
});
|
||||
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a missing primary value instead of coercing it to zero', async () => {
|
||||
const result = await runSave({ includePrimaryValue: false });
|
||||
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts an assigned CAPTURE ONLY Talent with no mechanic branches', async () => {
|
||||
const result = await runSave({ hasTalent: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
protocolSaved: true,
|
||||
protocolMode: 'add',
|
||||
});
|
||||
expect(mocks.parseConditionalRulePayload).toHaveBeenCalledOnce();
|
||||
expect(mocks.saveConditionalRuleInTransaction).toHaveBeenCalledWith(
|
||||
mocks.database,
|
||||
'os_protocol_talent',
|
||||
expect.any(String),
|
||||
capturedRule,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['ready', 'requires_assumptions'] as const)(
|
||||
'rejects an empty %s mechanic program',
|
||||
async (simulationReadiness) => {
|
||||
mocks.parseConditionalRulePayload.mockReturnValue({
|
||||
...capturedRule,
|
||||
simulationReadiness,
|
||||
});
|
||||
|
||||
const result = await runSave({ hasTalent: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 400,
|
||||
data: {
|
||||
protocolError:
|
||||
'Add a mechanic branch and outcome, or mark the Talent CAPTURE ONLY.',
|
||||
},
|
||||
});
|
||||
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('requires confirmation before removing an existing Talent', async () => {
|
||||
mocks.talentExists = true;
|
||||
|
||||
const result = await runSave({ id: protocolId });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 400,
|
||||
data: {
|
||||
protocolError:
|
||||
'Confirm removal of the existing Talent and mechanic program.',
|
||||
},
|
||||
});
|
||||
expect(mocks.database.transactionAsync).not.toHaveBeenCalled();
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes an existing Talent and its mechanic in the audited transaction', async () => {
|
||||
mocks.talentExists = true;
|
||||
|
||||
const result = await runSave({
|
||||
id: protocolId,
|
||||
confirmRemoval: true,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
protocolSaved: true,
|
||||
protocolMode: 'edit',
|
||||
});
|
||||
expect(mocks.events).toEqual([
|
||||
'BEGIN',
|
||||
'delete conditional rule',
|
||||
'delete talent',
|
||||
'audit',
|
||||
'COMMIT',
|
||||
]);
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: 'os_protocol.updated',
|
||||
entityId: protocolId,
|
||||
before: expect.objectContaining({
|
||||
talent: talentRecord,
|
||||
conditional: expect.objectContaining({ source_id: talentId }),
|
||||
}),
|
||||
after: expect.objectContaining({ talent: null, conditional: null }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
getRegistrySearch,
|
||||
getRegistrySort,
|
||||
} from '$lib/server/pagination';
|
||||
import { refreshAbilityFocuses } from '$lib/server/specialization-readiness';
|
||||
import {
|
||||
refreshAbilityFocuses,
|
||||
refreshSpecializationFocusReady,
|
||||
} from '$lib/server/specialization-readiness';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -36,6 +39,18 @@ import {
|
||||
saveConditionalRuleInTransaction,
|
||||
} from '$lib/server/conditional-effects';
|
||||
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
|
||||
import {
|
||||
normalizeSkillChipProfiles,
|
||||
SKILL_CHIP_QUALITIES,
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
} from '$lib/skill-chips/profiles';
|
||||
import {
|
||||
getSkillChipPrimaryAttributeIds,
|
||||
getSkillChipPrimaryProfiles,
|
||||
getSkillChipSecondaryPool,
|
||||
replaceSkillChipPrimaryProfiles,
|
||||
replaceSkillChipSecondaryPool,
|
||||
} from '$lib/server/skill-chip-profiles';
|
||||
|
||||
const mechanicOutcomes = (
|
||||
rule: ReturnType<typeof parseConditionalRulePayload>,
|
||||
@@ -68,6 +83,16 @@ const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
|
||||
}
|
||||
: null;
|
||||
|
||||
async function refreshAllFocusReadiness(
|
||||
database: Awaited<ReturnType<typeof getDatabase>>,
|
||||
) {
|
||||
const focuses = await (
|
||||
await database.prepare('SELECT id FROM specialization_focuses')
|
||||
).all();
|
||||
for (const focus of focuses)
|
||||
await refreshSpecializationFocusReady(database, String(focus.id));
|
||||
}
|
||||
|
||||
async function snapshot(id: string) {
|
||||
const database = await getDatabase();
|
||||
const talent = await (
|
||||
@@ -91,16 +116,6 @@ async function snapshot(id: string) {
|
||||
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
primaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
secondaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
talent: talent ?? null,
|
||||
talentEffects: talent
|
||||
? await (
|
||||
@@ -150,8 +165,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
desc: "COALESCE((SELECT talent.name FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC",
|
||||
},
|
||||
rollPools: {
|
||||
asc: '((SELECT COUNT(*) FROM skill_chip_primary_options primary_option WHERE primary_option.skill_chip_set_id=sets.id)+(SELECT COUNT(*) FROM skill_chip_secondary_options secondary_option WHERE secondary_option.skill_chip_set_id=sets.id)) ASC,sets.name COLLATE NOCASE ASC',
|
||||
desc: '((SELECT COUNT(*) FROM skill_chip_primary_options primary_option WHERE primary_option.skill_chip_set_id=sets.id)+(SELECT COUNT(*) FROM skill_chip_secondary_options secondary_option WHERE secondary_option.skill_chip_set_id=sets.id)) DESC,sets.name COLLATE NOCASE ASC',
|
||||
asc: 'sets.name COLLATE NOCASE ASC,sets.id ASC',
|
||||
desc: 'sets.name COLLATE NOCASE DESC,sets.id DESC',
|
||||
},
|
||||
state: {
|
||||
asc: 'sets.active ASC,sets.name COLLATE NOCASE ASC',
|
||||
@@ -173,7 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
: filter === 'configured'
|
||||
? [
|
||||
'EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)',
|
||||
'EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)',
|
||||
]
|
||||
: []),
|
||||
...(!requestedItemId && search
|
||||
@@ -198,7 +212,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
|
||||
SUM(CASE WHEN specialization_ability_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
|
||||
FROM skill_chip_sets`)
|
||||
).get(),
|
||||
(
|
||||
@@ -211,7 +225,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
module: 'attributes',
|
||||
action: 'save',
|
||||
kind: 'stat',
|
||||
fields: ['unit', 'chipMin', 'chipMax'],
|
||||
fields: ['unit'],
|
||||
},
|
||||
]),
|
||||
getPendingEntities(user.id, [
|
||||
@@ -224,8 +238,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'twoPieceStatId',
|
||||
'twoPieceMin',
|
||||
'twoPieceMax',
|
||||
'primaryStatIds',
|
||||
'secondaryStatIds',
|
||||
'hasTalent',
|
||||
'talentName',
|
||||
'descriptionTemplate',
|
||||
'conditionalRule',
|
||||
@@ -252,8 +265,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const [
|
||||
sets,
|
||||
twoPiece,
|
||||
primaryOptions,
|
||||
secondaryOptions,
|
||||
talents,
|
||||
talentEffects,
|
||||
stats,
|
||||
@@ -262,6 +273,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
abilityChipCounts,
|
||||
icons,
|
||||
renders,
|
||||
primaryProfiles,
|
||||
secondaryPool,
|
||||
primaryAttributeIds,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
@@ -271,10 +285,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_secondary_options')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
@@ -283,7 +293,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,unit,skill_chip_min,skill_chip_max FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
@@ -310,13 +320,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='skill_chip_render'",
|
||||
)
|
||||
).all(),
|
||||
getSkillChipPrimaryProfiles(database),
|
||||
getSkillChipSecondaryPool(database),
|
||||
getSkillChipPrimaryAttributeIds(database),
|
||||
]);
|
||||
const availableStats = [
|
||||
...pendingStats.map((item) => ({
|
||||
...item,
|
||||
unit: String(item.unit ?? ''),
|
||||
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
|
||||
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax),
|
||||
})),
|
||||
...stats,
|
||||
];
|
||||
@@ -347,9 +358,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const currentPendingSets = pendingSets.filter(
|
||||
(set) =>
|
||||
Boolean(set.abilityId) &&
|
||||
Array.isArray(set.primaryStatIds) &&
|
||||
Array.isArray(set.secondaryStatIds) &&
|
||||
Boolean(set.conditionalRule),
|
||||
(String(set.hasTalent) !== '1' || Boolean(set.conditionalRule)),
|
||||
);
|
||||
const pendingSetRows = requestedItemId
|
||||
? currentPendingSets.filter((set) => set.id === requestedItemId)
|
||||
@@ -374,19 +383,22 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
min_value: Number(set.twoPieceMin),
|
||||
max_value: Number(set.twoPieceMax),
|
||||
},
|
||||
primaryStatIds: set.primaryStatIds,
|
||||
secondaryStatIds: set.secondaryStatIds,
|
||||
talent: {
|
||||
name: set.talentName,
|
||||
description_template: set.descriptionTemplate,
|
||||
conditionalRule: (() => {
|
||||
try {
|
||||
return JSON.parse(String(set.conditionalRule ?? 'null'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
talent:
|
||||
String(set.hasTalent) === '1'
|
||||
? {
|
||||
name: set.talentName,
|
||||
description_template: set.descriptionTemplate,
|
||||
conditionalRule: (() => {
|
||||
try {
|
||||
return JSON.parse(
|
||||
String(set.conditionalRule ?? 'null'),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
const configuredSets = await Promise.all(
|
||||
sets.map(async (set) => {
|
||||
@@ -407,12 +419,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
})(),
|
||||
twoPiece:
|
||||
twoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
|
||||
primaryStatIds: primaryOptions
|
||||
.filter((item) => item.skill_chip_set_id === set.id)
|
||||
.map((item) => String(item.stat_attribute_id)),
|
||||
secondaryStatIds: secondaryOptions
|
||||
.filter((item) => item.skill_chip_set_id === set.id)
|
||||
.map((item) => String(item.stat_attribute_id)),
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
@@ -433,9 +439,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
return {
|
||||
sets: [...pendingSetRows, ...configuredSets],
|
||||
stats: availableStats,
|
||||
rollStats: availableStats.filter(
|
||||
(stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null,
|
||||
primaryProfiles: primaryAttributeIds.map(
|
||||
(statId) =>
|
||||
primaryProfiles.find((profile) => profile.statId === statId) ?? {
|
||||
statId,
|
||||
values: {},
|
||||
},
|
||||
),
|
||||
primaryProfileBase: primaryProfiles,
|
||||
secondaryPool,
|
||||
abilities: loadedAbilities,
|
||||
summary: {
|
||||
active: Number(summary?.active ?? 0),
|
||||
@@ -490,12 +502,7 @@ export const actions: Actions = {
|
||||
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
|
||||
const twoPieceMin = Number(form.get('twoPieceMin'));
|
||||
const twoPieceMax = Number(form.get('twoPieceMax'));
|
||||
const primaryStatIds = [
|
||||
...new Set(form.getAll('primaryStatIds').map(String).filter(Boolean)),
|
||||
];
|
||||
const secondaryStatIds = [
|
||||
...new Set(form.getAll('secondaryStatIds').map(String).filter(Boolean)),
|
||||
];
|
||||
const hasTalent = form.get('hasTalent') === '1';
|
||||
const talentName = String(form.get('talentName') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
@@ -503,25 +510,30 @@ export const actions: Actions = {
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const database = await getDatabase();
|
||||
let conditionalRule;
|
||||
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
|
||||
null;
|
||||
try {
|
||||
const conditionalRuleRaw = form.get('conditionalRule');
|
||||
const activeStats = await (
|
||||
await database.prepare('SELECT id FROM stat_attributes WHERE active=1')
|
||||
).all();
|
||||
conditionalRule = parseConditionalRulePayload(
|
||||
conditionalRuleRaw,
|
||||
[
|
||||
{ key: 'minimum', variantKey: 'minimum' },
|
||||
{ key: 'maximum', variantKey: 'maximum' },
|
||||
],
|
||||
{
|
||||
allowedAttributeIds: new Set(
|
||||
activeStats.map((row) => String(row.id)),
|
||||
),
|
||||
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
||||
},
|
||||
);
|
||||
if (hasTalent) {
|
||||
const conditionalRuleRaw = form.get('conditionalRule');
|
||||
const activeStats = await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM stat_attributes WHERE active=1',
|
||||
)
|
||||
).all();
|
||||
conditionalRule = parseConditionalRulePayload(
|
||||
conditionalRuleRaw,
|
||||
[
|
||||
{ key: 'minimum', variantKey: 'minimum' },
|
||||
{ key: 'maximum', variantKey: 'maximum' },
|
||||
],
|
||||
{
|
||||
allowedAttributeIds: new Set(
|
||||
activeStats.map((row) => String(row.id)),
|
||||
),
|
||||
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
@@ -547,38 +559,30 @@ export const actions: Actions = {
|
||||
return fail(400, {
|
||||
skillChipError: 'Configure a valid 2-piece stat range.',
|
||||
});
|
||||
if (!primaryStatIds.length)
|
||||
return fail(400, {
|
||||
skillChipError: 'Configure at least one possible primary stat.',
|
||||
});
|
||||
if (secondaryStatIds.length < 2)
|
||||
return fail(400, {
|
||||
skillChipError: 'Configure at least two possible secondary stats.',
|
||||
});
|
||||
if (primaryStatIds.some((statId) => secondaryStatIds.includes(statId)))
|
||||
return fail(400, {
|
||||
skillChipError: 'Primary and secondary stat pools must be distinct.',
|
||||
});
|
||||
const outcomes = mechanicOutcomes(conditionalRule);
|
||||
const numberedOutcomes = descriptionOutcomes(conditionalRule);
|
||||
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
|
||||
const numberedOutcomes = conditionalRule
|
||||
? descriptionOutcomes(conditionalRule)
|
||||
: [];
|
||||
const durationSeconds =
|
||||
conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)
|
||||
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
|
||||
?.expiresAfterSeconds ?? null;
|
||||
const maxStacks = conditionalRule.pools.length
|
||||
const maxStacks = conditionalRule?.pools.length
|
||||
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
|
||||
: null;
|
||||
if (
|
||||
talentName.length < 2 ||
|
||||
talentName.length > 80 ||
|
||||
!descriptionTemplate ||
|
||||
!conditionalRule.clauses.length ||
|
||||
!outcomes.length
|
||||
hasTalent &&
|
||||
(talentName.length < 2 ||
|
||||
talentName.length > 80 ||
|
||||
!descriptionTemplate ||
|
||||
!conditionalRule?.clauses.length ||
|
||||
!outcomes.length)
|
||||
)
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
'The 3-piece talent requires a name, description, mechanic branch, and outcome.',
|
||||
});
|
||||
if (
|
||||
hasTalent &&
|
||||
outcomes.some(
|
||||
(outcome) =>
|
||||
outcome.variants.maximum.value < outcome.variants.minimum.value,
|
||||
@@ -592,6 +596,7 @@ export const actions: Actions = {
|
||||
(match) => Number(match[1]),
|
||||
);
|
||||
if (
|
||||
hasTalent &&
|
||||
placeholders.some(
|
||||
(index) =>
|
||||
!numberedOutcomes.some(
|
||||
@@ -603,12 +608,16 @@ export const actions: Actions = {
|
||||
skillChipError:
|
||||
'Description placeholders must reference configured numbered outcomes.',
|
||||
});
|
||||
if (descriptionTemplate.includes('{duration}') && durationSeconds === null)
|
||||
if (
|
||||
hasTalent &&
|
||||
descriptionTemplate.includes('{duration}') &&
|
||||
durationSeconds === null
|
||||
)
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
'The description uses {duration}, but no expiring state pool is defined.',
|
||||
});
|
||||
if (descriptionTemplate.includes('{max_stacks}') && !maxStacks)
|
||||
if (hasTalent && descriptionTemplate.includes('{max_stacks}') && !maxStacks)
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
'The description uses {max_stacks}, but no state pool maximum is defined.',
|
||||
@@ -656,11 +665,21 @@ export const actions: Actions = {
|
||||
).get(id)
|
||||
: null;
|
||||
if (
|
||||
await (
|
||||
existingTalent &&
|
||||
!hasTalent &&
|
||||
form.get('confirmTalentRemoval') !== 'REMOVE'
|
||||
)
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
'Confirm removal of the existing 3-piece Talent and mechanic program.',
|
||||
});
|
||||
if (
|
||||
hasTalent &&
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?',
|
||||
)
|
||||
).get(talentName, existingTalent?.id ?? 'new')
|
||||
).get(talentName, existingTalent?.id ?? 'new'))
|
||||
)
|
||||
return fail(409, {
|
||||
skillChipError: 'That Skill Chip talent name is already in use.',
|
||||
@@ -668,14 +687,7 @@ export const actions: Actions = {
|
||||
const outcomeStatIds = outcomes
|
||||
.map((effect) => effect.attributeId)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
const allStatIds = [
|
||||
...new Set([
|
||||
twoPieceStatId,
|
||||
...primaryStatIds,
|
||||
...secondaryStatIds,
|
||||
...outcomeStatIds,
|
||||
]),
|
||||
];
|
||||
const allStatIds = [...new Set([twoPieceStatId, ...outcomeStatIds])];
|
||||
const validStats = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`,
|
||||
@@ -685,17 +697,6 @@ export const actions: Actions = {
|
||||
return fail(400, {
|
||||
skillChipError: 'One or more selected stats are unavailable.',
|
||||
});
|
||||
const rollStatIds = [...primaryStatIds, ...secondaryStatIds];
|
||||
const validRollStats = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND id IN (${rollStatIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...rollStatIds);
|
||||
if (Number(validRollStats?.count) !== rollStatIds.length)
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
'Every primary and secondary option requires a configured Skill Chip range.',
|
||||
});
|
||||
const setId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const talentId = String(existingTalent?.id ?? randomUUID());
|
||||
@@ -724,61 +725,50 @@ export const actions: Actions = {
|
||||
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)
|
||||
).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM skill_chip_primary_options WHERE skill_chip_set_id=?',
|
||||
)
|
||||
).run(setId);
|
||||
for (const statId of primaryStatIds)
|
||||
if (hasTalent && conditionalRule) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO skill_chip_primary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)',
|
||||
)
|
||||
).run(setId, statId);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM skill_chip_secondary_options WHERE skill_chip_set_id=?',
|
||||
)
|
||||
).run(setId);
|
||||
for (const statId of secondaryStatIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO skill_chip_secondary_options(skill_chip_set_id,stat_attribute_id) VALUES(?,?)',
|
||||
)
|
||||
).run(setId, statId);
|
||||
await (
|
||||
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
||||
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
||||
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
|
||||
).run(talentId, setId, talentName, descriptionTemplate, now, now);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
|
||||
)
|
||||
).run(talentId);
|
||||
let compatibilitySortOrder = 0;
|
||||
for (const outcome of numberedOutcomes) {
|
||||
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
|
||||
continue;
|
||||
compatibilitySortOrder += 1;
|
||||
).run(talentId, setId, talentName, descriptionTemplate, now, now);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
|
||||
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
).run(talentId);
|
||||
let compatibilitySortOrder = 0;
|
||||
for (const outcome of numberedOutcomes) {
|
||||
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
|
||||
continue;
|
||||
compatibilitySortOrder += 1;
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
talentId,
|
||||
outcome.attributeId,
|
||||
outcome.variants.minimum.value,
|
||||
outcome.variants.maximum.value,
|
||||
compatibilitySortOrder,
|
||||
);
|
||||
}
|
||||
await saveConditionalRuleInTransaction(
|
||||
database,
|
||||
'skill_chip_talent',
|
||||
talentId,
|
||||
outcome.attributeId,
|
||||
outcome.variants.minimum.value,
|
||||
outcome.variants.maximum.value,
|
||||
compatibilitySortOrder,
|
||||
conditionalRule,
|
||||
);
|
||||
} else if (existingTalent) {
|
||||
await (
|
||||
await database.prepare(
|
||||
"DELETE FROM conditional_effect_rules WHERE source_type='skill_chip_talent' AND source_id=?",
|
||||
)
|
||||
).run(String(existingTalent.id));
|
||||
await (
|
||||
await database.prepare('DELETE FROM skill_chip_talents WHERE id=?')
|
||||
).run(String(existingTalent.id));
|
||||
}
|
||||
await saveConditionalRuleInTransaction(
|
||||
database,
|
||||
'skill_chip_talent',
|
||||
talentId,
|
||||
conditionalRule,
|
||||
);
|
||||
await applyIconMutation(
|
||||
database,
|
||||
'skill_chip',
|
||||
@@ -804,6 +794,7 @@ export const actions: Actions = {
|
||||
entityId: setId,
|
||||
before,
|
||||
after: await snapshot(setId),
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
@@ -816,33 +807,163 @@ export const actions: Actions = {
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
savePrimaryProfile: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const database = await getDatabase();
|
||||
const form = await request.formData();
|
||||
const expectedStatIds = await getSkillChipPrimaryAttributeIds(database);
|
||||
const normalized = normalizeSkillChipProfiles(
|
||||
(() => {
|
||||
try {
|
||||
return JSON.parse(String(form.get('profiles') ?? ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
SKILL_CHIP_QUALITIES,
|
||||
{ expectedStatIds, minimumOptions: 4 },
|
||||
);
|
||||
if (normalized.error)
|
||||
return fail(400, { skillChipPrimaryProfileError: normalized.error });
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const before = await getSkillChipPrimaryProfiles(database);
|
||||
if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) {
|
||||
await database.exec('ROLLBACK');
|
||||
if (JSON.stringify(before) === JSON.stringify(normalized.profiles))
|
||||
return {
|
||||
skillChipPrimaryProfileSaved: true,
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
return fail(409, {
|
||||
skillChipPrimaryProfileError:
|
||||
'The shared primary profile changed after this editor opened. Review the latest values and try again.',
|
||||
});
|
||||
}
|
||||
await replaceSkillChipPrimaryProfiles(database, normalized.profiles);
|
||||
await refreshAllFocusReadiness(database);
|
||||
const after = await getSkillChipPrimaryProfiles(database);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'skill_chip_primary_profile.updated',
|
||||
entityId: 'skill-chip-primary-profile',
|
||||
before,
|
||||
after,
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { skillChipPrimaryProfileSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
saveSecondaryPool: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const database = await getDatabase();
|
||||
const form = await request.formData();
|
||||
const normalized = normalizeSkillChipProfiles(
|
||||
(() => {
|
||||
try {
|
||||
return JSON.parse(String(form.get('profiles') ?? ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
SKILL_CHIP_SECONDARY_QUALITIES,
|
||||
{ minimumOptions: 2 },
|
||||
);
|
||||
if (normalized.error)
|
||||
return fail(400, { skillChipSecondaryPoolError: normalized.error });
|
||||
const statIds = normalized.profiles.map((profile) => profile.statId);
|
||||
const validStats = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...statIds);
|
||||
if (Number(validStats?.count) !== statIds.length)
|
||||
return fail(400, {
|
||||
skillChipSecondaryPoolError:
|
||||
'One or more selected Attributes are unavailable.',
|
||||
});
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const before = await getSkillChipSecondaryPool(database);
|
||||
if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) {
|
||||
await database.exec('ROLLBACK');
|
||||
if (JSON.stringify(before) === JSON.stringify(normalized.profiles))
|
||||
return {
|
||||
skillChipSecondaryPoolSaved: true,
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
return fail(409, {
|
||||
skillChipSecondaryPoolError:
|
||||
'The shared secondary pool changed after this editor opened. Review the latest values and try again.',
|
||||
});
|
||||
}
|
||||
await replaceSkillChipSecondaryPool(database, normalized.profiles);
|
||||
await refreshAllFocusReadiness(database);
|
||||
const after = await getSkillChipSecondaryPool(database);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'skill_chip_secondary_pool.updated',
|
||||
entityId: 'skill-chip-secondary-pool',
|
||||
before,
|
||||
after,
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { skillChipSecondaryPoolSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await snapshot(id);
|
||||
if (!before.record)
|
||||
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||||
const database = await getDatabase();
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(new Date().toISOString(), id);
|
||||
await refreshAbilityFocuses(
|
||||
database,
|
||||
String(before.record.specialization_ability_id),
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'skill_chip.archived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await snapshot(id),
|
||||
});
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const before = await snapshot(id);
|
||||
if (!before.record) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(new Date().toISOString(), id);
|
||||
await refreshAbilityFocuses(
|
||||
database,
|
||||
String(before.record.specialization_ability_id),
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'skill_chip.archived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await snapshot(id),
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { skillChipArchived: true };
|
||||
},
|
||||
unarchive: async ({ request, cookies }) => {
|
||||
@@ -851,50 +972,63 @@ export const actions: Actions = {
|
||||
'data.manage',
|
||||
);
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await snapshot(id);
|
||||
if (!before.record)
|
||||
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||||
const abilityId = String(before.record.specialization_ability_id);
|
||||
const database = await getDatabase();
|
||||
if (
|
||||
!(await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
|
||||
)
|
||||
).get(abilityId))
|
||||
) {
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
'The assigned specialization ability must be active before restoring this Skill Chip.',
|
||||
});
|
||||
}
|
||||
const assignedCount = Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const before = await snapshot(id);
|
||||
if (!before.record) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
||||
}
|
||||
const abilityId = String(before.record.specialization_ability_id);
|
||||
if (
|
||||
!(await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
|
||||
)
|
||||
).get(abilityId))
|
||||
) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(400, {
|
||||
skillChipError:
|
||||
'The assigned specialization ability must be active before restoring this Skill Chip.',
|
||||
});
|
||||
}
|
||||
const assignedCount = Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
|
||||
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
|
||||
).get(abilityId, id)
|
||||
)?.count ?? 0,
|
||||
);
|
||||
if (assignedCount >= 3)
|
||||
return fail(409, {
|
||||
skillChipError:
|
||||
'That ability already has all three active Skill Chips assigned.',
|
||||
).get(abilityId, id)
|
||||
)?.count ?? 0,
|
||||
);
|
||||
if (assignedCount >= 3) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(409, {
|
||||
skillChipError:
|
||||
'That ability already has all three active Skill Chips assigned.',
|
||||
});
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(new Date().toISOString(), id);
|
||||
await refreshAbilityFocuses(database, abilityId);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'skill_chip.unarchived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await snapshot(id),
|
||||
database,
|
||||
});
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(new Date().toISOString(), id);
|
||||
await refreshAbilityFocuses(database, abilityId);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'skill_chip.unarchived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await snapshot(id),
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { skillChipUnarchived: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
|
||||
import SkillChipSharedProfiles from '$lib/components/SkillChipSharedProfiles.svelte';
|
||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -22,7 +23,7 @@
|
||||
clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}]
|
||||
});
|
||||
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
||||
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
|
||||
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, hasTalent: false, talentOpen: false, talent: blankTalent() });
|
||||
let editing = $state<any | null>(null);
|
||||
let highEndCount = $state('0');
|
||||
let handledResultToken = $state('');
|
||||
@@ -65,9 +66,9 @@
|
||||
...item,
|
||||
abilityId: String(item.specialization_ability_id),
|
||||
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
|
||||
primaryStatIds: [...item.primaryStatIds],
|
||||
secondaryStatIds: [...item.secondaryStatIds],
|
||||
talent: item.talent ? { ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() } : blankTalent()
|
||||
hasTalent: Boolean(item.talent),
|
||||
talentOpen: false,
|
||||
talent: item.talent ? structuredClone({ ...item.talent, conditionalRule: item.talent.conditionalRule ?? blankRule() }) : blankTalent()
|
||||
};
|
||||
highEndCount = '0';
|
||||
};
|
||||
@@ -86,17 +87,30 @@
|
||||
editing = form.skillChipMode === 'add' ? blank() : null;
|
||||
highEndCount = '0';
|
||||
}
|
||||
if (form?.skillChipError && editing?.hasTalent) editing.talentOpen = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Skill Chips — SHD.build admin</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">SKILL MODIFICATION SYNERGY REGISTRY</p><h2>SKILL CHIPS</h2><p>Configure Superior and High-end chip rolls, 2-piece bonuses, and quality-scaled 3-piece talents.</p></div><button class="primary" onclick={() => editing = blank()}>ADD SKILL CHIP <span>+</span></button></div>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">SKILL MODIFICATION SYNERGY REGISTRY</p><h2>SKILL CHIPS</h2><p>Configure shared quality rolls, ability-linked chip sets, 2-piece bonuses, and optional 3-piece talents.</p></div><button class="primary" onclick={() => editing = blank()}>ADD SKILL CHIP <span>+</span></button></div>
|
||||
|
||||
<SkillChipSharedProfiles
|
||||
primaryProfiles={data.primaryProfiles}
|
||||
primaryProfileBase={data.primaryProfileBase}
|
||||
secondaryPool={data.secondaryPool}
|
||||
stats={data.stats}
|
||||
primaryError={form?.skillChipPrimaryProfileError}
|
||||
secondaryError={form?.skillChipSecondaryPoolError}
|
||||
primarySaved={form?.skillChipPrimaryProfileSaved}
|
||||
secondarySaved={form?.skillChipSecondaryPoolSaved}
|
||||
resultToken={form?.resultToken}
|
||||
/>
|
||||
|
||||
<KpiFilters active={data.filter} items={[
|
||||
{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},
|
||||
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},
|
||||
{value:'assigned',label:'ABILITY LINKED',count:data.summary.assigned,description:'Assigned to a class ability'},
|
||||
{value:'configured',label:'CONFIGURED',count:data.summary.configured,description:'Complete 2-piece and 3-piece effects'},
|
||||
{value:'configured',label:'CONFIGURED',count:data.summary.configured,description:'Complete 2-piece bonus data'},
|
||||
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
|
||||
]}/>
|
||||
|
||||
@@ -106,8 +120,9 @@
|
||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}</p><h3>{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="hasTalent" value={editing.hasTalent ? '1' : '0'}/>
|
||||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
|
||||
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
|
||||
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b>STANDARD</b><b>SPECIALIZED</b><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Shared profiles define primary and secondary ranges.</small></div></div>
|
||||
<section class="skill-chip-ability-assignment">
|
||||
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
|
||||
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
|
||||
@@ -118,15 +133,14 @@
|
||||
<div class="skill-chip-bonus-grid"><div><SearchableAssignment items={data.stats} fieldName="twoPieceStatId" mode="single" subjectLabel="BONUS ATTRIBUTE" searchPlaceholder="Search attributes…" emptySelectionLabel="NO BONUS ATTRIBUTE SELECTED" bind:value={editing.twoPiece.stat_attribute_id}/></div><div class="skill-chip-range-fields"><label class="field">MINIMUM VALUE<input name="twoPieceMin" type="number" step="any" bind:value={editing.twoPiece.min_value} required/></label><label class="field">MAXIMUM VALUE<input name="twoPieceMax" type="number" step="any" bind:value={editing.twoPiece.max_value} required/></label></div></div>
|
||||
</section>
|
||||
|
||||
<div class="skill-chip-pools">
|
||||
<div><div class="selection-head"><div><span>PRIMARY STAT POOL</span><small>ONE RANDOM STAT · USES ATTRIBUTE SKILL CHIP RANGE</small></div><b>{editing.primaryStatIds.length}</b></div><SearchableAssignment items={data.rollStats} fieldName="primaryStatIds" subjectLabel="PRIMARY STATS" searchPlaceholder="Search Skill Chip stats…" excludedIds={editing.secondaryStatIds} bind:selected={editing.primaryStatIds}/></div>
|
||||
<div><div class="selection-head"><div><span>SECONDARY STAT POOL</span><small>TWO DISTINCT RANDOM STATS · USE ATTRIBUTE RANGES</small></div><b>{editing.secondaryStatIds.length}</b></div><SearchableAssignment items={data.rollStats} fieldName="secondaryStatIds" subjectLabel="SECONDARY STATS" searchPlaceholder="Search Skill Chip stats…" excludedIds={editing.primaryStatIds} bind:selected={editing.secondaryStatIds}/></div>
|
||||
</div>
|
||||
|
||||
<section class="skill-chip-talent">
|
||||
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>QUALITY-SCALED OUTCOMES · TYPED TRIGGER AND DESCRIPTION</small></div><b>{previewOutcomes().length} OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}</b></div>
|
||||
<div class="selection-head"><div><span>3-PIECE TALENT</span><small>OPTIONAL SET BONUS · QUALITY-SCALED WHEN ASSIGNED</small></div><SegmentedChoice legend="TALENT ASSIGNMENT" name="talent-assignment" options={[{value:'0',label:'NOT ASSIGNED'},{value:'1',label:'ASSIGNED'}]} value={editing.hasTalent ? '1' : '0'} onchange={(values) => { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/></div>
|
||||
{#if editing.hasTalent}
|
||||
<details class="modeling-disclosure skill-chip-talent-disclosure" bind:open={editing.talentOpen}>
|
||||
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>{previewOutcomes().length} QUALITY-SCALED OUTCOME{previewOutcomes().length === 1 ? '' : 'S'}</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
|
||||
<div class="modeling-content">
|
||||
<div class="skill-chip-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} Skill Damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for scaled effect values and units.</small></label></div>
|
||||
<div class="skill-chip-preview"><SegmentedChoice legend="QUALITY MIX PREVIEW" name="qualityPreview" options={qualitySteps} bind:value={highEndCount}/><div><span>{3 - Number(highEndCount)} SUPERIOR · {highEndCount} HIGH-END</span><p>{talentPreview() || 'Talent description preview'}</p></div></div>
|
||||
<div class="skill-chip-preview"><SegmentedChoice legend="QUALITY MIX PREVIEW" name="qualityPreview" options={qualitySteps} bind:value={highEndCount}/><div><span>{3 - Number(highEndCount)} NON-HIGH-END · {highEndCount} HIGH-END</span><p>{talentPreview() || 'Talent description preview'}</p></div></div>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.stats}
|
||||
allowAssignedAbility
|
||||
@@ -139,9 +153,12 @@
|
||||
}}
|
||||
bind:rule={editing.talent.conditionalRule}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
{:else}<p class="console-note">This set has no 3-piece Talent. Its 2-piece bonus remains active when the requirement is met.</p>{#if editing.talent.id}<label class="skill-chip-talent-removal"><input type="checkbox" name="confirmTalentRemoval" value="REMOVE" required/> CONFIRM REMOVAL OF THE EXISTING TALENT AND MECHANIC PROGRAM</label>{/if}{/if}
|
||||
</section>
|
||||
{#if form?.skillChipError}<p class="error">{form.skillChipError}</p>{/if}
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId || !editing.primaryStatIds.length || editing.secondaryStatIds.length < 2 || !data.rollStats.length}>SAVE SKILL CHIP →</button></div></div>
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE CHIP SET' : 'UNARCHIVE CHIP SET'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!editing.abilityId}>SAVE SKILL CHIP →</button></div></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -150,9 +167,9 @@
|
||||
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="ROLL POOLS" column="rollPools" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><span>ROLL PROFILE</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.sets as item}
|
||||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED'}</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
|
||||
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
|
||||
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first ability-linked Skill Chip definition.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const database = {
|
||||
exec: vi.fn(async () => undefined),
|
||||
prepare: vi.fn(),
|
||||
};
|
||||
return {
|
||||
database,
|
||||
getDatabase: vi.fn(async () => database),
|
||||
getPrimaryAttributeIds: vi.fn(),
|
||||
getPrimaryProfiles: vi.fn(),
|
||||
getSecondaryPool: vi.fn(),
|
||||
recordIsacAudit: vi.fn(),
|
||||
refreshFocus: vi.fn(),
|
||||
replacePrimaryProfiles: vi.fn(),
|
||||
replaceSecondaryPool: vi.fn(),
|
||||
requirePermission: vi.fn(async (user) => user),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
requireDataView: vi.fn(async (user) => user),
|
||||
requirePermission: mocks.requirePermission,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: mocks.getDatabase,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
auditRecord: vi.fn(),
|
||||
recordIsacAudit: mocks.recordIsacAudit,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/data-moderation', () => ({
|
||||
getPendingEntities: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/skill-chip-profiles', () => ({
|
||||
getSkillChipPrimaryAttributeIds: mocks.getPrimaryAttributeIds,
|
||||
getSkillChipPrimaryProfiles: mocks.getPrimaryProfiles,
|
||||
getSkillChipSecondaryPool: mocks.getSecondaryPool,
|
||||
replaceSkillChipPrimaryProfiles: mocks.replacePrimaryProfiles,
|
||||
replaceSkillChipSecondaryPool: mocks.replaceSecondaryPool,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/specialization-readiness', () => ({
|
||||
refreshAbilityFocuses: vi.fn(),
|
||||
refreshSpecializationFocusReady: mocks.refreshFocus,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/managed-images', () => ({
|
||||
applyGameDataRender: vi.fn(),
|
||||
ManagedImageValidationError: class extends Error {},
|
||||
managedImageUrl: vi.fn(() => ''),
|
||||
processManagedImage: vi.fn(),
|
||||
readGameDataRenderMutation: vi.fn(),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const primaryIds = [
|
||||
'skill-duration',
|
||||
'skill-radius',
|
||||
'skill-cooldown-recovery',
|
||||
'skill-intensity',
|
||||
];
|
||||
const primaryQualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
|
||||
const secondaryQualities = ['Specialized', 'Superior', 'High-end'];
|
||||
|
||||
const profiles = (ids: string[], qualities: string[], offset = 0) =>
|
||||
ids.map((statId, profileIndex) => ({
|
||||
statId,
|
||||
values: Object.fromEntries(
|
||||
qualities.map((quality, qualityIndex) => [
|
||||
quality,
|
||||
{
|
||||
min: offset + profileIndex * 10 + qualityIndex + 1,
|
||||
max: offset + profileIndex * 10 + qualityIndex + 2,
|
||||
},
|
||||
]),
|
||||
),
|
||||
}));
|
||||
|
||||
const primaryAfter = profiles(primaryIds, primaryQualities, 10);
|
||||
const secondaryIds = ['critical-hit-chance', 'critical-hit-damage'];
|
||||
const secondaryAfter = profiles(secondaryIds, secondaryQualities, 20);
|
||||
|
||||
const actionRequest = (action: string, payload: unknown, base: unknown) => {
|
||||
const form = new FormData();
|
||||
form.set('profiles', JSON.stringify(payload));
|
||||
form.set('baseProfiles', JSON.stringify(base));
|
||||
return new Request(`http://localhost/isac/skillchips?/${action}`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
const cases = [
|
||||
{
|
||||
actionName: 'savePrimaryProfile' as const,
|
||||
payload: primaryAfter,
|
||||
getProfiles: mocks.getPrimaryProfiles,
|
||||
replaceProfiles: mocks.replacePrimaryProfiles,
|
||||
before: profiles(primaryIds, primaryQualities),
|
||||
after: profiles(primaryIds, primaryQualities, 30),
|
||||
auditAction: 'skill_chip_primary_profile.updated',
|
||||
entityId: 'skill-chip-primary-profile',
|
||||
savedKey: 'skillChipPrimaryProfileSaved',
|
||||
},
|
||||
{
|
||||
actionName: 'saveSecondaryPool' as const,
|
||||
payload: secondaryAfter,
|
||||
getProfiles: mocks.getSecondaryPool,
|
||||
replaceProfiles: mocks.replaceSecondaryPool,
|
||||
before: profiles(secondaryIds, secondaryQualities),
|
||||
after: profiles(secondaryIds, secondaryQualities, 40),
|
||||
auditAction: 'skill_chip_secondary_pool.updated',
|
||||
entityId: 'skill-chip-secondary-pool',
|
||||
savedKey: 'skillChipSecondaryPoolSaved',
|
||||
},
|
||||
];
|
||||
|
||||
describe('Skill Chip shared profile actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getPrimaryAttributeIds.mockResolvedValue(primaryIds);
|
||||
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||
all: vi.fn(async () =>
|
||||
sql.includes('specialization_focuses') ? [{ id: 'focus-1' }] : [],
|
||||
),
|
||||
get: vi.fn(async (...statIds: unknown[]) => ({ count: statIds.length })),
|
||||
run: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
for (const testCase of cases) {
|
||||
it(`${testCase.actionName} audits complete evidence and commits its transaction`, async () => {
|
||||
testCase.getProfiles
|
||||
.mockResolvedValueOnce(testCase.before)
|
||||
.mockResolvedValueOnce(testCase.after);
|
||||
|
||||
const result = await actions[testCase.actionName]?.({
|
||||
cookies: {} as never,
|
||||
request: actionRequest(
|
||||
testCase.actionName,
|
||||
testCase.payload,
|
||||
testCase.before,
|
||||
),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
[testCase.savedKey]: true,
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||
{ id: 'operator-1' },
|
||||
'data.manage',
|
||||
);
|
||||
expect(testCase.getProfiles).toHaveBeenNthCalledWith(1, mocks.database);
|
||||
expect(testCase.replaceProfiles).toHaveBeenCalledWith(
|
||||
mocks.database,
|
||||
testCase.payload,
|
||||
);
|
||||
expect(testCase.getProfiles).toHaveBeenNthCalledWith(2, mocks.database);
|
||||
expect(mocks.refreshFocus).toHaveBeenCalledWith(
|
||||
mocks.database,
|
||||
'focus-1',
|
||||
);
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: testCase.auditAction,
|
||||
entityId: testCase.entityId,
|
||||
before: testCase.before,
|
||||
after: testCase.after,
|
||||
database: mocks.database,
|
||||
});
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['COMMIT'],
|
||||
]);
|
||||
});
|
||||
|
||||
it(`${testCase.actionName} rolls back and does not commit when audit fails`, async () => {
|
||||
const auditError = new Error('audit insert failed');
|
||||
testCase.getProfiles
|
||||
.mockResolvedValueOnce(testCase.before)
|
||||
.mockResolvedValueOnce(testCase.after);
|
||||
mocks.recordIsacAudit.mockRejectedValueOnce(auditError);
|
||||
|
||||
await expect(
|
||||
actions[testCase.actionName]?.({
|
||||
cookies: {} as never,
|
||||
request: actionRequest(
|
||||
testCase.actionName,
|
||||
testCase.payload,
|
||||
testCase.before,
|
||||
),
|
||||
} as never),
|
||||
).rejects.toThrow(auditError);
|
||||
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['ROLLBACK'],
|
||||
]);
|
||||
expect(mocks.database.exec).not.toHaveBeenCalledWith('COMMIT');
|
||||
});
|
||||
|
||||
it(`${testCase.actionName} rejects stale baseProfiles without replacing or auditing`, async () => {
|
||||
testCase.getProfiles.mockResolvedValueOnce(testCase.before);
|
||||
|
||||
const result = await actions[testCase.actionName]?.({
|
||||
cookies: {} as never,
|
||||
request: actionRequest(
|
||||
testCase.actionName,
|
||||
testCase.payload,
|
||||
profiles(['stale-profile'], primaryQualities),
|
||||
),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 409 });
|
||||
expect(testCase.replaceProfiles).not.toHaveBeenCalled();
|
||||
expect(mocks.refreshFocus).not.toHaveBeenCalled();
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['ROLLBACK'],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
it('does not open a database when data.manage permission is denied', async () => {
|
||||
const permissionError = new Error('Forbidden');
|
||||
mocks.requirePermission.mockRejectedValueOnce(permissionError);
|
||||
|
||||
await expect(
|
||||
actions.savePrimaryProfile?.({
|
||||
cookies: {} as never,
|
||||
request: actionRequest('savePrimaryProfile', primaryAfter, []),
|
||||
} as never),
|
||||
).rejects.toThrow(permissionError);
|
||||
|
||||
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||
{ id: 'operator-1' },
|
||||
'data.manage',
|
||||
);
|
||||
expect(mocks.getDatabase).not.toHaveBeenCalled();
|
||||
expect(mocks.database.exec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const events: string[] = [];
|
||||
return {
|
||||
events,
|
||||
talentExists: true,
|
||||
auditRecord: vi.fn(),
|
||||
conditionalRuleSnapshot: vi.fn(),
|
||||
gameDataIconMetadata: vi.fn(async () => null),
|
||||
recordIsacAudit: vi.fn(async () => {
|
||||
events.push('audit');
|
||||
}),
|
||||
database: {
|
||||
exec: vi.fn(async (sql: string) => {
|
||||
events.push(sql);
|
||||
}),
|
||||
prepare: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
requireDataView: vi.fn(async (user) => user),
|
||||
requirePermission: vi.fn(async (user) => user),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
auditRecord: mocks.auditRecord,
|
||||
recordIsacAudit: mocks.recordIsacAudit,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/data-moderation', () => ({
|
||||
getPendingEntities: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/game-data-icons', () => ({
|
||||
applyIconMutation: vi.fn(),
|
||||
gameDataIconMetadata: mocks.gameDataIconMetadata,
|
||||
gameDataIconUrl: vi.fn(() => ''),
|
||||
GameDataIconValidationError: class extends Error {},
|
||||
readIconMutation: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/managed-images', () => ({
|
||||
applyGameDataRender: vi.fn(),
|
||||
ManagedImageValidationError: class extends Error {},
|
||||
managedImageUrl: vi.fn(() => ''),
|
||||
processManagedImage: vi.fn(),
|
||||
readGameDataRenderMutation: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/specialization-readiness', () => ({
|
||||
refreshAbilityFocuses: vi.fn(),
|
||||
refreshSpecializationFocusReady: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/conditional-effects', () => ({
|
||||
conditionalRuleSnapshot: mocks.conditionalRuleSnapshot,
|
||||
loadConditionalRuleDraft: vi.fn(),
|
||||
parseConditionalRulePayload: vi.fn(),
|
||||
saveConditionalRuleInTransaction: vi.fn(),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const beforeRecord = {
|
||||
id: 'set-1',
|
||||
name: 'Pulse Matrix',
|
||||
specialization_ability_id: 'ability-1',
|
||||
active: 1,
|
||||
};
|
||||
const afterRecord = { ...beforeRecord, updated_at: '2026-08-02T12:00:00.000Z' };
|
||||
const existingTalent = {
|
||||
id: 'talent-1',
|
||||
skill_chip_set_id: 'set-1',
|
||||
name: 'Signal Cascade',
|
||||
description_template: 'Existing Talent',
|
||||
};
|
||||
const twoPiece = {
|
||||
skill_chip_set_id: 'set-1',
|
||||
stat_attribute_id: 'skill-duration',
|
||||
min_value: 5,
|
||||
max_value: 10,
|
||||
};
|
||||
const talentEffects = [
|
||||
{
|
||||
id: 'effect-1',
|
||||
talent_id: 'talent-1',
|
||||
stat_attribute_id: 'skill-intensity',
|
||||
min_value: 1,
|
||||
max_value: 2,
|
||||
sort_order: 1,
|
||||
},
|
||||
];
|
||||
const conditional = { id: 'rule-1', source_id: 'talent-1' };
|
||||
|
||||
const saveRequest = (confirmRemoval = false) => {
|
||||
const form = new FormData();
|
||||
form.set('id', 'set-1');
|
||||
form.set('name', 'Pulse Matrix');
|
||||
form.set('abilityId', 'ability-1');
|
||||
form.set('twoPieceStatId', 'skill-duration');
|
||||
form.set('twoPieceMin', '5');
|
||||
form.set('twoPieceMax', '10');
|
||||
form.set('hasTalent', '0');
|
||||
if (confirmRemoval) form.set('confirmTalentRemoval', 'REMOVE');
|
||||
return new Request('http://localhost/isac/skillchips?/save', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
const runSave = (confirmRemoval = false) =>
|
||||
actions.save?.({
|
||||
cookies: {} as never,
|
||||
request: saveRequest(confirmRemoval),
|
||||
} as never);
|
||||
|
||||
describe('Skill Chip Talent removal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.events.length = 0;
|
||||
mocks.talentExists = true;
|
||||
mocks.auditRecord
|
||||
.mockResolvedValueOnce(beforeRecord)
|
||||
.mockResolvedValueOnce(afterRecord);
|
||||
mocks.conditionalRuleSnapshot.mockResolvedValue(conditional);
|
||||
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||
get: vi.fn(async (...args: unknown[]) => {
|
||||
if (sql.includes('FROM specialization_abilities'))
|
||||
return { id: 'ability-1' };
|
||||
if (sql.includes('COUNT(*) AS count FROM skill_chip_sets'))
|
||||
return { count: 2 };
|
||||
if (sql.includes('FROM skill_chip_sets WHERE name=')) return null;
|
||||
if (sql.includes('FROM skill_chip_talents WHERE skill_chip_set_id='))
|
||||
return mocks.talentExists ? existingTalent : null;
|
||||
if (sql.includes('COUNT(*) AS count FROM stat_attributes'))
|
||||
return { count: args.length };
|
||||
if (sql.includes('FROM managed_images')) return null;
|
||||
if (sql.includes('FROM skill_chip_two_piece_bonuses')) return twoPiece;
|
||||
return null;
|
||||
}),
|
||||
all: vi.fn(async () => {
|
||||
if (sql.includes('FROM skill_chip_talent_effects'))
|
||||
return talentEffects;
|
||||
return [];
|
||||
}),
|
||||
run: vi.fn(async () => {
|
||||
if (sql.includes('DELETE FROM conditional_effect_rules'))
|
||||
mocks.events.push('delete conditional rule');
|
||||
if (sql.includes('DELETE FROM skill_chip_talents')) {
|
||||
mocks.events.push('delete talent');
|
||||
mocks.talentExists = false;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('requires explicit confirmation before removing an existing Talent', async () => {
|
||||
const result = await runSave();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 400,
|
||||
data: {
|
||||
skillChipError:
|
||||
'Confirm removal of the existing 3-piece Talent and mechanic program.',
|
||||
},
|
||||
});
|
||||
expect(mocks.database.exec).not.toHaveBeenCalled();
|
||||
expect(mocks.events).not.toContain('delete conditional rule');
|
||||
expect(mocks.events).not.toContain('delete talent');
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the Talent and mechanic in one audited transaction after confirmation', async () => {
|
||||
const result = await runSave(true);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
skillChipSaved: true,
|
||||
skillChipMode: 'edit',
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.events).toEqual([
|
||||
'BEGIN',
|
||||
'delete conditional rule',
|
||||
'delete talent',
|
||||
'audit',
|
||||
'COMMIT',
|
||||
]);
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: 'skill_chip.updated',
|
||||
entityId: 'set-1',
|
||||
before: {
|
||||
record: beforeRecord,
|
||||
icon: null,
|
||||
render: null,
|
||||
twoPiece,
|
||||
talent: existingTalent,
|
||||
talentEffects,
|
||||
conditional,
|
||||
},
|
||||
after: {
|
||||
record: afterRecord,
|
||||
icon: null,
|
||||
render: null,
|
||||
twoPiece,
|
||||
talent: null,
|
||||
talentEffects: [],
|
||||
conditional: null,
|
||||
},
|
||||
database: mocks.database,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -211,7 +211,7 @@
|
||||
nodeEditing = null;
|
||||
};
|
||||
const editSpecialization = (item: any) => {
|
||||
specializationEditing = { id: item.id, name: item.name, description: item.description ?? '', active: Number(item.active), iconUrl: item.iconUrl ?? '' };
|
||||
specializationEditing = { id: item.id, name: item.name, description: item.description ?? '', active: Number(item.active), iconUrl: item.iconUrl ?? '', renderUrl: item.renderUrl ?? '' };
|
||||
focusEditing = abilityEditing = nodeEditing = null;
|
||||
};
|
||||
const editFocus = (item: any) => {
|
||||
@@ -249,7 +249,8 @@
|
||||
} : blankAbility(item.specialization_id).attack,
|
||||
effects: (item.effects ?? []).map(normalizedEffect),
|
||||
active: Number(item.active),
|
||||
iconUrl: item.iconUrl ?? ''
|
||||
iconUrl: item.iconUrl ?? '',
|
||||
renderUrl: item.renderUrl ?? ''
|
||||
};
|
||||
specializationEditing = focusEditing = nodeEditing = null;
|
||||
combatEffectEditing = null;
|
||||
@@ -284,7 +285,8 @@
|
||||
costs: { ...(rank.costs ?? {}) }
|
||||
})),
|
||||
active: Number(item.active),
|
||||
iconUrl: item.iconUrl ?? ''
|
||||
iconUrl: item.iconUrl ?? '',
|
||||
renderUrl: item.renderUrl ?? ''
|
||||
};
|
||||
specializationEditing = focusEditing = abilityEditing = null;
|
||||
};
|
||||
|
||||
@@ -21,18 +21,29 @@ import {
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import {
|
||||
MOD_QUALITIES,
|
||||
normalizeModSecondaryPool,
|
||||
} from '$lib/mods/secondary-pool';
|
||||
import {
|
||||
getModSecondaryPool,
|
||||
replaceModSecondaryPool,
|
||||
} from '$lib/server/mod-secondary-pools';
|
||||
import {
|
||||
applyIconMutation,
|
||||
gameDataIconMetadata,
|
||||
gameDataIconUrl,
|
||||
GameDataIconValidationError,
|
||||
readIconMutation,
|
||||
} from '$lib/server/game-data-icons';
|
||||
import {
|
||||
WEAPON_MOD_SLOT_DEFINITIONS,
|
||||
WEAPON_MOD_SLOT_TYPES,
|
||||
weaponModSlotDefinitionById,
|
||||
} from '$lib/weapons/mod-slots';
|
||||
|
||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const;
|
||||
const slots = [
|
||||
'Optics',
|
||||
'Muzzle',
|
||||
'Underbarrel',
|
||||
'Magazine',
|
||||
'Barrel',
|
||||
'Upper Receiver',
|
||||
'Lower Receiver',
|
||||
'Trigger',
|
||||
] as const;
|
||||
const qualities = MOD_QUALITIES;
|
||||
const slots = WEAPON_MOD_SLOT_TYPES;
|
||||
const weaponCategories = [
|
||||
'shotgun',
|
||||
'rifle',
|
||||
@@ -49,10 +60,6 @@ type QualityValue = {
|
||||
primaryLevel20Max: number;
|
||||
};
|
||||
type Effect = { statId: string; values: Record<string, number> };
|
||||
type SecondaryRange = {
|
||||
statId: string;
|
||||
values: Record<string, { min: number; max: number }>;
|
||||
};
|
||||
type ExoticAttribute = {
|
||||
statId: string;
|
||||
level1Value: number;
|
||||
@@ -86,16 +93,6 @@ async function snapshot(id: string) {
|
||||
'SELECT * FROM weapon_mod_quality_values WHERE weapon_mod_id=? ORDER BY quality',
|
||||
)
|
||||
).all(id),
|
||||
secondaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM weapon_mod_secondary_options WHERE weapon_mod_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
secondaryQualityValues: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapon_mod_secondary_quality_values WHERE weapon_mod_id=? ORDER BY stat_attribute_id,quality',
|
||||
)
|
||||
).all(id),
|
||||
exoticAttributes: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapon_mod_exotic_attributes WHERE weapon_mod_id=? ORDER BY sort_order',
|
||||
@@ -146,10 +143,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
asc: 'CASE WHEN mods.exotic_weapon_id IS NULL THEN 0 ELSE 1 END ASC,mods.name COLLATE NOCASE ASC',
|
||||
desc: 'CASE WHEN mods.exotic_weapon_id IS NULL THEN 0 ELSE 1 END DESC,mods.name COLLATE NOCASE ASC',
|
||||
},
|
||||
secondaries: {
|
||||
asc: '(SELECT COUNT(*) FROM weapon_mod_secondary_options option WHERE option.weapon_mod_id=mods.id) ASC,mods.name COLLATE NOCASE ASC',
|
||||
desc: '(SELECT COUNT(*) FROM weapon_mod_secondary_options option WHERE option.weapon_mod_id=mods.id) DESC,mods.name COLLATE NOCASE ASC',
|
||||
},
|
||||
compatibility: {
|
||||
asc: "COALESCE(weapons.name,(SELECT MIN(link.weapon_category) FROM weapon_mod_compatible_categories link WHERE link.weapon_mod_id=mods.id),'') COLLATE NOCASE ASC,mods.name COLLATE NOCASE ASC",
|
||||
desc: "COALESCE(weapons.name,(SELECT MIN(link.weapon_category) FROM weapon_mod_compatible_categories link WHERE link.weapon_mod_id=mods.id),'') COLLATE NOCASE DESC,mods.name COLLATE NOCASE ASC",
|
||||
@@ -229,7 +222,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'exoticWeaponId',
|
||||
'compatibleCategories',
|
||||
'qualityValues',
|
||||
'secondaryRanges',
|
||||
'effects',
|
||||
'exoticAttributes',
|
||||
],
|
||||
@@ -260,14 +252,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
mods,
|
||||
compatibility,
|
||||
qualityRows,
|
||||
secondaryOptions,
|
||||
secondaryValues,
|
||||
exoticAttributes,
|
||||
effects,
|
||||
effectValues,
|
||||
stats,
|
||||
weapons,
|
||||
renders,
|
||||
slotIcons,
|
||||
secondaryPool,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT mods.*,weapons.name AS exotic_weapon_name FROM weapon_mods mods
|
||||
@@ -278,14 +270,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await database.prepare('SELECT * FROM weapon_mod_compatible_categories')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_mod_quality_values')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM weapon_mod_secondary_options')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapon_mod_secondary_quality_values',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapon_mod_exotic_attributes ORDER BY sort_order',
|
||||
@@ -314,6 +298,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='weapon_mod_render'",
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='weapon_mod_slot'",
|
||||
)
|
||||
).all(),
|
||||
getModSecondaryPool(database, 'weapon'),
|
||||
]);
|
||||
const availableWeapons = [
|
||||
...pendingWeapons.map((item) => ({
|
||||
@@ -331,7 +321,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
?.name ?? '',
|
||||
compatibleCategories: item.compatibleCategories,
|
||||
qualityValues: parseJson(String(item.qualityValues), []),
|
||||
secondaryRanges: parseJson(String(item.secondaryRanges), []),
|
||||
exoticAttributes: parseJson(String(item.exoticAttributes), []),
|
||||
effects: parseJson(String(item.effects), []),
|
||||
}));
|
||||
@@ -352,23 +341,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
primaryLevel20Min: row.primary_level_20_min,
|
||||
primaryLevel20Max: row.primary_level_20_max,
|
||||
})),
|
||||
secondaryRanges: secondaryOptions
|
||||
.filter((option) => option.weapon_mod_id === mod.id)
|
||||
.map((option) => ({
|
||||
statId: String(option.stat_attribute_id),
|
||||
values: Object.fromEntries(
|
||||
secondaryValues
|
||||
.filter(
|
||||
(row) =>
|
||||
row.weapon_mod_id === mod.id &&
|
||||
row.stat_attribute_id === option.stat_attribute_id,
|
||||
)
|
||||
.map((row) => [
|
||||
String(row.quality),
|
||||
{ min: Number(row.min_value), max: Number(row.max_value) },
|
||||
]),
|
||||
),
|
||||
})),
|
||||
exoticAttributes: exoticAttributes
|
||||
.filter((row) => row.weapon_mod_id === mod.id)
|
||||
.map((row) => ({
|
||||
@@ -396,9 +368,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
})(),
|
||||
})),
|
||||
],
|
||||
secondaryPool,
|
||||
stats: [...pendingStats, ...stats],
|
||||
weapons: availableWeapons,
|
||||
slots: slots.map((name) => ({ id: name, name })),
|
||||
slots: WEAPON_MOD_SLOT_DEFINITIONS.map((definition) => {
|
||||
const icon = slotIcons.find((row) => row.entity_id === definition.id);
|
||||
return {
|
||||
...definition,
|
||||
iconUrl: icon
|
||||
? gameDataIconUrl('weapon_mod_slot', definition.id, icon.updated_at)
|
||||
: '',
|
||||
updatedAt: icon ? String(icon.updated_at) : '',
|
||||
};
|
||||
}),
|
||||
qualities,
|
||||
weaponCategories: weaponCategories.map((name) => ({
|
||||
id: name,
|
||||
@@ -418,6 +400,141 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveSlotIcon: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const slotType = String(form.get('slotType') ?? '');
|
||||
const definition = weaponModSlotDefinitionById(id);
|
||||
if (!definition || definition.name !== slotType) {
|
||||
return fail(400, {
|
||||
weaponModSlotIconError: 'Choose a valid Weapon Mod slot icon target.',
|
||||
});
|
||||
}
|
||||
let mutation: Awaited<ReturnType<typeof readIconMutation>>;
|
||||
try {
|
||||
mutation = await readIconMutation(form);
|
||||
} catch (error) {
|
||||
if (error instanceof GameDataIconValidationError)
|
||||
return fail(400, { weaponModSlotIconError: error.message });
|
||||
throw error;
|
||||
}
|
||||
if (!mutation.output && !mutation.remove) {
|
||||
return fail(400, {
|
||||
weaponModSlotIconError:
|
||||
'Choose an icon to upload or remove the current icon.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const beforeIcon = await gameDataIconMetadata(
|
||||
database,
|
||||
'weapon_mod_slot',
|
||||
definition.id,
|
||||
);
|
||||
const baseUpdatedAt = String(form.get('baseUpdatedAt') ?? '');
|
||||
if (String(beforeIcon?.updated_at ?? '') !== baseUpdatedAt) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(409, {
|
||||
weaponModSlotIconError:
|
||||
'This slot icon changed after the editor opened. Review the latest icon and try again.',
|
||||
});
|
||||
}
|
||||
if (mutation.remove && !beforeIcon) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(400, {
|
||||
weaponModSlotIconError: 'This slot does not have an icon to remove.',
|
||||
});
|
||||
}
|
||||
await applyIconMutation(
|
||||
database,
|
||||
'weapon_mod_slot',
|
||||
definition.id,
|
||||
mutation,
|
||||
operator.id,
|
||||
);
|
||||
const afterIcon = await gameDataIconMetadata(
|
||||
database,
|
||||
'weapon_mod_slot',
|
||||
definition.id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'weapon_mod_slot_icon.updated',
|
||||
entityId: definition.id,
|
||||
before: { slot: definition, icon: beforeIcon },
|
||||
after: { slot: definition, icon: afterIcon },
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
weaponModSlotIconSaved: true,
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
saveSecondaryPool: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
if (String(form.get('id') ?? '') !== 'weapon-mod-secondary-pool') {
|
||||
return fail(400, {
|
||||
weaponModSecondaryPoolError:
|
||||
'The shared Weapon Mod pool target is invalid.',
|
||||
});
|
||||
}
|
||||
const normalized = normalizeModSecondaryPool(
|
||||
parseJson<unknown>(form.get('secondaryRanges'), null),
|
||||
);
|
||||
if (normalized.error) {
|
||||
return fail(400, { weaponModSecondaryPoolError: normalized.error });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const statIds = normalized.pool.map((range) => range.statId);
|
||||
const validStats = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
|
||||
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)
|
||||
).get(...statIds);
|
||||
if (Number(validStats?.count) !== statIds.length) {
|
||||
return fail(400, {
|
||||
weaponModSecondaryPoolError:
|
||||
'One or more selected Attributes are unavailable.',
|
||||
});
|
||||
}
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const before = await getModSecondaryPool(database, 'weapon');
|
||||
await replaceModSecondaryPool(database, 'weapon', normalized.pool);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'weapon_mod_secondary_pool.updated',
|
||||
entityId: 'weapon-mod-secondary-pool',
|
||||
before,
|
||||
after: await getModSecondaryPool(database, 'weapon'),
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
weaponModSecondaryPoolSaved: true,
|
||||
weaponModSecondaryPoolMode: 'edit',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
@@ -442,6 +559,12 @@ export const actions: Actions = {
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
if (form.has('secondaryRanges')) {
|
||||
return fail(400, {
|
||||
weaponModError:
|
||||
'Secondary Attributes must be submitted through the shared pool editor.',
|
||||
});
|
||||
}
|
||||
const slotType = String(form.get('slotType') ?? '');
|
||||
const isExotic = form.get('isExotic') === '1';
|
||||
const exoticWeaponId = String(form.get('exoticWeaponId') ?? '');
|
||||
@@ -454,10 +577,6 @@ export const actions: Actions = {
|
||||
form.get('qualityValues'),
|
||||
[],
|
||||
);
|
||||
const secondaryRanges = parseJson<SecondaryRange[]>(
|
||||
form.get('secondaryRanges'),
|
||||
[],
|
||||
);
|
||||
const effects = parseJson<Effect[]>(form.get('effects'), []);
|
||||
const exoticAttributes = parseJson<ExoticAttribute[]>(
|
||||
form.get('exoticAttributes'),
|
||||
@@ -478,30 +597,6 @@ export const actions: Actions = {
|
||||
weaponModError:
|
||||
'Exotic weapon assignment is only available for Exotic Weapon Mods.',
|
||||
});
|
||||
if (
|
||||
!isExotic &&
|
||||
(secondaryRanges.length < 2 ||
|
||||
secondaryRanges.length > 50 ||
|
||||
new Set(secondaryRanges.map((range) => range.statId)).size !==
|
||||
secondaryRanges.length ||
|
||||
secondaryRanges.some(
|
||||
(range) =>
|
||||
!range.statId ||
|
||||
qualities.some((quality) => {
|
||||
const values = range.values?.[quality];
|
||||
return (
|
||||
!values ||
|
||||
!Number.isFinite(Number(values.min)) ||
|
||||
!Number.isFinite(Number(values.max)) ||
|
||||
Number(values.max) < Number(values.min)
|
||||
);
|
||||
}),
|
||||
))
|
||||
)
|
||||
return fail(400, {
|
||||
weaponModError:
|
||||
'Configure at least two unique possible secondary attributes with valid ranges for every quality.',
|
||||
});
|
||||
if (isExotic && compatibleCategories.length)
|
||||
return fail(400, {
|
||||
weaponModError:
|
||||
@@ -599,10 +694,7 @@ export const actions: Actions = {
|
||||
...new Set(
|
||||
isExotic
|
||||
? exoticAttributes.map((attribute) => attribute.statId)
|
||||
: [
|
||||
...secondaryRanges.map((range) => range.statId),
|
||||
...effects.map((effect) => effect.statId),
|
||||
],
|
||||
: effects.map((effect) => effect.statId),
|
||||
),
|
||||
];
|
||||
const validStats = await (
|
||||
@@ -671,29 +763,6 @@ export const actions: Actions = {
|
||||
row.primaryLevel20Min,
|
||||
row.primaryLevel20Max,
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM weapon_mod_secondary_options WHERE weapon_mod_id=?',
|
||||
)
|
||||
).run(modId);
|
||||
for (const range of isExotic ? [] : secondaryRanges) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) VALUES(?,?)',
|
||||
)
|
||||
).run(modId, range.statId);
|
||||
for (const quality of qualities)
|
||||
await (
|
||||
await database.prepare(`INSERT INTO weapon_mod_secondary_quality_values
|
||||
(weapon_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`)
|
||||
).run(
|
||||
modId,
|
||||
range.statId,
|
||||
quality,
|
||||
range.values[quality].min,
|
||||
range.values[quality].max,
|
||||
);
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM weapon_mod_effects WHERE weapon_mod_id=?',
|
||||
|
||||
@@ -9,19 +9,20 @@
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import ModSecondaryPoolEditor from '$lib/components/ModSecondaryPoolEditor.svelte';
|
||||
import WeaponModSlotIconPanel from '$lib/components/WeaponModSlotIconPanel.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const qualityRow = (quality: string) => ({
|
||||
quality, primaryLevel1Min: 0, primaryLevel1Max: 0, primaryLevel20Min: 0, primaryLevel20Max: 0
|
||||
});
|
||||
const blankSecondary = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality,{ min:0,max:0 }])) });
|
||||
const blankEffect = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality, 0])) });
|
||||
const blankExoticAttribute = () => ({ statId:'',level1Value:0,level20Value:0 });
|
||||
const blank = () => ({
|
||||
id: '', name: '', active: 1, slot_type: '', exotic_weapon_id: '', renderUrl: '',
|
||||
compatibleCategories: [], qualityValues: data.qualities.map(qualityRow),
|
||||
secondaryRanges: [blankSecondary(),blankSecondary()], effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()]
|
||||
effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()]
|
||||
});
|
||||
let editing = $state<any | null>(null);
|
||||
let levelPreview = $state(1);
|
||||
@@ -31,9 +32,8 @@
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const categoryOptions = $derived(data.weaponCategories.map((category: any) => ({ value:category.id,label:category.name,icon:category.id as GameIconName,iconOnly:true })));
|
||||
const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.id,label:slot.name.toUpperCase() })));
|
||||
const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.name,label:slot.name.toUpperCase() })));
|
||||
const exoticWeapons = $derived(data.weapons.filter((weapon: any) => Number(weapon.is_exotic)));
|
||||
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
|
||||
const weapon = (id: string) => data.weapons.find((item: any) => item.id === id);
|
||||
const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview));
|
||||
const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
|
||||
@@ -43,7 +43,6 @@
|
||||
...item,
|
||||
compatibleCategories: [...item.compatibleCategories],
|
||||
qualityValues: data.qualities.map((quality: string) => ({ ...qualityRow(quality), ...(item.qualityValues.find((row: any) => row.quality === quality) ?? {}) })),
|
||||
secondaryRanges: item.secondaryRanges.length ? item.secondaryRanges.map((range: any) => ({ statId:range.statId,values:structuredClone(range.values) })) : [blankSecondary(),blankSecondary()],
|
||||
exoticAttributes: item.exoticAttributes.length ? item.exoticAttributes.map((attribute: any)=>({...attribute})) : [blankExoticAttribute()],
|
||||
effects: item.effects.length ? item.effects.map((effect: any) => ({ statId: effect.statId, values: { ...effect.values } })) : [blankEffect()]
|
||||
};
|
||||
@@ -58,8 +57,6 @@
|
||||
};
|
||||
const addEffect = () => { if (editing.effects.length < 2) editing.effects = [...editing.effects, blankEffect()]; };
|
||||
const removeEffect = (index: number) => { if (editing.effects.length > 1) editing.effects = editing.effects.filter((_: any, itemIndex: number) => itemIndex !== index); };
|
||||
const addSecondary = () => { if (editing.secondaryRanges.length < 50) editing.secondaryRanges = [...editing.secondaryRanges,blankSecondary()]; };
|
||||
const removeSecondary = (index: number) => { if (editing.secondaryRanges.length > 2) editing.secondaryRanges = editing.secondaryRanges.filter((_: any,itemIndex: number) => itemIndex !== index); };
|
||||
const addExoticAttribute = () => { if(editing.exoticAttributes.length<12) editing.exoticAttributes=[...editing.exoticAttributes,blankExoticAttribute()]; };
|
||||
const removeExoticAttribute = (index:number) => { if(editing.exoticAttributes.length>1) editing.exoticAttributes=editing.exoticAttributes.filter((_:any,itemIndex:number)=>itemIndex!==index); };
|
||||
$effect(() => {
|
||||
@@ -81,6 +78,9 @@
|
||||
<svelte:head><title>Weapon Mods — ISAC</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">ARMAMENT MODIFICATION REGISTRY</p><h2>WEAPON MODS</h2><p>Configure tier-aware slot compatibility, quality-scaled rolls, and fixed Mod Effects.</p></div><button class="primary" onclick={() => { editing = blank(); isExotic = false; }}>ADD WEAPON MOD <span>+</span></button></div>
|
||||
|
||||
<WeaponModSlotIconPanel slots={data.slots} error={form?.weaponModSlotIconError} saved={form?.weaponModSlotIconSaved} resultToken={form?.resultToken}/>
|
||||
<ModSecondaryPoolEditor kind="Weapon Mod" pool={data.secondaryPool} stats={data.stats} error={form?.weaponModSecondaryPoolError} saved={form?.weaponModSecondaryPoolSaved} resultToken={form?.resultToken}/>
|
||||
|
||||
<KpiFilters active={data.filter} items={[
|
||||
{value:'',label:'TOTAL MODS',count:data.summary.total,description:'Show all definitions'},
|
||||
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to weapons'},
|
||||
@@ -96,7 +96,6 @@
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="isExotic" value={isExotic ? '1' : '0'}/>
|
||||
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
|
||||
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
|
||||
<input type="hidden" name="effects" value={JSON.stringify(editing.effects)}/>
|
||||
<input type="hidden" name="exoticAttributes" value={JSON.stringify(editing.exoticAttributes)}/>
|
||||
<div class="weapon-mod-identity">
|
||||
@@ -130,16 +129,7 @@
|
||||
</div>
|
||||
<div class="weapon-mod-preview">
|
||||
<div><label>LEVEL PREVIEW <b>{levelPreview}</b><input type="range" min="1" max="20" step="1" bind:value={levelPreview}/></label><SegmentedChoice legend="QUALITY PREVIEW" name="qualityPreview" options={data.qualities.map((quality: string) => ({ value:quality,label:quality }))} bind:value={qualityPreview}/></div>
|
||||
{#if previewRow}<div class="weapon-mod-preview-values"><span><div class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></div><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span><span><small>SECONDARY ATTRIBUTE POOL</small><b>{editing.secondaryRanges.length} OPTIONS</b></span></div>{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="weapon-mod-secondary-section">
|
||||
<div class="selection-head"><div><span>SECONDARY ATTRIBUTE POOL</span><small>THE BUILDER SELECTS TWO DISTINCT ATTRIBUTES · EVERY OPTION REQUIRES A RANGE AT EACH QUALITY</small></div><button type="button" class="row-action" onclick={addSecondary} disabled={editing.secondaryRanges.length>=50}>ADD ATTRIBUTE +</button></div>
|
||||
<div class="weapon-mod-secondary-options">
|
||||
{#each editing.secondaryRanges as range,index}
|
||||
<div><strong>{String(index+1).padStart(2,'0')}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={editing.secondaryRanges.filter((_: any,itemIndex: number)=>itemIndex!==index).map((item: any)=>item.statId)} bind:value={range.statId}/>{#each data.qualities as quality}<label><span class={qualityClass(quality)}>{quality.toUpperCase()}</span><div><input type="number" step="any" bind:value={range.values[quality].min} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].max} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} maximum`} required/></div></label>{/each}<button type="button" aria-label={`Remove secondary attribute option ${index+1}`} onclick={()=>removeSecondary(index)} disabled={editing.secondaryRanges.length<=2}>×</button></div>
|
||||
{/each}
|
||||
{#if previewRow}<div class="weapon-mod-preview-values"><span><div class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></div><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span><span><small>SHARED SECONDARY POOL</small><b>{data.secondaryPool.length} OPTIONS</b></span></div>{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -166,9 +156,9 @@
|
||||
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<div class="dashboard-table weapon-mod-dashboard-table">
|
||||
<div class="dashboard-row weapon-mod-row table-heading"><SortableColumnHeader label="WEAPON MOD" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><SortableColumnHeader label="COMPATIBILITY" column="compatibility" sort={data.sort}/><SortableColumnHeader label="MOD EFFECT" column="effects" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
<div class="dashboard-row weapon-mod-row table-heading"><SortableColumnHeader label="WEAPON MOD" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="PRIMARY" column="primary" sort={data.sort}/><span>SECONDARIES</span><SortableColumnHeader label="COMPATIBILITY" column="compatibility" sort={data.sort}/><SortableColumnHeader label="MOD EFFECT" column="effects" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.mods as item}
|
||||
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${data.secondaryPool.length} SHARED`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : '0 / 1 / 2 / 2'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
|
||||
{#if editing?.id === item.id}
|
||||
<RegistryInlineEditor id={`weapon-mod-editor-${item.id}`} label={`Edit ${item.name}`}>
|
||||
{@render weaponModEditor()}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audits: [] as Array<Record<string, unknown>>,
|
||||
database: {
|
||||
exec: vi.fn(async () => undefined),
|
||||
prepare: vi.fn(async () => ({
|
||||
get: vi.fn(async (...statIds: unknown[]) => ({ count: statIds.length })),
|
||||
})),
|
||||
},
|
||||
getModSecondaryPool: vi.fn(),
|
||||
recordIsacAudit: vi.fn(),
|
||||
replaceModSecondaryPool: vi.fn(),
|
||||
requirePermission: vi.fn(async (user) => user),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
requireDataView: vi.fn(async (user) => user),
|
||||
requirePermission: mocks.requirePermission,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
auditRecord: vi.fn(),
|
||||
recordIsacAudit: mocks.recordIsacAudit,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/data-moderation', () => ({
|
||||
getPendingEntities: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/managed-images', () => ({
|
||||
applyGameDataRender: vi.fn(),
|
||||
ManagedImageValidationError: class extends Error {},
|
||||
managedImageUrl: vi.fn(() => ''),
|
||||
processManagedImage: vi.fn(),
|
||||
readGameDataRenderMutation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/mod-secondary-pools', () => ({
|
||||
getModSecondaryPool: mocks.getModSecondaryPool,
|
||||
replaceModSecondaryPool: mocks.replaceModSecondaryPool,
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const endpoints = (
|
||||
level1Min: number,
|
||||
level1Max: number,
|
||||
level20Min: number,
|
||||
level20Max: number,
|
||||
) => ({ level1Min, level1Max, level20Min, level20Max });
|
||||
|
||||
const pool = (suffix: string, offset = 0) => [
|
||||
{
|
||||
statId: `critical-hit-chance-${suffix}`,
|
||||
values: {
|
||||
Specialized: endpoints(1 + offset, 2 + offset, 3 + offset, 4 + offset),
|
||||
Superior: endpoints(2 + offset, 3 + offset, 4 + offset, 5 + offset),
|
||||
'High-end': endpoints(3 + offset, 4 + offset, 5 + offset, 6 + offset),
|
||||
},
|
||||
},
|
||||
{
|
||||
statId: `critical-hit-damage-${suffix}`,
|
||||
values: {
|
||||
Specialized: endpoints(4 + offset, 5 + offset, 6 + offset, 7 + offset),
|
||||
Superior: endpoints(5 + offset, 6 + offset, 7 + offset, 8 + offset),
|
||||
'High-end': endpoints(6 + offset, 7 + offset, 8 + offset, 9 + offset),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const request = (secondaryRanges: ReturnType<typeof pool>) => {
|
||||
const form = new FormData();
|
||||
form.set('id', 'weapon-mod-secondary-pool');
|
||||
form.set('secondaryRanges', JSON.stringify(secondaryRanges));
|
||||
return new Request('http://localhost/isac/weaponmods?/saveSecondaryPool', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
describe('Weapon Mod shared secondary pool action', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.audits.length = 0;
|
||||
mocks.recordIsacAudit.mockImplementation(async (event) => {
|
||||
mocks.audits.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
it('audits the complete contract and commits the transaction', async () => {
|
||||
const before = pool('before');
|
||||
const after = pool('after', 1);
|
||||
mocks.getModSecondaryPool
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce(after);
|
||||
|
||||
const result = await actions.saveSecondaryPool?.({
|
||||
cookies: {} as never,
|
||||
request: request(after),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
weaponModSecondaryPoolSaved: true,
|
||||
weaponModSecondaryPoolMode: 'edit',
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||
{ id: 'operator-1' },
|
||||
'data.manage',
|
||||
);
|
||||
expect(mocks.replaceModSecondaryPool).toHaveBeenCalledWith(
|
||||
mocks.database,
|
||||
'weapon',
|
||||
after,
|
||||
);
|
||||
expect(mocks.audits).toEqual([
|
||||
{
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: 'weapon_mod_secondary_pool.updated',
|
||||
entityId: 'weapon-mod-secondary-pool',
|
||||
before,
|
||||
after,
|
||||
database: mocks.database,
|
||||
},
|
||||
]);
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['COMMIT'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rolls back without committing when the audit write fails', async () => {
|
||||
const before = pool('before');
|
||||
const after = pool('after', 1);
|
||||
const auditError = new Error('audit insert failed');
|
||||
mocks.getModSecondaryPool
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce(after);
|
||||
mocks.recordIsacAudit.mockRejectedValueOnce(auditError);
|
||||
|
||||
await expect(
|
||||
actions.saveSecondaryPool?.({
|
||||
cookies: {} as never,
|
||||
request: request(after),
|
||||
} as never),
|
||||
).rejects.toThrow(auditError);
|
||||
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledOnce();
|
||||
expect(mocks.database.exec).toHaveBeenCalledWith('ROLLBACK');
|
||||
expect(mocks.database.exec).not.toHaveBeenCalledWith('COMMIT');
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['ROLLBACK'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WEAPON_MOD_SLOT_DEFINITIONS } from '$lib/weapons/mod-slots';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
applyIconMutation: vi.fn(),
|
||||
database: {
|
||||
exec: vi.fn(async () => undefined),
|
||||
prepare: vi.fn(async () => ({ get: vi.fn(), all: vi.fn() })),
|
||||
},
|
||||
gameDataIconMetadata: vi.fn(),
|
||||
readIconMutation: vi.fn(),
|
||||
recordIsacAudit: vi.fn(),
|
||||
requirePermission: vi.fn(async (user) => user),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||
}));
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
requireDataView: vi.fn(async (user) => user),
|
||||
requirePermission: mocks.requirePermission,
|
||||
}));
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
auditRecord: vi.fn(),
|
||||
recordIsacAudit: mocks.recordIsacAudit,
|
||||
}));
|
||||
vi.mock('$lib/server/data-moderation', () => ({
|
||||
getPendingEntities: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock('$lib/server/managed-images', () => ({
|
||||
applyGameDataRender: vi.fn(),
|
||||
ManagedImageValidationError: class extends Error {},
|
||||
managedImageUrl: vi.fn(() => ''),
|
||||
processManagedImage: vi.fn(),
|
||||
readGameDataRenderMutation: vi.fn(),
|
||||
}));
|
||||
vi.mock('$lib/server/game-data-icons', () => ({
|
||||
applyIconMutation: mocks.applyIconMutation,
|
||||
gameDataIconMetadata: mocks.gameDataIconMetadata,
|
||||
gameDataIconUrl: vi.fn(() => ''),
|
||||
GameDataIconValidationError: class extends Error {},
|
||||
readIconMutation: mocks.readIconMutation,
|
||||
}));
|
||||
vi.mock('$lib/server/mod-secondary-pools', () => ({
|
||||
getModSecondaryPool: vi.fn(),
|
||||
replaceModSecondaryPool: vi.fn(),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const definition = WEAPON_MOD_SLOT_DEFINITIONS[0];
|
||||
|
||||
function actionRequest(overrides: Record<string, string> = {}) {
|
||||
const form = new FormData();
|
||||
form.set('id', overrides.id ?? definition.id);
|
||||
form.set('name', `${definition.name} Slot`);
|
||||
form.set('slotType', overrides.slotType ?? definition.name);
|
||||
form.set('baseUpdatedAt', overrides.baseUpdatedAt ?? 'before-version');
|
||||
return new Request('http://localhost/isac/weaponmods?/saveSlotIcon', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
const invoke = (request = actionRequest()) =>
|
||||
actions.saveSlotIcon?.({ cookies: {} as never, request } as never);
|
||||
|
||||
describe('Weapon Mod shared slot icon action', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.gameDataIconMetadata.mockReset();
|
||||
mocks.readIconMutation.mockReset();
|
||||
mocks.recordIsacAudit.mockReset();
|
||||
mocks.readIconMutation.mockResolvedValue({
|
||||
output: Buffer.from('icon'),
|
||||
remove: false,
|
||||
});
|
||||
mocks.gameDataIconMetadata
|
||||
.mockResolvedValueOnce({ byte_size: 10, updated_at: 'before-version' })
|
||||
.mockResolvedValueOnce({ byte_size: 20, updated_at: 'after-version' });
|
||||
});
|
||||
|
||||
it('validates the target, saves, audits, and commits', async () => {
|
||||
const result = await invoke();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
weaponModSlotIconSaved: true,
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||
{ id: 'operator-1' },
|
||||
'data.manage',
|
||||
);
|
||||
expect(mocks.applyIconMutation).toHaveBeenCalledWith(
|
||||
mocks.database,
|
||||
'weapon_mod_slot',
|
||||
definition.id,
|
||||
{ output: Buffer.from('icon'), remove: false },
|
||||
'operator-1',
|
||||
);
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: 'weapon_mod_slot_icon.updated',
|
||||
entityId: definition.id,
|
||||
before: {
|
||||
slot: definition,
|
||||
icon: { byte_size: 10, updated_at: 'before-version' },
|
||||
},
|
||||
after: {
|
||||
slot: definition,
|
||||
icon: { byte_size: 20, updated_at: 'after-version' },
|
||||
},
|
||||
database: mocks.database,
|
||||
});
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['COMMIT'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a mismatched slot identity before reading the upload', async () => {
|
||||
const result = await invoke(actionRequest({ slotType: 'Muzzle' }));
|
||||
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(mocks.readIconMutation).not.toHaveBeenCalled();
|
||||
expect(mocks.database.exec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects stale editor data without changing the icon', async () => {
|
||||
const result = await invoke(
|
||||
actionRequest({ baseUpdatedAt: 'stale-version' }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 409 });
|
||||
expect(mocks.applyIconMutation).not.toHaveBeenCalled();
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['ROLLBACK'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rolls back when audit persistence fails', async () => {
|
||||
const auditError = new Error('audit failed');
|
||||
mocks.recordIsacAudit.mockRejectedValueOnce(auditError);
|
||||
|
||||
await expect(invoke()).rejects.toThrow(auditError);
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['ROLLBACK'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user