From 849c4de44d06ff7bb6c2dd21f0aca2345012a737 Mon Sep 17 00:00:00 2001 From: Aaron Lutomski Date: Mon, 25 May 2026 01:47:07 -0700 Subject: [PATCH] Added profession item type and partially automated chargen. Overhauled modified roll dialog. Added stylized chat card. --- CHANGELOG.md | 1 + assets/img/green_delta_icon.webp | Bin 51086 -> 6918 bytes css/deltagreen.css | 750 ++++++++++++++ css/dialogue.css | 24 - lang/ca.json | 76 +- lang/cn.json | 76 +- lang/de.json | 76 +- lang/en.json | 130 +-- lang/es.json | 77 +- lang/fr.json | 77 +- lang/ja.json | 76 +- lang/pl.json | 77 +- lang/pt-BR.json | 77 +- lang/sv.json | 76 +- lang/uk.json | 77 +- module/applications/add-profession-dialog.js | 549 +++++++++++ module/applications/assign-stats-dialog.js | 156 +++ module/applications/pick-statistics-dialog.js | 55 ++ module/applications/profession-setup-flow.js | 83 ++ module/applications/roll-stats-dialog.js | 274 ++++++ module/chat/dg-chat-card.js | 259 +++++ module/data/item/profession.js | 21 + module/deltagreen.js | 29 +- module/item/item.js | 15 +- module/macros/macro-functions.js | 11 +- module/roll/roll-classes.js | 86 +- module/roll/roll-dialogs.js | 283 +++--- module/roll/roll.js | 9 +- module/roll/skill-improvement-roll.js | 14 +- module/sheets/agent-sheet.js | 100 +- module/sheets/base-item-sheet.js | 32 +- module/sheets/mixins/profession-item-mixin.js | 277 ++++++ module/sheets/mixins/roll-sheet-mixin.js | 7 +- module/utils/dice-so-nice.js | 86 ++ module/utils/profession-skills.js | 918 ++++++++++++++++++ module/utils/profession-stat-setup.js | 112 +++ module/utils/resource-chat.js | 17 +- system.json | 3 + .../actor/partials/profession-subname.html | 14 + templates/actor/parts/left-bar.html | 6 +- templates/chat/dg-chat-card.hbs | 12 + templates/dialog/add-profession-skill.html | 39 + templates/dialog/add-profession.html | 136 +++ templates/dialog/assign-stats.html | 19 + templates/dialog/modify-percentile-roll.html | 74 +- templates/dialog/pick-statistics.html | 3 + templates/dialog/roll-stats.html | 23 + .../item/parts/profession-description.html | 5 + templates/item/parts/profession-header.html | 16 + .../item/parts/profession-skills-tab.html | 75 ++ templates/roll/damage-roll.hbs | 21 + 51 files changed, 5171 insertions(+), 338 deletions(-) create mode 100644 module/applications/add-profession-dialog.js create mode 100644 module/applications/assign-stats-dialog.js create mode 100644 module/applications/pick-statistics-dialog.js create mode 100644 module/applications/profession-setup-flow.js create mode 100644 module/applications/roll-stats-dialog.js create mode 100644 module/chat/dg-chat-card.js create mode 100644 module/data/item/profession.js create mode 100644 module/sheets/mixins/profession-item-mixin.js create mode 100644 module/utils/dice-so-nice.js create mode 100644 module/utils/profession-skills.js create mode 100644 module/utils/profession-stat-setup.js create mode 100644 templates/actor/partials/profession-subname.html create mode 100644 templates/chat/dg-chat-card.hbs create mode 100644 templates/dialog/add-profession-skill.html create mode 100644 templates/dialog/add-profession.html create mode 100644 templates/dialog/assign-stats.html create mode 100644 templates/dialog/pick-statistics.html create mode 100644 templates/dialog/roll-stats.html create mode 100644 templates/item/parts/profession-description.html create mode 100644 templates/item/parts/profession-header.html create mode 100644 templates/item/parts/profession-skills-tab.html create mode 100644 templates/roll/damage-roll.hbs diff --git a/CHANGELOG.md b/CHANGELOG.md index f799c46..9590e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - [#123](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/123) - Added Active Effect support - [#346](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/346) - Maximum resources modifiable through AE - [#365](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/365) - Added buttons to trigger exhaustion, rest, and use stimulants +- [#209](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/209) - Added **Profession** item type with partial chargen automation ### **Bug Fixes:** - [#392](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/392) - Fixed sheet rolls diff --git a/assets/img/green_delta_icon.webp b/assets/img/green_delta_icon.webp index eaaa4799a70aa4e8e6c8029ad221282e398bb2ed..6df7a711b4020e4cbc7bdd5430e12d42d5f22fc0 100644 GIT binary patch literal 6918 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 vg#l}B>;YFt4D>>XdfrjVh$dh|b^sE@o9+}(ZUJ .tab.top-div-border { flex: 1 1 auto; @@ -1561,6 +1582,85 @@ a[data-action="applySkillImprovements"] { font-weight: bold; } +/* Delta Green stylized chat cards */ +.deltagreen.chat-card.dg-chat-card { + border: 2px solid #000; + border-radius: 3px; + overflow: hidden; + margin: 0; +} + +.dg-chat-card__header { + display: flex; + flex-direction: row; + align-items: center; + gap: 0; + background: #1a1a1a; + color: #f5f5f0; + padding: 6px 0; +} + +.dg-chat-card__icon-slot { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + align-self: center; + width: 40px; + height: 40px; + min-width: 40px; + aspect-ratio: 1 / 1; + box-sizing: border-box; + margin-left: 8px; + overflow: hidden; +} + +.dg-chat-card__icon { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center center; +} + +.dg-chat-card__titles { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 8px 0 8px; +} + +.dg-chat-card__speaker { + margin: 0; + font-size: 0.95rem; + font-weight: bold; + color: #f5f5f0; + line-height: 1.2; +} + +.dg-chat-card__label { + font-size: 0.8rem; + color: #f5f5f0; + line-height: 1.25; + margin-top: 2px; +} + +.dg-chat-card__body { + border-color: #000; + border-width: 0; + border-style: solid; + border-radius: 0; + padding: 5px; + background: inherit; +} + +.chat-message:has(.dg-chat-card) .message-header, +.chat-message.dg-chat-card-message .message-header { + display: none; +} + .rollback-section { order: 99; display: flex; @@ -1604,6 +1704,17 @@ a[data-action="applySkillImprovements"] { padding: 3px; } + .left-bar .profession-subname-link { + display: block; + box-sizing: border-box; + padding: 1px 2px; + line-height: var(--line-height-18, 1.125rem); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .stats-wrapper { margin-top: 8px; margin-bottom: 8px; @@ -2333,6 +2444,93 @@ a[data-action="applySkillImprovements"] { display: none; } +.modify-roll-dialog-app .form-footer { + display: none; +} + +.modify-roll-dialog .form-group { + margin-bottom: 0.5rem; +} + +.modify-roll-dialog .modify-roll-readonly { + flex: 1; + font-weight: bold; +} + +.modify-roll-dialog .modify-roll-section { + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-border-light-2, rgba(255, 255, 255, 0.2)); +} + +.modify-roll-dialog .modify-roll-section h3 { + margin: 0 0 0.35rem; + font-size: var(--font-size-14, 0.875rem); + font-weight: bold; +} + +.modify-roll-dialog .modify-roll-hint { + margin: 0 0 0.65rem; + font-size: var(--font-size-12); + color: var(--color-text-light-secondary, rgba(255, 255, 255, 0.75)); + line-height: 1.35; +} + +.modify-roll-dialog .modify-roll-quick-grid { + display: grid; + grid-template-columns: 5rem 1fr; + gap: 10px 12px; + align-items: center; +} + +.modify-roll-dialog .modify-roll-quick-option { + display: contents; +} + +.modify-roll-dialog .modify-roll-quick-btn, +.modify-roll-dialog .modify-roll-submit-btn { + box-sizing: border-box; + padding: 4px 8px; + border-radius: 3px; + font-size: 0.8rem; + font-weight: bold; + background: var(--color-control-bg, rgba(255, 255, 255, 0.14)); + color: var(--color-text-light-primary, #eee); + border: 1px solid var(--color-border-light-2, rgba(255, 255, 255, 0.35)); + cursor: pointer; + white-space: nowrap; + text-align: center; +} + +.modify-roll-dialog .modify-roll-quick-btn:hover, +.modify-roll-dialog .modify-roll-submit-btn:hover { + background: var(--color-control-bg-hover, rgba(255, 255, 255, 0.22)); + border-color: var(--color-border-light-highlight, rgba(255, 255, 255, 0.5)); +} + +.modify-roll-dialog .modify-roll-quick-explanation { + margin: 0; + font-size: var(--font-size-12); + color: var(--color-text-light-secondary, rgba(255, 255, 255, 0.75)); + line-height: 1.35; +} + +.modify-roll-dialog .modify-roll-custom-row { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.modify-roll-dialog .modify-roll-custom-row input[type="number"] { + flex: 0 0 5rem; + text-align: center; +} + +.modify-roll-dialog .modify-roll-submit-btn { + flex: 0 0 auto; + min-width: 5rem; +} + .deltagreen .effects-list .effect-controls { display: flex; gap: 4px; @@ -2356,3 +2554,555 @@ a[data-action="applySkillImprovements"] { width: 100%; max-width: 14rem; } + +/* Profession item sheet */ +.deltagreen .profession-sheet-grid-2col { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.75rem; + align-items: start; +} + +.deltagreen .profession-sheet-name { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5rem; + align-items: center; +} + +.deltagreen .tab.profession-skills { + --profession-toolbar-label-width: 11rem; + --profession-toolbar-secondary-label-width: 7rem; +} + +.deltagreen .tab.profession-skills .profession-skills-section { + margin-top: 0.75rem; +} + +.deltagreen .tab.profession-skills .profession-skills-section:first-child { + margin-top: 0; +} + +.deltagreen .tab.profession-skills .profession-section-toolbar { + display: grid; + grid-template-columns: + var(--profession-toolbar-label-width) + auto + var(--profession-toolbar-secondary-label-width) + 4rem; + align-items: center; + column-gap: 0.5rem; + margin-bottom: 0.35rem; +} + +.deltagreen .tab.profession-skills .profession-skills-toolbar-label { + font-weight: bold; +} + +.deltagreen .tab.profession-skills .profession-skills-toolbar-control { + justify-self: start; +} + +.deltagreen .tab.profession-skills .profession-bonds-input, +.deltagreen .tab.profession-skills .profession-option-picks-input { + width: 100%; + text-align: center; +} + +.deltagreen .tab.profession-skills .profession-skill-list { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.25rem 1rem; + list-style: none; + margin: 0; + padding: 0; +} + +.deltagreen .tab.profession-skills .profession-skill-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.15rem 0; + min-width: 0; +} + +.deltagreen .tab.profession-skills .profession-skill-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deltagreen .tab.profession-skills .profession-skill-rating { + flex-shrink: 0; + min-width: 2.5rem; + text-align: right; +} + +.add-profession-skill-dialog-app .add-profession-skill-dialog .profession-typed-label-group.hidden, +.deltagreen .add-profession-skill-dialog .profession-typed-label-group.hidden { + display: none; +} + +.add-profession-skill-dialog-app .add-profession-skill-dialog .profession-typed-label-group, +.add-profession-skill-dialog-app .standard-form .add-profession-skill-dialog .profession-typed-label-group, +.deltagreen .add-profession-skill-dialog .profession-typed-label-group, +.standard-form .add-profession-skill-dialog .profession-typed-label-group { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; +} + +.add-profession-skill-dialog-app .add-profession-skill-dialog .profession-typed-label-group > *, +.add-profession-skill-dialog-app .standard-form .add-profession-skill-dialog .profession-typed-label-group > *, +.deltagreen .add-profession-skill-dialog .profession-typed-label-group > *, +.standard-form .add-profession-skill-dialog .profession-typed-label-group > * { + flex: 0 0 auto; + width: 100%; + max-width: 100%; +} + +.add-profession-skill-dialog-app .add-profession-skill-dialog .profession-typed-name-field, +.add-profession-skill-dialog-app .add-profession-skill-dialog .profession-choose-one-field, +.add-profession-skill-dialog-app .standard-form .add-profession-skill-dialog .profession-typed-name-field, +.add-profession-skill-dialog-app .standard-form .add-profession-skill-dialog .profession-choose-one-field, +.deltagreen .add-profession-skill-dialog .profession-typed-name-field, +.deltagreen .add-profession-skill-dialog .profession-choose-one-field, +.standard-form .add-profession-skill-dialog .profession-typed-name-field, +.standard-form .add-profession-skill-dialog .profession-choose-one-field { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; +} + +.add-profession-skill-dialog-app .add-profession-skill-dialog .profession-typed-name-field > label, +.add-profession-skill-dialog-app .standard-form .add-profession-skill-dialog .profession-typed-name-field > label, +.deltagreen .add-profession-skill-dialog .profession-typed-name-field > label, +.standard-form .add-profession-skill-dialog .profession-typed-name-field > label { + flex: none; + width: 100%; + line-height: 1.3; +} + +.deltagreen .add-profession-skill-dialog .profession-typed-name-field input { + width: 100%; +} + +.deltagreen .add-profession-skill-dialog .profession-choose-one-field { + padding-top: 0.25rem; +} + +/* Add Profession dialog */ +.add-profession-dialog-app h3 { + font-size: var(--font-size-14); + font-weight: bold; + margin: 0.5rem 0 0.35rem; + line-height: 1.2; +} + +.add-profession-skill-dialog-app .add-profession-skill-dialog .profession-choose-one-checkbox, +.deltagreen .add-profession-skill-dialog .profession-choose-one-checkbox { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0; +} + +.add-profession-skill-dialog-app .add-profession-skill-dialog .profession-choose-one-hint, +.deltagreen .add-profession-skill-dialog .profession-choose-one-hint { + margin: 0.25rem 0 0; +} + +.add-profession-dialog-app .add-profession-choose-one-section { + margin-bottom: 0.75rem; +} + +.add-profession-dialog-app .add-profession-choose-one-list { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.add-profession-dialog-app .add-profession-choose-one-row { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.add-profession-dialog-app .add-profession-choose-one-row input { + width: 100%; +} + +.add-profession-dialog-app .add-profession-option-row .option-choose-one-label { + width: 100%; +} + +.add-profession-dialog-app .add-profession-skills-section { + margin-bottom: 0.75rem; +} + +.add-profession-dialog-app .add-profession-skills-columns-header { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; + margin-bottom: 0.25rem; + font-size: var(--font-size-12); + font-weight: bold; +} + +.add-profession-dialog-app .add-profession-skill-col-header { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.5rem; + padding: 0 0.15rem; +} + +.add-profession-dialog-app .add-profession-skills-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.2rem 0.75rem; + max-height: 16rem; + overflow-y: auto; +} + +.add-profession-dialog-app .add-profession-skill-entry { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.25rem 0.5rem; + align-items: center; + min-width: 0; +} + +.add-profession-dialog-app .add-profession-skill-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.add-profession-dialog-app .add-profession-skill-value { + text-align: right; + min-width: 2.5rem; +} + +.add-profession-dialog-app .add-profession-skill-entry.is-modified .add-profession-skill-label, +.add-profession-dialog-app .add-profession-skill-entry.is-modified .add-profession-skill-value { + color: var(--color-text-success, #2d8a3e); +} + +.add-profession-dialog-app .add-profession-options-section { + margin-bottom: 0.75rem; +} + +.add-profession-dialog-app .add-profession-option-list { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.35rem 0.75rem; + margin: 0 0 0.75rem; + padding: 0; +} + +.add-profession-dialog-app .add-profession-option-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + min-width: 0; +} + +.add-profession-dialog-app .add-profession-option-row > label { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + width: 100%; + min-width: 0; +} + +.add-profession-dialog-app .add-profession-option-row .option-skill-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; +} + +.add-profession-dialog-app .add-profession-option-row .option-typed-label { + width: 100%; +} + +.add-profession-dialog-app .add-profession-bonus-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem 0.75rem; + margin-bottom: 0.75rem; +} + +.add-profession-dialog-app .add-profession-bonus-slot { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.35rem; + min-width: 0; +} + +.add-profession-dialog-app .add-profession-bonus-slot select { + flex: 0 1 11rem; + width: 11rem; + max-width: 42%; + min-width: 7rem; +} + +.add-profession-dialog-app .add-profession-bonus-slot .bonus-typed-label { + flex: 1 1 0; + min-width: 5rem; + width: auto; +} + +.add-profession-dialog-app .add-profession-bonus-slot .bonus-typed-label.hidden { + display: none; +} + +.add-profession-dialog-app .cap-warning, +.add-profession-dialog-app .validation-error { + color: var(--color-text-error, #c00); + font-weight: bold; + margin: 0.25rem 0; +} + +.add-profession-dialog-app .add-profession-bonds-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; +} + +.add-profession-dialog-app .add-profession-bond-row { + display: grid; + grid-template-columns: auto 1fr 1fr 3.5rem; + gap: 0.35rem 0.5rem; + align-items: end; + min-width: 0; +} + +.add-profession-dialog-app .add-profession-bond-row .bond-row-label { + font-weight: bold; + white-space: nowrap; + padding-bottom: 0.15rem; +} + +.add-profession-dialog-app .add-profession-bond-field { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; + font-size: var(--font-size-12); +} + +.add-profession-dialog-app .add-profession-bond-field-label { + white-space: nowrap; +} + +.add-profession-dialog-app .add-profession-bond-field input { + width: 100%; + min-width: 0; +} + +.add-profession-dialog-app .add-profession-bond-field-score input { + text-align: center; +} + +.add-profession-dialog-app .form-footer { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: stretch; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.add-profession-dialog-app .form-footer > button { + flex: 1 1 0; + min-width: 0; +} + +.deltagreen .profession-assigned-row { + margin-bottom: 0.75rem; +} + +.pick-statistics-dialog-app .pick-statistics-intro { + margin: 0 0 0.5rem; + line-height: 1.4; +} + +.pick-statistics-dialog-app .form-footer { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.pick-statistics-dialog-app .form-footer > button[data-action="skip"] { + grid-column: 1 / -1; +} + +.roll-stats-dialog-app .roll-stats-layout { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + align-items: start; +} + +.roll-stats-dialog-app .roll-stats-pool-section { + min-width: 5.5rem; +} + +.roll-stats-dialog-app .roll-stats-pool-hint { + margin: 0.75rem 0 0; + font-size: var(--font-size-12, 0.75rem); + text-align: center; + color: var(--color-text-dark-secondary, rgba(255, 255, 255, 0.65)); +} + +.roll-stats-dialog-app .roll-stats-pool { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + width: 3rem; + min-height: 3rem; + padding: 0.5rem; + border: 1px dashed var(--color-border-light-2, rgba(255, 255, 255, 0.25)); + border-radius: 4px; +} + +.roll-stats-dialog-app .roll-stats-pool.drag-over { + border-color: var(--color-border-highlight, #ff6400); + background: rgba(255, 100, 0, 0.08); +} + +.roll-stats-dialog-app .roll-stat-token { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; + font-weight: bold; + font-size: var(--font-size-16, 1rem); + border: 1px solid var(--color-border-light-2, rgba(255, 255, 255, 0.25)); + border-radius: 4px; + background: var(--color-bg-option, rgba(0, 0, 0, 0.15)); + cursor: grab; + user-select: none; + box-sizing: border-box; +} + +.roll-stats-dialog-app .roll-stat-token.is-dragging { + opacity: 0.5; + cursor: grabbing; +} + +.roll-stats-dialog-app .roll-stats-assignment-section { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.roll-stats-dialog-app .roll-stat-row { + display: grid; + grid-template-columns: 3rem auto; + align-items: center; + gap: 0.75rem; +} + +.roll-stats-dialog-app .roll-stat-label { + font-weight: bold; +} + +.roll-stats-dialog-app .roll-stat-slot { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + flex: 0 0 auto; + padding: 0; + border: 1px dashed var(--color-border-light-2, rgba(255, 255, 255, 0.25)); + border-radius: 4px; + box-sizing: border-box; +} + +.roll-stats-dialog-app .roll-stat-slot.drag-over { + border-color: var(--color-border-highlight, #ff6400); + background: rgba(255, 100, 0, 0.08); +} + +.roll-stats-dialog-app .roll-stat-slot .roll-stat-token { + cursor: grab; + width: 100%; + height: 100%; +} + +.roll-stats-dialog-app .form-footer { + display: flex; + flex-direction: row; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.assign-stats-dialog-app .assign-stats-remaining { + margin: 0 0 0.75rem; + font-weight: bold; +} + +.assign-stats-dialog-app .assign-stats-remaining.is-invalid { + color: var(--color-text-warning, #ff9200); +} + +.assign-stats-dialog-app .assign-stats-rows { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.assign-stats-dialog-app .assign-stats-row { + display: grid; + grid-template-columns: 3rem 1fr; + align-items: center; + gap: 0.75rem; +} + +.assign-stats-dialog-app .assign-stats-label { + font-weight: bold; +} + +.assign-stats-dialog-app .assign-stats-row input { + width: 100%; + text-align: center; +} + +.assign-stats-dialog-app .form-footer { + display: flex; + flex-direction: row; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.roll-stats-chat-table { + width: auto; + margin: 0 auto; + border-collapse: collapse; +} + +.roll-stats-chat-table td { + padding: 0.35rem 1rem; + text-align: center; + font-weight: bold; + font-size: var(--font-size-16, 1rem); + border: 1px solid var(--color-border-light-2, rgba(255, 255, 255, 0.2)); +} + +.roll-stats-chat-table .roll-stats-chat-value { + min-width: 2rem; +} diff --git a/css/dialogue.css b/css/dialogue.css index f654ff1..094fc96 100644 --- a/css/dialogue.css +++ b/css/dialogue.css @@ -1,27 +1,3 @@ -.skill-modify-grid { - display: grid; - grid-template-columns: auto auto; - align-content: left; - justify-content: left; - align-items: left; - padding: 10px; - grid-gap: 10px 15px; -} - -.plusMinusNumberFieldBox { - display: flex; - gap: 10px; - - select[name="plusOrMinus"] { - flex: 1; - } - - .numeric-label-field { - flex: 1; - text-align: center !important; - } -} - .damage-modify-label-grid { display: grid; grid-template-columns: auto auto; diff --git a/lang/ca.json b/lang/ca.json index 9f45b50..ab73a03 100644 --- a/lang/ca.json +++ b/lang/ca.json @@ -141,7 +141,6 @@ "DG.ModifyDamageRollDialogue.Weapon": "Arma:", "DG.ModifySkillRollDialogue.ModifierTooltip": "Donar un modificador positiu o negatiu aquí per sumar o restar al objectiu en la tirada d'habilitats.", "DG.ModifySkillRollDialogue.OriginalTarget": "Objectiu original:", - "DG.ModifySkillRollDialogue.TargetModifier": "Modificar objectiu:", "DG.ModifySkillRollDialogue.Test": "Test:", "DG.ModifySkillRollDialogue.Title": "Modificar tirada", "DG.NPCSheet.BreakPointAbbreviation": "PT", @@ -195,8 +194,6 @@ "DG.Roll.Lethality": "Letalitat", "DG.Roll.Or": "O", "DG.Roll.Roll": "Tirar", - "DG.Roll.Rolling": "Tirant", - "DG.Roll.RollingDamageFor": "Tirant dany per", "DG.Roll.Success": "Èxit", "DG.Roll.Target": "Objectiu: ", "DG.RollLuck": "Tira sort", @@ -300,5 +297,76 @@ "TYPES.Item.motivation": "Motivació", "TYPES.Item.ritual": "Ritual", "TYPES.Item.tome": "Tome", - "TYPES.Item.weapon": "Arma" + "TYPES.Item.weapon": "Arma", + "DG.ModifySkillRollDialogue.QuickModifiersHeader": "Modificadors ràpids", + "DG.ModifySkillRollDialogue.QuickModifiersHint": "Aplica un ajust predefinit i tira immediatament.", + "DG.ModifySkillRollDialogue.ModifierMuchHarder": "Fa la tirada molt més difícil", + "DG.ModifySkillRollDialogue.ModifierHarder": "Fa la tirada més difícil", + "DG.ModifySkillRollDialogue.ModifierEasier": "Fa la tirada més fàcil", + "DG.ModifySkillRollDialogue.ModifierMuchEasier": "Fa la tirada molt més fàcil", + "DG.ModifySkillRollDialogue.CustomModifierHeader": "Modificador personalitzat", + "DG.ModifySkillRollDialogue.CustomModifierHint": "Introduïu un valor positiu o negatiu per ajustar l'objectiu.", + "DG.ModifySkillRollDialogue.InvalidModifier": "Introduïu un modificador vàlid per tirar.", + "TYPES.Item.profession": "Professió", + "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", + "DG.Profession.AssignedHeader": "ASSIGNED PROFESSION ITEM", + "DG.Profession.Dialog.Title": "Pick Skills", + "DG.Profession.Dialog.Skill": "Skill", + "DG.Profession.Dialog.Value": "Value", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)", + "DG.Profession.Dialog.BonusSkillsHeader": "Bonus Skills", + "DG.Profession.Dialog.BondsHeader": "Bonds", + "DG.Profession.Dialog.Bond": "Bond", + "DG.Profession.Dialog.SelectSkill": "Select a skill…", + "DG.Profession.Dialog.SkillTypePlaceholder": "Type or specialization", + "DG.Profession.Dialog.Submit": "Submit", + "DG.Profession.Dialog.CapWarning": "{skill} is being raised to {attempted} but is capped at 80 during agent creation. This is wasting {waste} skill points.", + "DG.Profession.Dialog.DuplicateTypedName": "Duplicate {type} ({name}) detected. Please choose unique names.", + "DG.Profession.ChooseOnePlaceholder": "choose one", + "DG.Profession.Dialog.ChooseOneNamesHeader": "Name your skills", + "DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization", + "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.PickStatistics.Intro": "Tria com establir les estadístiques d'aquest agent abans de triar habilitats.", + "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", + "DG.ProfessionSetup.PickStatistics.AssignStats": "Assign Stats", + "DG.ProfessionSetup.PickStatistics.AssignStatsTooltip": "Divide 72 points between stats as desired.", + "DG.ProfessionSetup.PickStatistics.LeaveStatsAlone": "Leave my stats alone!", + "DG.ProfessionSetup.RollStats.Title": "Roll Stats", + "DG.ProfessionSetup.RollStats.PoolHeader": "Drag each rolled value onto a stat.", + "DG.ProfessionSetup.RollStats.Submit": "Submit", + "DG.ProfessionSetup.RollStats.ChatLabel": "Tirades de stats (4d6, descarta el més baix)", + "DG.ProfessionSetup.AssignStats.Title": "Assign Stats", + "DG.ProfessionSetup.AssignStats.PointsRemaining": "Points remaining: {remaining}", + "DG.ProfessionSetup.AssignStats.Submit": "Submit", + "DG.ItemWindow.Profession.Description": "Description", + "DG.ItemWindow.Profession.SkillsTab": "Skills", + "DG.ItemWindow.Profession.Name": "Profession Name", + "DG.ItemWindow.Profession.Bonds": "Bonds", + "DG.ItemWindow.Profession.BondsTooltip": "Number of bond slots granted when this profession is assigned to an agent (0–5).", + "DG.ItemWindow.Profession.AutomaticSkills": "Automatic Skills", + "DG.ItemWindow.Profession.OptionSkills": "Skill Options", + "DG.ItemWindow.Profession.OptionPicks": "Option Picks", + "DG.ItemWindow.Profession.AddSkill": "Add Skill", + "DG.ItemWindow.Profession.AddSkillTitle": "Add Skill to Profession", + "DG.ItemWindow.Profession.RemoveSkill": "Remove Skill", + "DG.ItemWindow.Profession.Skill": "Skill", + "DG.ItemWindow.Profession.SelectSkill": "Select a skill…", + "DG.ItemWindow.Profession.Rating": "Rating (%)", + "DG.ItemWindow.Profession.SkillType": "Skill Type", + "DG.ItemWindow.Profession.SkillTypePlaceholder": "e.g. German, Painting", + "DG.ItemWindow.Profession.TypedSkillRequired": "Enter a skill type before adding this skill.", + "DG.ItemWindow.Profession.ChooseOne": "Choose One", + "DG.ItemWindow.Profession.ChooseOneHint": "The player will name this skill when the profession is applied to an agent.", + "DG.ItemWindow.Profession.TypedSkillNameConflict": "Another automatic or option skill in this category already uses the name \"{name}\".", + "DG.Profession.Dialog.OptionPicksRequired": "Select exactly {picks} skill option(s).", + "DG.Profession.Dialog.BondNameRequired": "Enter a name for each bond.", + "DG.Profession.Dialog.BondRelationshipRequired": "Enter a relationship for each bond.", + "DG.Profession.Dialog.BonusSkillRequired": "Select all {count} bonus skills.", + "DG.Profession.Dialog.BonusSkillTypeRequired": "Enter a specialization for each typed bonus skill.", + "DG.Profession.Dialog.TypedNameRequired": "Enter a name for each {type} skill.", + "DG.Physical.Chat.ExhaustedLabel": "Exhausted", + "DG.Physical.Chat.RestedLabel": "Rest", + "DG.Physical.Chat.StimulantsRegularLabel": "Regular Stimulants", + "DG.Physical.Chat.StimulantsHardLabel": "Hard Drugs" } diff --git a/lang/cn.json b/lang/cn.json index aa2d242..e7b6f5e 100644 --- a/lang/cn.json +++ b/lang/cn.json @@ -151,7 +151,6 @@ "DG.ModifyDamageRollDialogue.Weapon": "武器:", "DG.ModifySkillRollDialogue.ModifierTooltip": "在这里给出正面或负面修正,以增加/减少技能检定的目标值。", "DG.ModifySkillRollDialogue.OriginalTarget": "初始目标:", - "DG.ModifySkillRollDialogue.TargetModifier": "修改目标:", "DG.ModifySkillRollDialogue.Test": "测试:", "DG.ModifySkillRollDialogue.Title": "修改掷骰", "DG.NPCSheet.BreakPointAbbreviation": "BP", @@ -206,8 +205,6 @@ "DG.Roll.Lethality": "致死等级", "DG.Roll.Or": "或", "DG.Roll.Roll": "掷骰", - "DG.Roll.Rolling": "正在掷骰", - "DG.Roll.RollingDamageFor": "正在为以下目标掷伤害骰", "DG.Roll.Success": "成功", "DG.Roll.Target": "需求: ", "DG.RollLuck": "幸运检定", @@ -332,5 +329,76 @@ "TYPES.Item.motivation": "人生动机", "TYPES.Item.ritual": "仪式", "TYPES.Item.tome": "典籍", - "TYPES.Item.weapon": "武器" + "TYPES.Item.weapon": "武器", + "DG.ModifySkillRollDialogue.QuickModifiersHeader": "快速修正", + "DG.ModifySkillRollDialogue.QuickModifiersHint": "应用预设修正并立即掷骰。", + "DG.ModifySkillRollDialogue.ModifierMuchHarder": "使掷骰困难得多", + "DG.ModifySkillRollDialogue.ModifierHarder": "使掷骰更困难", + "DG.ModifySkillRollDialogue.ModifierEasier": "使掷骰更容易", + "DG.ModifySkillRollDialogue.ModifierMuchEasier": "使掷骰容易得多", + "DG.ModifySkillRollDialogue.CustomModifierHeader": "自定义修正", + "DG.ModifySkillRollDialogue.CustomModifierHint": "输入正数或负数以调整目标值。", + "DG.ModifySkillRollDialogue.InvalidModifier": "请输入有效的修正值后再掷骰。", + "TYPES.Item.profession": "职业", + "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", + "DG.Profession.AssignedHeader": "ASSIGNED PROFESSION ITEM", + "DG.Profession.Dialog.Title": "Pick Skills", + "DG.Profession.Dialog.Skill": "Skill", + "DG.Profession.Dialog.Value": "Value", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)", + "DG.Profession.Dialog.BonusSkillsHeader": "Bonus Skills", + "DG.Profession.Dialog.BondsHeader": "Bonds", + "DG.Profession.Dialog.Bond": "Bond", + "DG.Profession.Dialog.SelectSkill": "Select a skill…", + "DG.Profession.Dialog.SkillTypePlaceholder": "Type or specialization", + "DG.Profession.Dialog.Submit": "Submit", + "DG.Profession.Dialog.CapWarning": "{skill} is being raised to {attempted} but is capped at 80 during agent creation. This is wasting {waste} skill points.", + "DG.Profession.Dialog.DuplicateTypedName": "Duplicate {type} ({name}) detected. Please choose unique names.", + "DG.Profession.ChooseOnePlaceholder": "choose one", + "DG.Profession.Dialog.ChooseOneNamesHeader": "Name your skills", + "DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization", + "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.PickStatistics.Intro": "在选择技能之前,请选择如何设置此调查员的数据。", + "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", + "DG.ProfessionSetup.PickStatistics.AssignStats": "Assign Stats", + "DG.ProfessionSetup.PickStatistics.AssignStatsTooltip": "Divide 72 points between stats as desired.", + "DG.ProfessionSetup.PickStatistics.LeaveStatsAlone": "Leave my stats alone!", + "DG.ProfessionSetup.RollStats.Title": "Roll Stats", + "DG.ProfessionSetup.RollStats.PoolHeader": "Drag each rolled value onto a stat.", + "DG.ProfessionSetup.RollStats.Submit": "Submit", + "DG.ProfessionSetup.RollStats.ChatLabel": "数据投骰(4d6,弃最低)", + "DG.ProfessionSetup.AssignStats.Title": "Assign Stats", + "DG.ProfessionSetup.AssignStats.PointsRemaining": "Points remaining: {remaining}", + "DG.ProfessionSetup.AssignStats.Submit": "Submit", + "DG.ItemWindow.Profession.Description": "Description", + "DG.ItemWindow.Profession.SkillsTab": "Skills", + "DG.ItemWindow.Profession.Name": "Profession Name", + "DG.ItemWindow.Profession.Bonds": "Bonds", + "DG.ItemWindow.Profession.BondsTooltip": "Number of bond slots granted when this profession is assigned to an agent (0–5).", + "DG.ItemWindow.Profession.AutomaticSkills": "Automatic Skills", + "DG.ItemWindow.Profession.OptionSkills": "Skill Options", + "DG.ItemWindow.Profession.OptionPicks": "Option Picks", + "DG.ItemWindow.Profession.AddSkill": "Add Skill", + "DG.ItemWindow.Profession.AddSkillTitle": "Add Skill to Profession", + "DG.ItemWindow.Profession.RemoveSkill": "Remove Skill", + "DG.ItemWindow.Profession.Skill": "Skill", + "DG.ItemWindow.Profession.SelectSkill": "Select a skill…", + "DG.ItemWindow.Profession.Rating": "Rating (%)", + "DG.ItemWindow.Profession.SkillType": "Skill Type", + "DG.ItemWindow.Profession.SkillTypePlaceholder": "e.g. German, Painting", + "DG.ItemWindow.Profession.TypedSkillRequired": "Enter a skill type before adding this skill.", + "DG.ItemWindow.Profession.ChooseOne": "Choose One", + "DG.ItemWindow.Profession.ChooseOneHint": "The player will name this skill when the profession is applied to an agent.", + "DG.ItemWindow.Profession.TypedSkillNameConflict": "Another automatic or option skill in this category already uses the name \"{name}\".", + "DG.Profession.Dialog.OptionPicksRequired": "Select exactly {picks} skill option(s).", + "DG.Profession.Dialog.BondNameRequired": "Enter a name for each bond.", + "DG.Profession.Dialog.BondRelationshipRequired": "Enter a relationship for each bond.", + "DG.Profession.Dialog.BonusSkillRequired": "Select all {count} bonus skills.", + "DG.Profession.Dialog.BonusSkillTypeRequired": "Enter a specialization for each typed bonus skill.", + "DG.Profession.Dialog.TypedNameRequired": "Enter a name for each {type} skill.", + "DG.Physical.Chat.ExhaustedLabel": "Exhausted", + "DG.Physical.Chat.RestedLabel": "Rest", + "DG.Physical.Chat.StimulantsRegularLabel": "Regular Stimulants", + "DG.Physical.Chat.StimulantsHardLabel": "Hard Drugs" } diff --git a/lang/de.json b/lang/de.json index a894f62..d42d08b 100644 --- a/lang/de.json +++ b/lang/de.json @@ -141,7 +141,6 @@ "DG.ModifyDamageRollDialogue.Weapon": "Waffe:", "DG.ModifySkillRollDialogue.ModifierTooltip": "Gib hier einen positiven oder negativen Modifikator an, der zum Ziel für die Fertigkeitsprobe addiert/ abgezogen wird.", "DG.ModifySkillRollDialogue.OriginalTarget": "Ursprüngliches Ziel:", - "DG.ModifySkillRollDialogue.TargetModifier": "Ziel modifizieren (+/-):", "DG.ModifySkillRollDialogue.Test": "Probe:", "DG.ModifySkillRollDialogue.Title": "Probe modifizieren", "DG.NPCSheet.BreakPointAbbreviation": "BS", @@ -195,8 +194,6 @@ "DG.Roll.Lethality": "Tödlichkeit", "DG.Roll.Or": "Oder", "DG.Roll.Roll": "Probe", - "DG.Roll.Rolling": "Probe", - "DG.Roll.RollingDamageFor": "SCHADEN würfeln für", "DG.Roll.Success": "Erfolg", "DG.Roll.Target": "Ziel: ", "DG.RollLuck": "Glücksprobe ausführen", @@ -315,5 +312,76 @@ "TYPES.Item.motivation": "Motivation", "TYPES.Item.ritual": "Ritual", "TYPES.Item.tome": "Buch", - "TYPES.Item.weapon": "Waffe" + "TYPES.Item.weapon": "Waffe", + "DG.ModifySkillRollDialogue.QuickModifiersHeader": "Schnellmodifikatoren", + "DG.ModifySkillRollDialogue.QuickModifiersHint": "Wende eine Voreinstellung an und würfle sofort.", + "DG.ModifySkillRollDialogue.ModifierMuchHarder": "Macht den Wurf deutlich schwerer", + "DG.ModifySkillRollDialogue.ModifierHarder": "Macht den Wurf schwerer", + "DG.ModifySkillRollDialogue.ModifierEasier": "Macht den Wurf leichter", + "DG.ModifySkillRollDialogue.ModifierMuchEasier": "Macht den Wurf deutlich leichter", + "DG.ModifySkillRollDialogue.CustomModifierHeader": "Eigener Modifikator", + "DG.ModifySkillRollDialogue.CustomModifierHint": "Gib einen positiven oder negativen Wert ein, um das Ziel anzupassen.", + "DG.ModifySkillRollDialogue.InvalidModifier": "Gib einen gültigen Modifikator ein, um zu würfeln.", + "TYPES.Item.profession": "Profession", + "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", + "DG.Profession.AssignedHeader": "ASSIGNED PROFESSION ITEM", + "DG.Profession.Dialog.Title": "Pick Skills", + "DG.Profession.Dialog.Skill": "Skill", + "DG.Profession.Dialog.Value": "Value", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)", + "DG.Profession.Dialog.BonusSkillsHeader": "Bonus Skills", + "DG.Profession.Dialog.BondsHeader": "Bonds", + "DG.Profession.Dialog.Bond": "Bond", + "DG.Profession.Dialog.SelectSkill": "Select a skill…", + "DG.Profession.Dialog.SkillTypePlaceholder": "Type or specialization", + "DG.Profession.Dialog.Submit": "Submit", + "DG.Profession.Dialog.CapWarning": "{skill} is being raised to {attempted} but is capped at 80 during agent creation. This is wasting {waste} skill points.", + "DG.Profession.Dialog.DuplicateTypedName": "Duplicate {type} ({name}) detected. Please choose unique names.", + "DG.Profession.ChooseOnePlaceholder": "choose one", + "DG.Profession.Dialog.ChooseOneNamesHeader": "Name your skills", + "DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization", + "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.PickStatistics.Intro": "Wähle, wie die Statistiken dieses Agenten vor der Fertigkeitsauswahl gesetzt werden.", + "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", + "DG.ProfessionSetup.PickStatistics.AssignStats": "Assign Stats", + "DG.ProfessionSetup.PickStatistics.AssignStatsTooltip": "Divide 72 points between stats as desired.", + "DG.ProfessionSetup.PickStatistics.LeaveStatsAlone": "Leave my stats alone!", + "DG.ProfessionSetup.RollStats.Title": "Roll Stats", + "DG.ProfessionSetup.RollStats.PoolHeader": "Drag each rolled value onto a stat.", + "DG.ProfessionSetup.RollStats.Submit": "Submit", + "DG.ProfessionSetup.RollStats.ChatLabel": "Stat-Würfe (4W6, niedrigster verwerfen)", + "DG.ProfessionSetup.AssignStats.Title": "Assign Stats", + "DG.ProfessionSetup.AssignStats.PointsRemaining": "Points remaining: {remaining}", + "DG.ProfessionSetup.AssignStats.Submit": "Submit", + "DG.ItemWindow.Profession.Description": "Description", + "DG.ItemWindow.Profession.SkillsTab": "Skills", + "DG.ItemWindow.Profession.Name": "Profession Name", + "DG.ItemWindow.Profession.Bonds": "Bonds", + "DG.ItemWindow.Profession.BondsTooltip": "Number of bond slots granted when this profession is assigned to an agent (0–5).", + "DG.ItemWindow.Profession.AutomaticSkills": "Automatic Skills", + "DG.ItemWindow.Profession.OptionSkills": "Skill Options", + "DG.ItemWindow.Profession.OptionPicks": "Option Picks", + "DG.ItemWindow.Profession.AddSkill": "Add Skill", + "DG.ItemWindow.Profession.AddSkillTitle": "Add Skill to Profession", + "DG.ItemWindow.Profession.RemoveSkill": "Remove Skill", + "DG.ItemWindow.Profession.Skill": "Skill", + "DG.ItemWindow.Profession.SelectSkill": "Select a skill…", + "DG.ItemWindow.Profession.Rating": "Rating (%)", + "DG.ItemWindow.Profession.SkillType": "Skill Type", + "DG.ItemWindow.Profession.SkillTypePlaceholder": "e.g. German, Painting", + "DG.ItemWindow.Profession.TypedSkillRequired": "Enter a skill type before adding this skill.", + "DG.ItemWindow.Profession.ChooseOne": "Choose One", + "DG.ItemWindow.Profession.ChooseOneHint": "The player will name this skill when the profession is applied to an agent.", + "DG.ItemWindow.Profession.TypedSkillNameConflict": "Another automatic or option skill in this category already uses the name \"{name}\".", + "DG.Profession.Dialog.OptionPicksRequired": "Select exactly {picks} skill option(s).", + "DG.Profession.Dialog.BondNameRequired": "Enter a name for each bond.", + "DG.Profession.Dialog.BondRelationshipRequired": "Enter a relationship for each bond.", + "DG.Profession.Dialog.BonusSkillRequired": "Select all {count} bonus skills.", + "DG.Profession.Dialog.BonusSkillTypeRequired": "Enter a specialization for each typed bonus skill.", + "DG.Profession.Dialog.TypedNameRequired": "Enter a name for each {type} skill.", + "DG.Physical.Chat.ExhaustedLabel": "Exhausted", + "DG.Physical.Chat.RestedLabel": "Rest", + "DG.Physical.Chat.StimulantsRegularLabel": "Regular Stimulants", + "DG.Physical.Chat.StimulantsHardLabel": "Hard Drugs" } diff --git a/lang/en.json b/lang/en.json index 65c26cf..b038f93 100644 --- a/lang/en.json +++ b/lang/en.json @@ -3,9 +3,7 @@ "DG.AgentNameFieldTooltip": "LAST NAME, FIRST NAME (AND ALIAS IF APPLICABLE)", "DG.RollLuck": "Roll Luck", "DG.Luck": "Luck", - "DG.FallbackText.newItem": "New {type}", - "DG.Skills.accounting": "Accounting", "DG.Skills.alertness": "Alertness", "DG.Skills.anthropology": "Anthropology", @@ -44,7 +42,6 @@ "DG.Skills.unarmed_combat": "Unarmed Combat", "DG.Skills.unnatural": "Unnatural", "DG.Skills.flight": "Flight", - "DG.Skills.Tooltip.accounting": "Spot financial red flags like hidden assets, shell companies, or embezzlement.", "DG.Skills.Tooltip.alertness": "Notice hidden dangers, subtle movements, or quiet sounds before they become threats.", "DG.Skills.Tooltip.anthropology": "Understand the beliefs, behaviors, and taboos of living cultures and societies.", @@ -82,7 +79,6 @@ "DG.Skills.Tooltip.swim": "Survive in water under duress—rescue others, escape, or endure hazards.", "DG.Skills.Tooltip.unarmed_combat": "Subdue or injure opponents using fists, grapples, kicks, and other hand-to-hand techniques.", "DG.Skills.Tooltip.unnatural": "Recognize, research, or recall truths about entities and forces beyond human understanding.", - "DG.Skills.AddTypedOrCustomSkill": "Add Typed/Custom Skill", "DG.Skills.ToggleEditDeleteSkills": "Edit/Delete Skills", "DG.Skills.AddSkill": "Add Skill", @@ -96,7 +92,6 @@ "DG.Skills.EditSkill": "Edit Skill", "DG.Skills.SkillGroup": "Skill Group", "DG.Skills.SkillName": "Skill Name", - "DG.TypeSkills.Art": "Art", "DG.TypeSkills.Craft": "Craft", "DG.TypeSkills.ForeignLanguage": "Foreign Language", @@ -104,7 +99,6 @@ "DG.TypeSkills.Pilot": "Pilot", "DG.TypeSkills.Science": "Science", "DG.TypeSkills.Other": "Other", - "DG.TypeSkills.Tooltip.Art": "Create or appraise artistic works, and spot forgeries in a given field.", "DG.TypeSkills.Tooltip.Craft": "Build, fix, or sabotage specialized tools or systems like engines, locks, or wiring.", "DG.TypeSkills.Tooltip.ForeignLanguage": "Speak, read, and understand a specific language.", @@ -112,7 +106,6 @@ "DG.TypeSkills.Tooltip.Pilot": "Control and navigate air, sea, or space vehicles in critical situations or conditions.", "DG.TypeSkills.Tooltip.Science": "Apply specialized scientific knowledge to interpret or investigate the physical world.", "DG.TypeSkills.Tooltip.Other": "Other", - "DG.SpecialTraining.Dialog.CreateSpecialTraining": "Add Special Training", "DG.SpecialTraining.Dialog.DropDown.CustomSkills": "Custom/Typed Skills", "DG.SpecialTraining.Dialog.DropDown.Skills": "Skills", @@ -121,7 +114,6 @@ "DG.SpecialTraining.Dialog.NameLabel": "Name", "DG.SpecialTraining.Dialog.SkillLabel": "Skill or Stat", "DG.SpecialTraining.Dialog.Title": "Create/Edit Special Training", - "DG.Attributes.HP": "HP", "DG.Attributes.WP": "WP", "DG.Attributes.SAN": "SAN", @@ -131,7 +123,6 @@ "DG.Attributes.int": "INT", "DG.Attributes.pow": "POW", "DG.Attributes.cha": "CHA", - "DG.Attributes.Tooltip.HP": "Hit Points (HP) indicate physical health, a character becomes unconsious at 2 HP, and dies at 0 HP.", "DG.Attributes.Tooltip.WP": "Willpower Points (WP) indicate mental fortitude and drive. Agents require Willpower Points to keep going despite exhaustion or mentally taxing situations. See Page 47 of the Agent's Handbook for more detail.", "DG.Attributes.Tooltip.SAN": "Sanity Points (SAN) indicate a connection with humanity and reality as most people perceive it. Click to roll a SAN test", @@ -141,7 +132,6 @@ "DG.Attributes.Tooltip.int": "See Page 19 of the Agent's Handbook - Intelligence", "DG.Attributes.Tooltip.pow": "See Page 19 of the Agent's Handbook - Power", "DG.Attributes.Tooltip.cha": "See Page 19 of the Agent's Handbook - Charisma", - "DG.Navigation.Agent.skills": "Skills", "DG.Navigation.Agent.combat": "Combat", "DG.Navigation.Agent.physical": "Statistics", @@ -158,7 +148,6 @@ "DG.Navigation.NPC.gear": "Attacks & Abilities", "DG.Navigation.Vehicle.gear": "Gear", "DG.Navigation.Vehicle.notes": "Notes", - "DG.About.Changelog": "CHANGELOG (ONLINE)", "DG.About.Copyright": "Published by Arc Dream Publishing by arrangement with the Delta Green Partnership. The intellectual property known as Delta Green is a trademark and copyright owned by the Delta Green Partnership, who has licensed its use here.", "DG.About.License": "The contents of this system are licensed per the terms of the included license file, excepting those elements that are components of the Delta Green intellectual property.", @@ -166,8 +155,6 @@ "DG.About.OnlineHelpTooltip": "Launch browser with online GitHub help.", "DG.About.OnlineBugTracker": "Questions, Bug Reports or Feature Requests (ONLINE)", "DG.About.OnlineBugTrackerTooltip": "Launch browser with GitHub issue tracker.", - - "DG.Roll.Rolling": "Rolling", "DG.Roll.Target": "Target: ", "DG.Roll.Success": "Success", "DG.Roll.Failure": "Failure", @@ -177,10 +164,8 @@ "DG.Roll.Lethal": "Lethal", "DG.Roll.For": "For", "DG.Roll.Damage": "Damage", - "DG.Roll.RollingDamageFor": "Rolling DAMAGE for", "DG.Roll.Or": "Or", "DG.Roll.Roll": "Roll", - "DG.Physical.Wounds": "Wounds", "DG.Physical.FirstAidLabel": "First Aid Has Already Been Attempted: ", "DG.Physical.ExhaustionPenalty": "Penalty:", @@ -204,9 +189,7 @@ "DG.Physical.Chat.Rested": "{name} gets a full night's rest and regains {gain} Willpower, bringing them to {current}/{max}.", "DG.Physical.Chat.StimulantsRegular": "{name} takes regular stimulants, suppressing their exhaustion for {hours} hours. They cannot sleep during this time.", "DG.Physical.Chat.StimulantsHard": "{name} takes hard drugs, suppressing their exhaustion for {hours} hours. They cannot sleep during this time.", - "DG.Generic.AddButtonLabel": "Add", - "DG.ActiveEffects.SheetLabel": "Delta Green Active Effect", "DG.ActiveEffects.DefaultName": "Active Effect", "DG.ActiveEffects.Add": "Add Active Effect", @@ -232,7 +215,6 @@ "DG.ActiveEffects.Fields.MaxHP": "Maximum HP", "DG.ActiveEffects.Fields.MaxWP": "Maximum Willpower", "DG.ActiveEffects.Fields.MaxSanity": "Maximum Sanity", - "DG.Mental.BreakingPoint": "Breaking Point: ", "DG.Mental.Tooltip.BreakingPoint": "When SAN falls to or below this number, Agent gains a long-term mental disorder; set a new Breaking Point to current SAN - POW. Disorders remain even if SAN later rises above a previous Breaking Point.", "DG.Mental.Reset": "Reset", @@ -247,7 +229,6 @@ "DG.Mental.AdaptedToHelplessness": "Adapted to Helplessness", "DG.Mental.Motivations": "Motivation", "DG.Mental.Disorders": "Disorder", - "DG.Gear.WeaponTypes.Firearms": "Firearms", "DG.Gear.WeaponTypes.Melee": "Melee", "DG.Gear.Weapons": "WEAPONS", @@ -258,7 +239,6 @@ "DG.Gear.ArmorPiercing": "Armor Piercing", "DG.Gear.ArmorRating": "Armor Rating", "DG.Gear.Equipped": "Equipped", - "DG.CV.Sex": "Sex", "DG.CV.SexPlaceholder": "Sex", "DG.CV.AgeAndDoB": "Age and D.O.B.", @@ -269,12 +249,10 @@ "DG.CV.NationalityPlaceholder": "Nationality", "DG.CV.Education": "Education", "DG.CV.EducationPlaceholder": "Education or Training", - "DG.Bonds.Bond": "Bond", "DG.Bonds.Name": "Name", "DG.Bonds.Score": "Score", "DG.Bonds.NewBond": "New Bond", - "DG.Tooltip.MaximumHP": "Maximum health is the average of STR + CON, rounded UP.", "DG.Tooltip.MaximumWP": "Maximum WP is equal to POW.", "DG.Tooltip.CurrentSanityPartOne": "Current sanity, current breaking point is ", @@ -290,17 +268,14 @@ "DG.Tooltip.AdaptedToHelplessness": "Being adapted to helplessness means your Agent’s personal drive suffers as they pull back from the world around them. As soon as your Agent becomes adapted to helplessness, agent permanently loses 1D6 POW.", "DG.Tooltip.ToggleEquippedState": "Click to toggle whether this item is equipped or not.", "DG.Tooltip.Exhaustion": "An Agent who works too long or faces extreme danger and injury without resting becomes exhausted. When this comes into play is up to the Handler, but a good rule of thumb is that going a night without sleep or refusing to rest after losing SAN or Hit Points leads to exhaustion.", - "DG.Gear.Expense.NA": "N/A or None", "DG.Gear.Expense.Incidental": "Incidental", "DG.Gear.Expense.Standard": "Standard", "DG.Gear.Expense.Unusual": "Unusual", "DG.Gear.Expense.Major": "Major", "DG.Gear.Expense.Extreme": "Extreme", - "DG.ItemWindow.Bonds.Description": "Description/Developments", "DG.ItemWindow.Bonds.ScoreTooltip": "A higher score represents a stronger relationship between the agent and the bond.", - "DG.ItemWindow.Motivations.Attributes": "Attributes", "DG.ItemWindow.Motivations.Motivation": "Motivation", "DG.ItemWindow.Motivations.Description": "Description/Notes", @@ -313,19 +288,16 @@ "DG.ItemWindow.Motivations.DisorderHasBeenCuredTooltip": "A critical success on a therapy luck roll can 'cure' a disorder. It may return the next time a breaking point is hit however. See Pages 78-79 of the Agent's Handbook - Therapy.", "DG.ItemWindow.Motivations.AcuteEpisode": "Acute Episode", "DG.ItemWindow.Motivations.AcuteEpisodeTooltip": "While checked, transferred active effects on this motivation apply to the agent.", - "DG.ItemWindow.Gear.Attributes": "Attributes", "DG.ItemWindow.Gear.Description": "Description", "DG.ItemWindow.Gear.NamePlaceholder": "Name", "DG.ItemWindow.Gear.Expense": "Expense", "DG.ItemWindow.Gear.Equipped": "Equipped", - "DG.ItemWindow.Armor.Description": "Description", "DG.ItemWindow.Armor.NamePlaceholder": "Name", "DG.ItemWindow.Armor.ArmorRating": "Armor Rating", "DG.ItemWindow.Armor.Expense": "Expense", "DG.ItemWindow.Armor.Equipped": "Equipped", - "DG.ItemWindow.Weapons.Description": "Description", "DG.ItemWindow.Weapons.NamePlaceholder": "Name", "DG.ItemWindow.Weapons.Skill": "Skill", @@ -339,27 +311,29 @@ "DG.ItemWindow.Weapons.Ammo": "Ammo", "DG.ItemWindow.Weapons.Range": "Range", "DG.ItemWindow.Weapons.KillRadius": "Kill Radius", - "DG.ModifySkillRollDialogue.Title": "Modify Roll", "DG.ModifySkillRollDialogue.Test": "Test:", "DG.ModifySkillRollDialogue.OriginalTarget": "Original Target:", - "DG.ModifySkillRollDialogue.TargetModifier": "Modify Target:", - "DG.ModifySkillRollDialogue.ModifierTooltip": "Give a positive or negative modifier here to add/subtract from the target for the skill check.", - + "DG.ModifySkillRollDialogue.ModifierTooltip": "Enter a positive value to increase the target, or a negative value to decrease it.", + "DG.ModifySkillRollDialogue.QuickModifiersHeader": "Quick Modifiers", + "DG.ModifySkillRollDialogue.QuickModifiersHint": "Apply a preset adjustment and roll immediately.", + "DG.ModifySkillRollDialogue.ModifierMuchHarder": "Makes the roll much harder", + "DG.ModifySkillRollDialogue.ModifierHarder": "Makes the roll harder", + "DG.ModifySkillRollDialogue.ModifierEasier": "Makes the roll easier", + "DG.ModifySkillRollDialogue.ModifierMuchEasier": "Makes the roll much easier", + "DG.ModifySkillRollDialogue.CustomModifierHeader": "Custom Modifier", + "DG.ModifySkillRollDialogue.CustomModifierHint": "Enter a positive or negative value to adjust the target.", + "DG.ModifySkillRollDialogue.InvalidModifier": "Enter a valid modifier to roll.", "DG.ModifyDamageRollDialogue.Weapon": "Weapon:", "DG.ModifyDamageRollDialogue.OuterModifierTooltip": "Use this field to modify the outer part of the formula, such as to double the result.", "DG.ModifyDamageRollDialogue.InnerModifierTooltip": "Use this field to add a flat modifier such as '+1' or another dice term such as '+1d4'.", - "DG.Roll.Inhuman": "Inhuman", "DG.Tooltip.BreakPoint": "The current breaking point, reset when current sanity falls to or below this value. The starting breaking point is SAN - POW.", "DG.Tooltip.BreakPointReset": "Reset breaking point to be current SAN - WP.", "DG.Skills.ritual": "Ritual", - "DG.RollMode": "Roll Mode", "DG.Bonds.Relationship": "Relationship", - "DG.TypeSkills.Subskills.Painting": "Painting", - "DG.Sheet.BlockHeaders.Attributes": "ATTRIBUTES", "DG.Sheet.BlockHeaders.Statistics": "STATISTICS", "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes": "PHYSICAL DESCRIPTION / NOTES", @@ -372,9 +346,8 @@ "DG.Sheet.BlockHeaders.PersonalInfoCV": "PERSONAL INFORMATION / CV", "DG.Sheet.BlockHeaders.ScenarioInformation": "SCENARIO INFORMATION", "DG.Sheet.BlockHeaders.BondsRelationshipsContacts": "BONDS / RELATIONSHIPS / CONTACTS", - "DG.AgentSheet.ProfessionPlaceholder": "PROFESSION (RANK IF APPLICABLE)", - + "DG.AgentSheet.OpenProfessionToEdit": "Click to edit profession", "DG.UnnaturalSheet.HideUntrainedSkillsLabel": "Hide Untrained", "DG.UnnaturalSheet.NotesLabel": "Notes", "DG.UnnaturalSheet.ShortDescriptionPlaceholder": "Description of creature or creature type", @@ -382,39 +355,31 @@ "DG.UnnaturalSheet.Tabs.NotesTabLabel": "Skills & Notes", "DG.UnnaturalSheet.Tabs.ItemsTabLabel": "Attacks & Abilities", "DG.NPCSheet.BreakPointAbbreviation": "BP", - "DG.ImpossibleLanscapes.IHaveSeenThe": "I HAVE SEEN THE", "DG.ImpossibleLanscapes.YellowSign": "YELLOW SIGN", "DG.ImpossibleLanscapes.CorruptionFirstHalf": "Corrup", "DG.ImpossibleLanscapes.CorruptionSecondHalf": "tion", "DG.ImpossibleLanscapes.Gift": "Gift", "DG.ImpossibleLanscapes.Insight": "Insight", - "DG.VehicleSheet.NotesTabName": "Notes", "DG.VehicleSheet.GearTabName": "Gear", "DG.VehicleSheet.NotesLabel": "NOTES", "DG.VehicleSheet.DescriptionPlaceHolder": "Vehicle Description", - "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene": "Check a damaged Bond's box until the next Home Scene ends.", "DG.Generic.ClearAll": "Clear Checks", - "DG.Generic.SanDamage": "SAN Damage", - "DG.ItemWindow.Weapons.CustomSkillTarget": "Target", "DG.ItemWindow.Weapons.SkillModifier": "Skill Modifier", "DG.ItemWindow.Custom": "Custom", - "DG.Sheet.class.item": "Item Sheet", "DG.Sheet.class.agent": "Agent Sheet", "DG.Sheet.class.npc": "NPC Sheet", "DG.Sheet.class.unnatural": "Unnatural Sheet", "DG.Sheet.class.vehicle": "Vehicle Sheet", - "TYPES.Actor.agent": "Agent", "TYPES.Actor.npc": "NPC", "TYPES.Actor.unnatural": "Unnatural", "TYPES.Actor.vehicle": "Vehicle", - "TYPES.Item.armor": "Armor", "TYPES.Item.bond": "Bond", "TYPES.Item.gear": "Gear", @@ -422,13 +387,62 @@ "TYPES.Item.weapon": "Weapon", "TYPES.Item.tome": "Tome", "TYPES.Item.ritual": "Ritual", - + "TYPES.Item.profession": "Profession", + "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", + "DG.Profession.AssignedHeader": "ASSIGNED PROFESSION ITEM", + "DG.Profession.Dialog.Title": "Pick Skills", + "DG.Profession.Dialog.Skill": "Skill", + "DG.Profession.Dialog.Value": "Value", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)", + "DG.Profession.Dialog.BonusSkillsHeader": "Bonus Skills", + "DG.Profession.Dialog.BondsHeader": "Bonds", + "DG.Profession.Dialog.Bond": "Bond", + "DG.Profession.Dialog.SelectSkill": "Select a skill…", + "DG.Profession.Dialog.SkillTypePlaceholder": "Type or specialization", + "DG.Profession.Dialog.Submit": "Submit", + "DG.Profession.Dialog.CapWarning": "{skill} is being raised to {attempted} but is capped at 80 during agent creation. This is wasting {waste} skill points.", + "DG.Profession.Dialog.DuplicateTypedName": "Duplicate {type} ({name}) detected. Please choose unique names.", + "DG.Profession.ChooseOnePlaceholder": "choose one", + "DG.Profession.Dialog.ChooseOneNamesHeader": "Name your skills", + "DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization", + "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.PickStatistics.Intro": "Choose how to set this agent's statistics before picking skills.", + "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", + "DG.ProfessionSetup.PickStatistics.AssignStats": "Assign Stats", + "DG.ProfessionSetup.PickStatistics.AssignStatsTooltip": "Divide 72 points between stats as desired.", + "DG.ProfessionSetup.PickStatistics.LeaveStatsAlone": "Leave my stats alone!", + "DG.ProfessionSetup.RollStats.Title": "Roll Stats", + "DG.ProfessionSetup.RollStats.PoolHeader": "Drag each rolled value onto a stat.", + "DG.ProfessionSetup.RollStats.Submit": "Submit", + "DG.ProfessionSetup.RollStats.ChatLabel": "Stat Rolls (4d6, drop lowest)", + "DG.ProfessionSetup.AssignStats.Title": "Assign Stats", + "DG.ProfessionSetup.AssignStats.PointsRemaining": "Points remaining: {remaining}", + "DG.ProfessionSetup.AssignStats.Submit": "Submit", + "DG.ItemWindow.Profession.Description": "Description", + "DG.ItemWindow.Profession.SkillsTab": "Skills", + "DG.ItemWindow.Profession.Name": "Profession Name", + "DG.ItemWindow.Profession.Bonds": "Bonds", + "DG.ItemWindow.Profession.BondsTooltip": "Number of bond slots granted when this profession is assigned to an agent (0–5).", + "DG.ItemWindow.Profession.AutomaticSkills": "Automatic Skills", + "DG.ItemWindow.Profession.OptionSkills": "Skill Options", + "DG.ItemWindow.Profession.OptionPicks": "Option Picks", + "DG.ItemWindow.Profession.AddSkill": "Add Skill", + "DG.ItemWindow.Profession.AddSkillTitle": "Add Skill to Profession", + "DG.ItemWindow.Profession.RemoveSkill": "Remove Skill", + "DG.ItemWindow.Profession.Skill": "Skill", + "DG.ItemWindow.Profession.SelectSkill": "Select a skill…", + "DG.ItemWindow.Profession.Rating": "Rating (%)", + "DG.ItemWindow.Profession.SkillType": "Skill Type", + "DG.ItemWindow.Profession.SkillTypePlaceholder": "e.g. German, Painting", + "DG.ItemWindow.Profession.TypedSkillRequired": "Enter a skill type before adding this skill.", + "DG.ItemWindow.Profession.ChooseOne": "Choose One", + "DG.ItemWindow.Profession.ChooseOneHint": "The player will name this skill when the profession is applied to an agent.", + "DG.ItemWindow.Profession.TypedSkillNameConflict": "Another automatic or option skill in this category already uses the name \"{name}\".", "DG.Gear.Tomes": "TOMES", "DG.Gear.Rituals": "RITUALS", - "DG.Tomes.Name": "Name", "DG.Ritual.Name": "Name", - "DG.Tome.Name": "Name", "DG.Tome.Language": "Language", "DG.Tome.StudyTime": "Study Time", @@ -440,15 +454,12 @@ "DG.ItemWindow.Tome.StudySanityDamage": "Study Sanity Damage", "DG.ItemWindow.Tome.HandlerOnlyTabHeader": "Handler Only", "DG.ItemWindow.Tome.HandlerNotes": "Handler Notes", - "DG.ItemWindow.Ritual.ActivationSanityDamage": "Activation SAN Damage", "DG.ItemWindow.Ritual.ActivationTime": "Activation Time", "DG.ItemWindow.Ritual.ActivationCosts": "Activation Costs", "DG.ItemWindow.Ritual.StudyTime": "Study Time", "DG.ItemWindow.Ritual.LearnedSanityDamage": "Learn SAN Damage", - "DG.MissionPaused": "Mission Paused", - "DG.Settings.charactersheet.name": "Character Sheet Style", "DG.Settings.charactersheet.hint": "Choose how actor sheets should be styled. 'Program' is a more modern government style, where 'Cowboy/Outlaw' is an older, grittier looking typewriter style.", "DG.Settings.charactersheet.cowboys": "The Cowboys", @@ -476,15 +487,22 @@ "DG.Settings.skillFailure.name": "Auto-mark Skill Failure", "DG.Settings.skillFailure.hint": "Activate this setting to always mark a failure checkbox upon failing a skill. The Handler can undo the action from the chat message for the roll.", "DG.Settings.Saved": "The current settings have been saved.", - "DG.SettingsMenu.automation.name": "Automation Menu", "DG.SettingsMenu.automation.label": "Automation Menu", "DG.SettingsMenu.automation.title": "Automation Settings", - "DG.SettingsMenu.handler.name": "Handler Settings", "DG.SettingsMenu.handler.label": "Handler Settings", "DG.SettingsMenu.handler.title": "Handler-only Settings", - "DG.Messages.Skillsmark.Marked": "This skill is marked", - "DG.Messages.Skillsmark.Tooltip": "See p. 29 Agent's Handbook" + "DG.Messages.Skillsmark.Tooltip": "See p. 29 Agent's Handbook", + "DG.Profession.Dialog.OptionPicksRequired": "Select exactly {picks} skill option(s).", + "DG.Profession.Dialog.BondNameRequired": "Enter a name for each bond.", + "DG.Profession.Dialog.BondRelationshipRequired": "Enter a relationship for each bond.", + "DG.Profession.Dialog.BonusSkillRequired": "Select all {count} bonus skills.", + "DG.Profession.Dialog.BonusSkillTypeRequired": "Enter a specialization for each typed bonus skill.", + "DG.Profession.Dialog.TypedNameRequired": "Enter a name for each {type} skill.", + "DG.Physical.Chat.ExhaustedLabel": "Exhausted", + "DG.Physical.Chat.RestedLabel": "Rest", + "DG.Physical.Chat.StimulantsRegularLabel": "Regular Stimulants", + "DG.Physical.Chat.StimulantsHardLabel": "Hard Drugs" } diff --git a/lang/es.json b/lang/es.json index c26a99f..5c3901f 100644 --- a/lang/es.json +++ b/lang/es.json @@ -33,6 +33,7 @@ "DG.ActiveEffects.TransferWhenEquipped": "Transferred effects apply to the agent only while this item is equipped.", "DG.AgentNameFieldTooltip": "Apellido, nombre, alias o nombre en código", "DG.AgentSheet.ProfessionPlaceholder": "Profesión (rango, si es necesario)", + "DG.AgentSheet.OpenProfessionToEdit": "Clic para editar la profesión", "DG.Attributes.HP": "Puntos de salud (PS)", "DG.Attributes.SAN": "Puntos de Cordura (COR)", "DG.Attributes.Tooltip.HP": "Los puntos de Salud representan la cantidad de daño que tu Agente puede soportar. Cuando resulte herido, resta el daño de sus PS. Cuando tu Agente llegue a 2 PS, queda inconsciente. Cuando tu Agente llegue a 0 PS, muere. Los puntos de Salud se pueden recuperar mediante reposo y atención médica.", @@ -167,7 +168,6 @@ "DG.ModifyDamageRollDialogue.Weapon": "Arma:", "DG.ModifySkillRollDialogue.ModifierTooltip": "Dar un modificador positivo o negativo aquí para sumar o restar al objetivo en la chequeo de habilidades.", "DG.ModifySkillRollDialogue.OriginalTarget": "Valor original", - "DG.ModifySkillRollDialogue.TargetModifier": "Modificador", "DG.ModifySkillRollDialogue.Test": "Tirada", "DG.ModifySkillRollDialogue.Title": "Modificar tirada", "DG.NPCSheet.BreakPointAbbreviation": "PR", @@ -221,8 +221,6 @@ "DG.Roll.Lethality": "Letalidad", "DG.Roll.Or": "O", "DG.Roll.Roll": "Tirar", - "DG.Roll.Rolling": "Tirando", - "DG.Roll.RollingDamageFor": "Tirando daño para", "DG.Roll.Success": "Éxito", "DG.Roll.Target": "Objetivo: ", "DG.RollLuck": "Tira Suerte", @@ -431,5 +429,76 @@ "TYPES.Item.motivation": "Motivación", "TYPES.Item.ritual": "Ritual", "TYPES.Item.tome": "Tomo", - "TYPES.Item.weapon": "Arma" + "TYPES.Item.weapon": "Arma", + "DG.ModifySkillRollDialogue.QuickModifiersHeader": "Modificadores rápidos", + "DG.ModifySkillRollDialogue.QuickModifiersHint": "Aplica un ajuste predefinido y tira inmediatamente.", + "DG.ModifySkillRollDialogue.ModifierMuchHarder": "Hace la tirada mucho más difícil", + "DG.ModifySkillRollDialogue.ModifierHarder": "Hace la tirada más difícil", + "DG.ModifySkillRollDialogue.ModifierEasier": "Hace la tirada más fácil", + "DG.ModifySkillRollDialogue.ModifierMuchEasier": "Hace la tirada mucho más fácil", + "DG.ModifySkillRollDialogue.CustomModifierHeader": "Modificador personalizado", + "DG.ModifySkillRollDialogue.CustomModifierHint": "Introduce un valor positivo o negativo para ajustar el objetivo.", + "DG.ModifySkillRollDialogue.InvalidModifier": "Introduce un modificador válido para tirar.", + "TYPES.Item.profession": "Profession", + "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", + "DG.Profession.AssignedHeader": "ASSIGNED PROFESSION ITEM", + "DG.Profession.Dialog.Title": "Pick Skills", + "DG.Profession.Dialog.Skill": "Skill", + "DG.Profession.Dialog.Value": "Value", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)", + "DG.Profession.Dialog.BonusSkillsHeader": "Bonus Skills", + "DG.Profession.Dialog.BondsHeader": "Bonds", + "DG.Profession.Dialog.Bond": "Bond", + "DG.Profession.Dialog.SelectSkill": "Select a skill…", + "DG.Profession.Dialog.SkillTypePlaceholder": "Type or specialization", + "DG.Profession.Dialog.Submit": "Submit", + "DG.Profession.Dialog.CapWarning": "{skill} is being raised to {attempted} but is capped at 80 during agent creation. This is wasting {waste} skill points.", + "DG.Profession.Dialog.DuplicateTypedName": "Duplicate {type} ({name}) detected. Please choose unique names.", + "DG.Profession.ChooseOnePlaceholder": "choose one", + "DG.Profession.Dialog.ChooseOneNamesHeader": "Name your skills", + "DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization", + "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.PickStatistics.Intro": "Elige cómo establecer las estadísticas de este agente antes de elegir habilidades.", + "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", + "DG.ProfessionSetup.PickStatistics.AssignStats": "Assign Stats", + "DG.ProfessionSetup.PickStatistics.AssignStatsTooltip": "Divide 72 points between stats as desired.", + "DG.ProfessionSetup.PickStatistics.LeaveStatsAlone": "Leave my stats alone!", + "DG.ProfessionSetup.RollStats.Title": "Roll Stats", + "DG.ProfessionSetup.RollStats.PoolHeader": "Drag each rolled value onto a stat.", + "DG.ProfessionSetup.RollStats.Submit": "Submit", + "DG.ProfessionSetup.RollStats.ChatLabel": "Tiradas de stats (4d6, descarta el más bajo)", + "DG.ProfessionSetup.AssignStats.Title": "Assign Stats", + "DG.ProfessionSetup.AssignStats.PointsRemaining": "Points remaining: {remaining}", + "DG.ProfessionSetup.AssignStats.Submit": "Submit", + "DG.ItemWindow.Profession.Description": "Description", + "DG.ItemWindow.Profession.SkillsTab": "Skills", + "DG.ItemWindow.Profession.Name": "Profession Name", + "DG.ItemWindow.Profession.Bonds": "Bonds", + "DG.ItemWindow.Profession.BondsTooltip": "Number of bond slots granted when this profession is assigned to an agent (0–5).", + "DG.ItemWindow.Profession.AutomaticSkills": "Automatic Skills", + "DG.ItemWindow.Profession.OptionSkills": "Skill Options", + "DG.ItemWindow.Profession.OptionPicks": "Option Picks", + "DG.ItemWindow.Profession.AddSkill": "Add Skill", + "DG.ItemWindow.Profession.AddSkillTitle": "Add Skill to Profession", + "DG.ItemWindow.Profession.RemoveSkill": "Remove Skill", + "DG.ItemWindow.Profession.Skill": "Skill", + "DG.ItemWindow.Profession.SelectSkill": "Select a skill…", + "DG.ItemWindow.Profession.Rating": "Rating (%)", + "DG.ItemWindow.Profession.SkillType": "Skill Type", + "DG.ItemWindow.Profession.SkillTypePlaceholder": "e.g. German, Painting", + "DG.ItemWindow.Profession.TypedSkillRequired": "Enter a skill type before adding this skill.", + "DG.ItemWindow.Profession.ChooseOne": "Choose One", + "DG.ItemWindow.Profession.ChooseOneHint": "The player will name this skill when the profession is applied to an agent.", + "DG.ItemWindow.Profession.TypedSkillNameConflict": "Another automatic or option skill in this category already uses the name \"{name}\".", + "DG.Profession.Dialog.OptionPicksRequired": "Select exactly {picks} skill option(s).", + "DG.Profession.Dialog.BondNameRequired": "Enter a name for each bond.", + "DG.Profession.Dialog.BondRelationshipRequired": "Enter a relationship for each bond.", + "DG.Profession.Dialog.BonusSkillRequired": "Select all {count} bonus skills.", + "DG.Profession.Dialog.BonusSkillTypeRequired": "Enter a specialization for each typed bonus skill.", + "DG.Profession.Dialog.TypedNameRequired": "Enter a name for each {type} skill.", + "DG.Physical.Chat.ExhaustedLabel": "Exhausted", + "DG.Physical.Chat.RestedLabel": "Rest", + "DG.Physical.Chat.StimulantsRegularLabel": "Regular Stimulants", + "DG.Physical.Chat.StimulantsHardLabel": "Hard Drugs" } diff --git a/lang/fr.json b/lang/fr.json index f668fa5..b9c551c 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -33,6 +33,7 @@ "DG.ActiveEffects.TransferWhenEquipped": "Transferred effects apply to the agent only while this item is equipped.", "DG.AgentNameFieldTooltip": "NOM, PRÉNOM, INITIALES", "DG.AgentSheet.ProfessionPlaceholder": "PROFESSION (RANG LE CAS ÉCHÉANT)", + "DG.AgentSheet.OpenProfessionToEdit": "Cliquer pour modifier la profession", "DG.Attributes.HP": "PV", "DG.Attributes.SAN": "SAN", "DG.Attributes.Tooltip.HP": "Points de vie, un personnage devient inconscient à 2PV et meurt à 0PV", @@ -167,7 +168,6 @@ "DG.ModifyDamageRollDialogue.Weapon": "Arme:", "DG.ModifySkillRollDialogue.ModifierTooltip": "Donnez ici un modificateur positif ou négatif à ajouter/soustraire à la cible pour le test de compétence.", "DG.ModifySkillRollDialogue.OriginalTarget": "Niveau actuel:", - "DG.ModifySkillRollDialogue.TargetModifier": "Modificateur (+/-):", "DG.ModifySkillRollDialogue.Test": "Test de:", "DG.ModifySkillRollDialogue.Title": "Modifier le Lancer", "DG.NPCSheet.BreakPointAbbreviation": "SR", @@ -221,8 +221,6 @@ "DG.Roll.Lethality": "Létalité", "DG.Roll.Or": "Ou", "DG.Roll.Roll": "Lancer", - "DG.Roll.Rolling": "Jet de dés", - "DG.Roll.RollingDamageFor": "Jets de dégâts pour", "DG.Roll.Success": "Succés", "DG.Roll.Target": "Cible: ", "DG.RollLuck": "Jet de Chance", @@ -431,5 +429,76 @@ "TYPES.Item.motivation": "Motivation", "TYPES.Item.ritual": "Rituel", "TYPES.Item.tome": "Ouvrage", - "TYPES.Item.weapon": "Arme" + "TYPES.Item.weapon": "Arme", + "DG.ModifySkillRollDialogue.QuickModifiersHeader": "Modificateurs rapides", + "DG.ModifySkillRollDialogue.QuickModifiersHint": "Appliquez un ajustement prédéfini et lancez immédiatement.", + "DG.ModifySkillRollDialogue.ModifierMuchHarder": "Rend le jet beaucoup plus difficile", + "DG.ModifySkillRollDialogue.ModifierHarder": "Rend le jet plus difficile", + "DG.ModifySkillRollDialogue.ModifierEasier": "Rend le jet plus facile", + "DG.ModifySkillRollDialogue.ModifierMuchEasier": "Rend le jet beaucoup plus facile", + "DG.ModifySkillRollDialogue.CustomModifierHeader": "Modificateur personnalisé", + "DG.ModifySkillRollDialogue.CustomModifierHint": "Entrez une valeur positive ou négative pour ajuster la cible.", + "DG.ModifySkillRollDialogue.InvalidModifier": "Entrez un modificateur valide pour lancer.", + "TYPES.Item.profession": "Profession", + "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", + "DG.Profession.AssignedHeader": "ASSIGNED PROFESSION ITEM", + "DG.Profession.Dialog.Title": "Pick Skills", + "DG.Profession.Dialog.Skill": "Skill", + "DG.Profession.Dialog.Value": "Value", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)", + "DG.Profession.Dialog.BonusSkillsHeader": "Bonus Skills", + "DG.Profession.Dialog.BondsHeader": "Bonds", + "DG.Profession.Dialog.Bond": "Bond", + "DG.Profession.Dialog.SelectSkill": "Select a skill…", + "DG.Profession.Dialog.SkillTypePlaceholder": "Type or specialization", + "DG.Profession.Dialog.Submit": "Submit", + "DG.Profession.Dialog.CapWarning": "{skill} is being raised to {attempted} but is capped at 80 during agent creation. This is wasting {waste} skill points.", + "DG.Profession.Dialog.DuplicateTypedName": "Duplicate {type} ({name}) detected. Please choose unique names.", + "DG.Profession.ChooseOnePlaceholder": "choose one", + "DG.Profession.Dialog.ChooseOneNamesHeader": "Name your skills", + "DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization", + "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.PickStatistics.Intro": "Choisissez comment définir les statistiques de cet agent avant de choisir les compétences.", + "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", + "DG.ProfessionSetup.PickStatistics.AssignStats": "Assign Stats", + "DG.ProfessionSetup.PickStatistics.AssignStatsTooltip": "Divide 72 points between stats as desired.", + "DG.ProfessionSetup.PickStatistics.LeaveStatsAlone": "Leave my stats alone!", + "DG.ProfessionSetup.RollStats.Title": "Roll Stats", + "DG.ProfessionSetup.RollStats.PoolHeader": "Drag each rolled value onto a stat.", + "DG.ProfessionSetup.RollStats.Submit": "Submit", + "DG.ProfessionSetup.RollStats.ChatLabel": "Jets de stats (4d6, jetez le plus bas)", + "DG.ProfessionSetup.AssignStats.Title": "Assign Stats", + "DG.ProfessionSetup.AssignStats.PointsRemaining": "Points remaining: {remaining}", + "DG.ProfessionSetup.AssignStats.Submit": "Submit", + "DG.ItemWindow.Profession.Description": "Description", + "DG.ItemWindow.Profession.SkillsTab": "Skills", + "DG.ItemWindow.Profession.Name": "Profession Name", + "DG.ItemWindow.Profession.Bonds": "Bonds", + "DG.ItemWindow.Profession.BondsTooltip": "Number of bond slots granted when this profession is assigned to an agent (0–5).", + "DG.ItemWindow.Profession.AutomaticSkills": "Automatic Skills", + "DG.ItemWindow.Profession.OptionSkills": "Skill Options", + "DG.ItemWindow.Profession.OptionPicks": "Option Picks", + "DG.ItemWindow.Profession.AddSkill": "Add Skill", + "DG.ItemWindow.Profession.AddSkillTitle": "Add Skill to Profession", + "DG.ItemWindow.Profession.RemoveSkill": "Remove Skill", + "DG.ItemWindow.Profession.Skill": "Skill", + "DG.ItemWindow.Profession.SelectSkill": "Select a skill…", + "DG.ItemWindow.Profession.Rating": "Rating (%)", + "DG.ItemWindow.Profession.SkillType": "Skill Type", + "DG.ItemWindow.Profession.SkillTypePlaceholder": "e.g. German, Painting", + "DG.ItemWindow.Profession.TypedSkillRequired": "Enter a skill type before adding this skill.", + "DG.ItemWindow.Profession.ChooseOne": "Choose One", + "DG.ItemWindow.Profession.ChooseOneHint": "The player will name this skill when the profession is applied to an agent.", + "DG.ItemWindow.Profession.TypedSkillNameConflict": "Another automatic or option skill in this category already uses the name \"{name}\".", + "DG.Profession.Dialog.OptionPicksRequired": "Select exactly {picks} skill option(s).", + "DG.Profession.Dialog.BondNameRequired": "Enter a name for each bond.", + "DG.Profession.Dialog.BondRelationshipRequired": "Enter a relationship for each bond.", + "DG.Profession.Dialog.BonusSkillRequired": "Select all {count} bonus skills.", + "DG.Profession.Dialog.BonusSkillTypeRequired": "Enter a specialization for each typed bonus skill.", + "DG.Profession.Dialog.TypedNameRequired": "Enter a name for each {type} skill.", + "DG.Physical.Chat.ExhaustedLabel": "Exhausted", + "DG.Physical.Chat.RestedLabel": "Rest", + "DG.Physical.Chat.StimulantsRegularLabel": "Regular Stimulants", + "DG.Physical.Chat.StimulantsHardLabel": "Hard Drugs" } diff --git a/lang/ja.json b/lang/ja.json index b329b72..a5a2c5c 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -152,7 +152,6 @@ "DG.ModifyDamageRollDialogue.Weapon": "武器:", "DG.ModifySkillRollDialogue.ModifierTooltip": "技能チェックの目標値にプラスまたはマイナスの修正を加える。", "DG.ModifySkillRollDialogue.OriginalTarget": "元の目標値:", - "DG.ModifySkillRollDialogue.TargetModifier": "目標値修正:", "DG.ModifySkillRollDialogue.Test": "テスト:", "DG.ModifySkillRollDialogue.Title": "ロールの修正", "DG.NPCSheet.BreakPointAbbreviation": "限界点", @@ -207,8 +206,6 @@ "DG.Roll.Lethality": "致命率", "DG.Roll.Or": " / ", "DG.Roll.Roll": "ロール", - "DG.Roll.Rolling": "ローリング", - "DG.Roll.RollingDamageFor": "ダメージロール: ", "DG.Roll.Success": "成功", "DG.Roll.Target": "目標値: ", "DG.RollLuck": "幸運ロール", @@ -373,5 +370,76 @@ "TYPES.Item.motivation": "モチベーション", "TYPES.Item.ritual": "儀式", "TYPES.Item.tome": "魔道書", - "TYPES.Item.weapon": "武器" + "TYPES.Item.weapon": "武器", + "DG.ModifySkillRollDialogue.QuickModifiersHeader": "クイック修正", + "DG.ModifySkillRollDialogue.QuickModifiersHint": "プリセットの修正を適用してすぐにロールします。", + "DG.ModifySkillRollDialogue.ModifierMuchHarder": "ロールを大幅に難しくする", + "DG.ModifySkillRollDialogue.ModifierHarder": "ロールを難しくする", + "DG.ModifySkillRollDialogue.ModifierEasier": "ロールを易しくする", + "DG.ModifySkillRollDialogue.ModifierMuchEasier": "ロールを大幅に易しくする", + "DG.ModifySkillRollDialogue.CustomModifierHeader": "カスタム修正", + "DG.ModifySkillRollDialogue.CustomModifierHint": "正または負の値を入力して目標値を調整します。", + "DG.ModifySkillRollDialogue.InvalidModifier": "有効な修正値を入力してからロールしてください。", + "TYPES.Item.profession": "職業", + "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", + "DG.Profession.AssignedHeader": "ASSIGNED PROFESSION ITEM", + "DG.Profession.Dialog.Title": "Pick Skills", + "DG.Profession.Dialog.Skill": "Skill", + "DG.Profession.Dialog.Value": "Value", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)", + "DG.Profession.Dialog.BonusSkillsHeader": "Bonus Skills", + "DG.Profession.Dialog.BondsHeader": "Bonds", + "DG.Profession.Dialog.Bond": "Bond", + "DG.Profession.Dialog.SelectSkill": "Select a skill…", + "DG.Profession.Dialog.SkillTypePlaceholder": "Type or specialization", + "DG.Profession.Dialog.Submit": "Submit", + "DG.Profession.Dialog.CapWarning": "{skill} is being raised to {attempted} but is capped at 80 during agent creation. This is wasting {waste} skill points.", + "DG.Profession.Dialog.DuplicateTypedName": "Duplicate {type} ({name}) detected. Please choose unique names.", + "DG.Profession.ChooseOnePlaceholder": "choose one", + "DG.Profession.Dialog.ChooseOneNamesHeader": "Name your skills", + "DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization", + "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.PickStatistics.Intro": "技能を選ぶ前に、このエージェントの能力値をどう設定するか選んでください。", + "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", + "DG.ProfessionSetup.PickStatistics.AssignStats": "Assign Stats", + "DG.ProfessionSetup.PickStatistics.AssignStatsTooltip": "Divide 72 points between stats as desired.", + "DG.ProfessionSetup.PickStatistics.LeaveStatsAlone": "Leave my stats alone!", + "DG.ProfessionSetup.RollStats.Title": "Roll Stats", + "DG.ProfessionSetup.RollStats.PoolHeader": "Drag each rolled value onto a stat.", + "DG.ProfessionSetup.RollStats.Submit": "Submit", + "DG.ProfessionSetup.RollStats.ChatLabel": "能力値ロール(4d6、最低値を除外)", + "DG.ProfessionSetup.AssignStats.Title": "Assign Stats", + "DG.ProfessionSetup.AssignStats.PointsRemaining": "Points remaining: {remaining}", + "DG.ProfessionSetup.AssignStats.Submit": "Submit", + "DG.ItemWindow.Profession.Description": "Description", + "DG.ItemWindow.Profession.SkillsTab": "Skills", + "DG.ItemWindow.Profession.Name": "Profession Name", + "DG.ItemWindow.Profession.Bonds": "Bonds", + "DG.ItemWindow.Profession.BondsTooltip": "Number of bond slots granted when this profession is assigned to an agent (0–5).", + "DG.ItemWindow.Profession.AutomaticSkills": "Automatic Skills", + "DG.ItemWindow.Profession.OptionSkills": "Skill Options", + "DG.ItemWindow.Profession.OptionPicks": "Option Picks", + "DG.ItemWindow.Profession.AddSkill": "Add Skill", + "DG.ItemWindow.Profession.AddSkillTitle": "Add Skill to Profession", + "DG.ItemWindow.Profession.RemoveSkill": "Remove Skill", + "DG.ItemWindow.Profession.Skill": "Skill", + "DG.ItemWindow.Profession.SelectSkill": "Select a skill…", + "DG.ItemWindow.Profession.Rating": "Rating (%)", + "DG.ItemWindow.Profession.SkillType": "Skill Type", + "DG.ItemWindow.Profession.SkillTypePlaceholder": "e.g. German, Painting", + "DG.ItemWindow.Profession.TypedSkillRequired": "Enter a skill type before adding this skill.", + "DG.ItemWindow.Profession.ChooseOne": "Choose One", + "DG.ItemWindow.Profession.ChooseOneHint": "The player will name this skill when the profession is applied to an agent.", + "DG.ItemWindow.Profession.TypedSkillNameConflict": "Another automatic or option skill in this category already uses the name \"{name}\".", + "DG.Profession.Dialog.OptionPicksRequired": "Select exactly {picks} skill option(s).", + "DG.Profession.Dialog.BondNameRequired": "Enter a name for each bond.", + "DG.Profession.Dialog.BondRelationshipRequired": "Enter a relationship for each bond.", + "DG.Profession.Dialog.BonusSkillRequired": "Select all {count} bonus skills.", + "DG.Profession.Dialog.BonusSkillTypeRequired": "Enter a specialization for each typed bonus skill.", + "DG.Profession.Dialog.TypedNameRequired": "Enter a name for each {type} skill.", + "DG.Physical.Chat.ExhaustedLabel": "Exhausted", + "DG.Physical.Chat.RestedLabel": "Rest", + "DG.Physical.Chat.StimulantsRegularLabel": "Regular Stimulants", + "DG.Physical.Chat.StimulantsHardLabel": "Hard Drugs" } diff --git a/lang/pl.json b/lang/pl.json index 57e6ad6..209148f 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -33,6 +33,7 @@ "DG.ActiveEffects.TransferWhenEquipped": "Transferred effects apply to the agent only while this item is equipped.", "DG.AgentNameFieldTooltip": "NAZWISKO, IMIE (PSEUDONIM/KRYPTONIM, JESLI DOTYCZY)", "DG.AgentSheet.ProfessionPlaceholder": "ZAWÓD (RANGA, JEŚLI DOTYCZY)", + "DG.AgentSheet.OpenProfessionToEdit": "Kliknij, aby edytować zawód", "DG.Attributes.HP": "PW", "DG.Attributes.SAN": "PP", "DG.Attributes.Tooltip.HP": "Punkty Wytrzymałości, postać traci przytomność przy 2 PW i umiera przy 0 PW.", @@ -165,7 +166,6 @@ "DG.ModifyDamageRollDialogue.Weapon": "Broń:", "DG.ModifySkillRollDialogue.ModifierTooltip": "Podaj dodatni lub ujemny modyfikator, aby dodać/odjąć od celu dla testu umiejętności.", "DG.ModifySkillRollDialogue.OriginalTarget": "Oryginalny Cel:", - "DG.ModifySkillRollDialogue.TargetModifier": "Modyfikator Celu:", "DG.ModifySkillRollDialogue.Test": "Test:", "DG.ModifySkillRollDialogue.Title": "Modyfikuj Rzut", "DG.NPCSheet.BreakPointAbbreviation": "BP", @@ -219,8 +219,6 @@ "DG.Roll.Lethality": "Śmiertelność", "DG.Roll.Or": "Lub", "DG.Roll.Roll": "Rzuć", - "DG.Roll.Rolling": "Rzut na", - "DG.Roll.RollingDamageFor": "Rzut na Obrażenia", "DG.Roll.Success": "Sukces", "DG.Roll.Target": "Cel: ", "DG.RollLuck": "Test Szczęścia", @@ -426,5 +424,76 @@ "TYPES.Item.motivation": "Motywacja", "TYPES.Item.ritual": "Rytuał", "TYPES.Item.tome": "Księga", - "TYPES.Item.weapon": "Broń" + "TYPES.Item.weapon": "Broń", + "DG.ModifySkillRollDialogue.QuickModifiersHeader": "Szybkie modyfikatory", + "DG.ModifySkillRollDialogue.QuickModifiersHint": "Zastosuj ustawiony modyfikator i rzuć natychmiast.", + "DG.ModifySkillRollDialogue.ModifierMuchHarder": "Znacznie utrudnia rzut", + "DG.ModifySkillRollDialogue.ModifierHarder": "Utrudnia rzut", + "DG.ModifySkillRollDialogue.ModifierEasier": "Ułatwia rzut", + "DG.ModifySkillRollDialogue.ModifierMuchEasier": "Znacznie ułatwia rzut", + "DG.ModifySkillRollDialogue.CustomModifierHeader": "Własny modyfikator", + "DG.ModifySkillRollDialogue.CustomModifierHint": "Wprowadź wartość dodatnią lub ujemną, aby dostosować cel.", + "DG.ModifySkillRollDialogue.InvalidModifier": "Wprowadź prawidłowy modyfikator, aby rzucić.", + "TYPES.Item.profession": "Zawód", + "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", + "DG.Profession.AssignedHeader": "ASSIGNED PROFESSION ITEM", + "DG.Profession.Dialog.Title": "Pick Skills", + "DG.Profession.Dialog.Skill": "Skill", + "DG.Profession.Dialog.Value": "Value", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)", + "DG.Profession.Dialog.BonusSkillsHeader": "Bonus Skills", + "DG.Profession.Dialog.BondsHeader": "Bonds", + "DG.Profession.Dialog.Bond": "Bond", + "DG.Profession.Dialog.SelectSkill": "Select a skill…", + "DG.Profession.Dialog.SkillTypePlaceholder": "Type or specialization", + "DG.Profession.Dialog.Submit": "Submit", + "DG.Profession.Dialog.CapWarning": "{skill} is being raised to {attempted} but is capped at 80 during agent creation. This is wasting {waste} skill points.", + "DG.Profession.Dialog.DuplicateTypedName": "Duplicate {type} ({name}) detected. Please choose unique names.", + "DG.Profession.ChooseOnePlaceholder": "choose one", + "DG.Profession.Dialog.ChooseOneNamesHeader": "Name your skills", + "DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization", + "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.PickStatistics.Intro": "Wybierz, jak ustawić statystyki tego agenta przed wyborem umiejętności.", + "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", + "DG.ProfessionSetup.PickStatistics.AssignStats": "Assign Stats", + "DG.ProfessionSetup.PickStatistics.AssignStatsTooltip": "Divide 72 points between stats as desired.", + "DG.ProfessionSetup.PickStatistics.LeaveStatsAlone": "Leave my stats alone!", + "DG.ProfessionSetup.RollStats.Title": "Roll Stats", + "DG.ProfessionSetup.RollStats.PoolHeader": "Drag each rolled value onto a stat.", + "DG.ProfessionSetup.RollStats.Submit": "Submit", + "DG.ProfessionSetup.RollStats.ChatLabel": "Rzuty na statystyki (4k6, odrzuć najniższy)", + "DG.ProfessionSetup.AssignStats.Title": "Assign Stats", + "DG.ProfessionSetup.AssignStats.PointsRemaining": "Points remaining: {remaining}", + "DG.ProfessionSetup.AssignStats.Submit": "Submit", + "DG.ItemWindow.Profession.Description": "Description", + "DG.ItemWindow.Profession.SkillsTab": "Skills", + "DG.ItemWindow.Profession.Name": "Profession Name", + "DG.ItemWindow.Profession.Bonds": "Bonds", + "DG.ItemWindow.Profession.BondsTooltip": "Number of bond slots granted when this profession is assigned to an agent (0–5).", + "DG.ItemWindow.Profession.AutomaticSkills": "Automatic Skills", + "DG.ItemWindow.Profession.OptionSkills": "Skill Options", + "DG.ItemWindow.Profession.OptionPicks": "Option Picks", + "DG.ItemWindow.Profession.AddSkill": "Add Skill", + "DG.ItemWindow.Profession.AddSkillTitle": "Add Skill to Profession", + "DG.ItemWindow.Profession.RemoveSkill": "Remove Skill", + "DG.ItemWindow.Profession.Skill": "Skill", + "DG.ItemWindow.Profession.SelectSkill": "Select a skill…", + "DG.ItemWindow.Profession.Rating": "Rating (%)", + "DG.ItemWindow.Profession.SkillType": "Skill Type", + "DG.ItemWindow.Profession.SkillTypePlaceholder": "e.g. German, Painting", + "DG.ItemWindow.Profession.TypedSkillRequired": "Enter a skill type before adding this skill.", + "DG.ItemWindow.Profession.ChooseOne": "Choose One", + "DG.ItemWindow.Profession.ChooseOneHint": "The player will name this skill when the profession is applied to an agent.", + "DG.ItemWindow.Profession.TypedSkillNameConflict": "Another automatic or option skill in this category already uses the name \"{name}\".", + "DG.Profession.Dialog.OptionPicksRequired": "Select exactly {picks} skill option(s).", + "DG.Profession.Dialog.BondNameRequired": "Enter a name for each bond.", + "DG.Profession.Dialog.BondRelationshipRequired": "Enter a relationship for each bond.", + "DG.Profession.Dialog.BonusSkillRequired": "Select all {count} bonus skills.", + "DG.Profession.Dialog.BonusSkillTypeRequired": "Enter a specialization for each typed bonus skill.", + "DG.Profession.Dialog.TypedNameRequired": "Enter a name for each {type} skill.", + "DG.Physical.Chat.ExhaustedLabel": "Exhausted", + "DG.Physical.Chat.RestedLabel": "Rest", + "DG.Physical.Chat.StimulantsRegularLabel": "Regular Stimulants", + "DG.Physical.Chat.StimulantsHardLabel": "Hard Drugs" } diff --git a/lang/pt-BR.json b/lang/pt-BR.json index 059a74f..74fd479 100644 --- a/lang/pt-BR.json +++ b/lang/pt-BR.json @@ -33,6 +33,7 @@ "DG.ActiveEffects.TransferWhenEquipped": "Transferred effects apply to the agent only while this item is equipped.", "DG.AgentNameFieldTooltip": "SOBRENOME, NOME (OU PSEUDÔNIMO)", "DG.AgentSheet.ProfessionPlaceholder": "PROFISSÂO", + "DG.AgentSheet.OpenProfessionToEdit": "Clique para editar a profissão", "DG.Attributes.HP": "PV", "DG.Attributes.SAN": "SAN", "DG.Attributes.Tooltip.HP": "Pontos de Vida (PV) indicam a saúde física. Um personagem fica inconsciente com 2 PV e morre ao chegar a 0 PV.", @@ -167,7 +168,6 @@ "DG.ModifyDamageRollDialogue.Weapon": "Arma:", "DG.ModifySkillRollDialogue.ModifierTooltip": "Dê um modificador positivo ou negativo aqui para adicionar/subtrair do alvo para o teste de perícia.", "DG.ModifySkillRollDialogue.OriginalTarget": "Alvo Original:", - "DG.ModifySkillRollDialogue.TargetModifier": "Modificar Alvo (+/-):", "DG.ModifySkillRollDialogue.Test": "Teste:", "DG.ModifySkillRollDialogue.Title": "Modificar Rolagem", "DG.NPCSheet.BreakPointAbbreviation": "PR", @@ -221,8 +221,6 @@ "DG.Roll.Lethality": "Letalidade", "DG.Roll.Or": "Ou", "DG.Roll.Roll": "Rolar", - "DG.Roll.Rolling": "Rolando", - "DG.Roll.RollingDamageFor": "Rolando DANO para", "DG.Roll.Success": "Sucesso", "DG.Roll.Target": "Alvo: ", "DG.RollLuck": "Rolar Sorte", @@ -431,5 +429,76 @@ "TYPES.Item.motivation": "Motivação", "TYPES.Item.ritual": "Ritual", "TYPES.Item.tome": "Tomo", - "TYPES.Item.weapon": "Arma" + "TYPES.Item.weapon": "Arma", + "DG.ModifySkillRollDialogue.QuickModifiersHeader": "Modificadores rápidos", + "DG.ModifySkillRollDialogue.QuickModifiersHint": "Aplique um ajuste predefinido e role imediatamente.", + "DG.ModifySkillRollDialogue.ModifierMuchHarder": "Torna a rolagem muito mais difícil", + "DG.ModifySkillRollDialogue.ModifierHarder": "Torna a rolagem mais difícil", + "DG.ModifySkillRollDialogue.ModifierEasier": "Torna a rolagem mais fácil", + "DG.ModifySkillRollDialogue.ModifierMuchEasier": "Torna a rolagem muito mais fácil", + "DG.ModifySkillRollDialogue.CustomModifierHeader": "Modificador personalizado", + "DG.ModifySkillRollDialogue.CustomModifierHint": "Digite um valor positivo ou negativo para ajustar o alvo.", + "DG.ModifySkillRollDialogue.InvalidModifier": "Digite um modificador válido para rolar.", + "TYPES.Item.profession": "Profession", + "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", + "DG.Profession.AssignedHeader": "ASSIGNED PROFESSION ITEM", + "DG.Profession.Dialog.Title": "Pick Skills", + "DG.Profession.Dialog.Skill": "Skill", + "DG.Profession.Dialog.Value": "Value", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)", + "DG.Profession.Dialog.BonusSkillsHeader": "Bonus Skills", + "DG.Profession.Dialog.BondsHeader": "Bonds", + "DG.Profession.Dialog.Bond": "Bond", + "DG.Profession.Dialog.SelectSkill": "Select a skill…", + "DG.Profession.Dialog.SkillTypePlaceholder": "Type or specialization", + "DG.Profession.Dialog.Submit": "Submit", + "DG.Profession.Dialog.CapWarning": "{skill} is being raised to {attempted} but is capped at 80 during agent creation. This is wasting {waste} skill points.", + "DG.Profession.Dialog.DuplicateTypedName": "Duplicate {type} ({name}) detected. Please choose unique names.", + "DG.Profession.ChooseOnePlaceholder": "choose one", + "DG.Profession.Dialog.ChooseOneNamesHeader": "Name your skills", + "DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization", + "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.PickStatistics.Intro": "Escolha como definir as estatísticas deste agente antes de escolher perícias.", + "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", + "DG.ProfessionSetup.PickStatistics.AssignStats": "Assign Stats", + "DG.ProfessionSetup.PickStatistics.AssignStatsTooltip": "Divide 72 points between stats as desired.", + "DG.ProfessionSetup.PickStatistics.LeaveStatsAlone": "Leave my stats alone!", + "DG.ProfessionSetup.RollStats.Title": "Roll Stats", + "DG.ProfessionSetup.RollStats.PoolHeader": "Drag each rolled value onto a stat.", + "DG.ProfessionSetup.RollStats.Submit": "Submit", + "DG.ProfessionSetup.RollStats.ChatLabel": "Rolagens de stats (4d6, descarte o menor)", + "DG.ProfessionSetup.AssignStats.Title": "Assign Stats", + "DG.ProfessionSetup.AssignStats.PointsRemaining": "Points remaining: {remaining}", + "DG.ProfessionSetup.AssignStats.Submit": "Submit", + "DG.ItemWindow.Profession.Description": "Description", + "DG.ItemWindow.Profession.SkillsTab": "Skills", + "DG.ItemWindow.Profession.Name": "Profession Name", + "DG.ItemWindow.Profession.Bonds": "Bonds", + "DG.ItemWindow.Profession.BondsTooltip": "Number of bond slots granted when this profession is assigned to an agent (0–5).", + "DG.ItemWindow.Profession.AutomaticSkills": "Automatic Skills", + "DG.ItemWindow.Profession.OptionSkills": "Skill Options", + "DG.ItemWindow.Profession.OptionPicks": "Option Picks", + "DG.ItemWindow.Profession.AddSkill": "Add Skill", + "DG.ItemWindow.Profession.AddSkillTitle": "Add Skill to Profession", + "DG.ItemWindow.Profession.RemoveSkill": "Remove Skill", + "DG.ItemWindow.Profession.Skill": "Skill", + "DG.ItemWindow.Profession.SelectSkill": "Select a skill…", + "DG.ItemWindow.Profession.Rating": "Rating (%)", + "DG.ItemWindow.Profession.SkillType": "Skill Type", + "DG.ItemWindow.Profession.SkillTypePlaceholder": "e.g. German, Painting", + "DG.ItemWindow.Profession.TypedSkillRequired": "Enter a skill type before adding this skill.", + "DG.ItemWindow.Profession.ChooseOne": "Choose One", + "DG.ItemWindow.Profession.ChooseOneHint": "The player will name this skill when the profession is applied to an agent.", + "DG.ItemWindow.Profession.TypedSkillNameConflict": "Another automatic or option skill in this category already uses the name \"{name}\".", + "DG.Profession.Dialog.OptionPicksRequired": "Select exactly {picks} skill option(s).", + "DG.Profession.Dialog.BondNameRequired": "Enter a name for each bond.", + "DG.Profession.Dialog.BondRelationshipRequired": "Enter a relationship for each bond.", + "DG.Profession.Dialog.BonusSkillRequired": "Select all {count} bonus skills.", + "DG.Profession.Dialog.BonusSkillTypeRequired": "Enter a specialization for each typed bonus skill.", + "DG.Profession.Dialog.TypedNameRequired": "Enter a name for each {type} skill.", + "DG.Physical.Chat.ExhaustedLabel": "Exhausted", + "DG.Physical.Chat.RestedLabel": "Rest", + "DG.Physical.Chat.StimulantsRegularLabel": "Regular Stimulants", + "DG.Physical.Chat.StimulantsHardLabel": "Hard Drugs" } diff --git a/lang/sv.json b/lang/sv.json index ac92f4f..bb6ef23 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -152,7 +152,6 @@ "DG.ModifyDamageRollDialogue.Weapon": "Vapen:", "DG.ModifySkillRollDialogue.ModifierTooltip": "Ange en positiv eller negativ modifiering här.", "DG.ModifySkillRollDialogue.OriginalTarget": "Ursprungligt mål:", - "DG.ModifySkillRollDialogue.TargetModifier": "Modifiera mål:", "DG.ModifySkillRollDialogue.Test": "Slag:", "DG.ModifySkillRollDialogue.Title": "Modifiera kast", "DG.NPCSheet.BreakPointAbbreviation": "BP", @@ -207,8 +206,6 @@ "DG.Roll.Lethality": "Dödlighet", "DG.Roll.Or": "Eller", "DG.Roll.Roll": "Slå", - "DG.Roll.Rolling": "Slår för", - "DG.Roll.RollingDamageFor": "Slår SKADA för", "DG.Roll.Success": "Träff", "DG.Roll.Target": "Mål: ", "DG.RollLuck": "Turkast", @@ -333,5 +330,76 @@ "TYPES.Item.motivation": "Motivation", "TYPES.Item.ritual": "Ritual", "TYPES.Item.tome": "Trollbok", - "TYPES.Item.weapon": "Vapen" + "TYPES.Item.weapon": "Vapen", + "DG.ModifySkillRollDialogue.QuickModifiersHeader": "Snabbmodifieringar", + "DG.ModifySkillRollDialogue.QuickModifiersHint": "Tillämpa en förinställd justering och slå omedelbart.", + "DG.ModifySkillRollDialogue.ModifierMuchHarder": "Gör slaget mycket svårare", + "DG.ModifySkillRollDialogue.ModifierHarder": "Gör slaget svårare", + "DG.ModifySkillRollDialogue.ModifierEasier": "Gör slaget enklare", + "DG.ModifySkillRollDialogue.ModifierMuchEasier": "Gör slaget mycket enklare", + "DG.ModifySkillRollDialogue.CustomModifierHeader": "Anpassad modifiering", + "DG.ModifySkillRollDialogue.CustomModifierHint": "Ange ett positivt eller negativt värde för att justera målet.", + "DG.ModifySkillRollDialogue.InvalidModifier": "Ange en giltig modifiering för att slå.", + "TYPES.Item.profession": "Profession", + "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", + "DG.Profession.AssignedHeader": "ASSIGNED PROFESSION ITEM", + "DG.Profession.Dialog.Title": "Pick Skills", + "DG.Profession.Dialog.Skill": "Skill", + "DG.Profession.Dialog.Value": "Value", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)", + "DG.Profession.Dialog.BonusSkillsHeader": "Bonus Skills", + "DG.Profession.Dialog.BondsHeader": "Bonds", + "DG.Profession.Dialog.Bond": "Bond", + "DG.Profession.Dialog.SelectSkill": "Select a skill…", + "DG.Profession.Dialog.SkillTypePlaceholder": "Type or specialization", + "DG.Profession.Dialog.Submit": "Submit", + "DG.Profession.Dialog.CapWarning": "{skill} is being raised to {attempted} but is capped at 80 during agent creation. This is wasting {waste} skill points.", + "DG.Profession.Dialog.DuplicateTypedName": "Duplicate {type} ({name}) detected. Please choose unique names.", + "DG.Profession.ChooseOnePlaceholder": "choose one", + "DG.Profession.Dialog.ChooseOneNamesHeader": "Name your skills", + "DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization", + "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.PickStatistics.Intro": "Välj hur du vill sätta denna agents statistik innan du väljer färdigheter.", + "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", + "DG.ProfessionSetup.PickStatistics.AssignStats": "Assign Stats", + "DG.ProfessionSetup.PickStatistics.AssignStatsTooltip": "Divide 72 points between stats as desired.", + "DG.ProfessionSetup.PickStatistics.LeaveStatsAlone": "Leave my stats alone!", + "DG.ProfessionSetup.RollStats.Title": "Roll Stats", + "DG.ProfessionSetup.RollStats.PoolHeader": "Drag each rolled value onto a stat.", + "DG.ProfessionSetup.RollStats.Submit": "Submit", + "DG.ProfessionSetup.RollStats.ChatLabel": "Stat-slag (4t6, slå bort lägsta)", + "DG.ProfessionSetup.AssignStats.Title": "Assign Stats", + "DG.ProfessionSetup.AssignStats.PointsRemaining": "Points remaining: {remaining}", + "DG.ProfessionSetup.AssignStats.Submit": "Submit", + "DG.ItemWindow.Profession.Description": "Description", + "DG.ItemWindow.Profession.SkillsTab": "Skills", + "DG.ItemWindow.Profession.Name": "Profession Name", + "DG.ItemWindow.Profession.Bonds": "Bonds", + "DG.ItemWindow.Profession.BondsTooltip": "Number of bond slots granted when this profession is assigned to an agent (0–5).", + "DG.ItemWindow.Profession.AutomaticSkills": "Automatic Skills", + "DG.ItemWindow.Profession.OptionSkills": "Skill Options", + "DG.ItemWindow.Profession.OptionPicks": "Option Picks", + "DG.ItemWindow.Profession.AddSkill": "Add Skill", + "DG.ItemWindow.Profession.AddSkillTitle": "Add Skill to Profession", + "DG.ItemWindow.Profession.RemoveSkill": "Remove Skill", + "DG.ItemWindow.Profession.Skill": "Skill", + "DG.ItemWindow.Profession.SelectSkill": "Select a skill…", + "DG.ItemWindow.Profession.Rating": "Rating (%)", + "DG.ItemWindow.Profession.SkillType": "Skill Type", + "DG.ItemWindow.Profession.SkillTypePlaceholder": "e.g. German, Painting", + "DG.ItemWindow.Profession.TypedSkillRequired": "Enter a skill type before adding this skill.", + "DG.ItemWindow.Profession.ChooseOne": "Choose One", + "DG.ItemWindow.Profession.ChooseOneHint": "The player will name this skill when the profession is applied to an agent.", + "DG.ItemWindow.Profession.TypedSkillNameConflict": "Another automatic or option skill in this category already uses the name \"{name}\".", + "DG.Profession.Dialog.OptionPicksRequired": "Select exactly {picks} skill option(s).", + "DG.Profession.Dialog.BondNameRequired": "Enter a name for each bond.", + "DG.Profession.Dialog.BondRelationshipRequired": "Enter a relationship for each bond.", + "DG.Profession.Dialog.BonusSkillRequired": "Select all {count} bonus skills.", + "DG.Profession.Dialog.BonusSkillTypeRequired": "Enter a specialization for each typed bonus skill.", + "DG.Profession.Dialog.TypedNameRequired": "Enter a name for each {type} skill.", + "DG.Physical.Chat.ExhaustedLabel": "Exhausted", + "DG.Physical.Chat.RestedLabel": "Rest", + "DG.Physical.Chat.StimulantsRegularLabel": "Regular Stimulants", + "DG.Physical.Chat.StimulantsHardLabel": "Hard Drugs" } diff --git a/lang/uk.json b/lang/uk.json index b0316a8..e6dc9b7 100644 --- a/lang/uk.json +++ b/lang/uk.json @@ -33,6 +33,7 @@ "DG.ActiveEffects.TransferWhenEquipped": "Transferred effects apply to the agent only while this item is equipped.", "DG.AgentNameFieldTooltip": "ПРІЗВИЩЕ, ІМ’Я (ТА ПСЕВДОНІМ, ЯКЩО Є)", "DG.AgentSheet.ProfessionPlaceholder": "ПРОФЕСІЯ (ЗВАННЯ, ЯКЩО Є)", + "DG.AgentSheet.OpenProfessionToEdit": "Натисніть, щоб редагувати професію", "DG.Attributes.HP": "ПЗ", "DG.Attributes.SAN": "ГЗД", "DG.Attributes.Tooltip.HP": "Пункти Здоров’я (ПЗ) показують фізичний стан. Персонаж непритомніє при 2 ПЗ і помирає при 0 ПЗ.", @@ -167,7 +168,6 @@ "DG.ModifyDamageRollDialogue.Weapon": "Зброя:", "DG.ModifySkillRollDialogue.ModifierTooltip": "Вкажи позитивний або негативний модифікатор, щоб додати/відняти від цілі для перевірки навички.", "DG.ModifySkillRollDialogue.OriginalTarget": "Початкова ціль:", - "DG.ModifySkillRollDialogue.TargetModifier": "Змінити ціль:", "DG.ModifySkillRollDialogue.Test": "Тест:", "DG.ModifySkillRollDialogue.Title": "Змінити кидок", "DG.NPCSheet.BreakPointAbbreviation": "ТЗ", @@ -221,8 +221,6 @@ "DG.Roll.Lethality": "Летальність", "DG.Roll.Or": "Або", "DG.Roll.Roll": "Кинути", - "DG.Roll.Rolling": "Кидаємо...", - "DG.Roll.RollingDamageFor": "Кидок ШКОДИ для", "DG.Roll.Success": "Успіх", "DG.Roll.Target": "Ціль: ", "DG.RollLuck": "Кинути Удачу", @@ -431,5 +429,76 @@ "TYPES.Item.motivation": "Мотивація", "TYPES.Item.ritual": "Ритуал", "TYPES.Item.tome": "Том", - "TYPES.Item.weapon": "Зброя" + "TYPES.Item.weapon": "Зброя", + "DG.ModifySkillRollDialogue.QuickModifiersHeader": "Швидкі модифікатори", + "DG.ModifySkillRollDialogue.QuickModifiersHint": "Застосуй готове налаштування та кинь одразу.", + "DG.ModifySkillRollDialogue.ModifierMuchHarder": "Робить кидок значно складнішим", + "DG.ModifySkillRollDialogue.ModifierHarder": "Робить кидок складнішим", + "DG.ModifySkillRollDialogue.ModifierEasier": "Робить кидок легшим", + "DG.ModifySkillRollDialogue.ModifierMuchEasier": "Робить кидок значно легшим", + "DG.ModifySkillRollDialogue.CustomModifierHeader": "Власний модифікатор", + "DG.ModifySkillRollDialogue.CustomModifierHint": "Введи додатне або від'ємне значення, щоб змінити ціль.", + "DG.ModifySkillRollDialogue.InvalidModifier": "Введи дійсний модифікатор для кидка.", + "TYPES.Item.profession": "Професія", + "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", + "DG.Profession.AssignedHeader": "ASSIGNED PROFESSION ITEM", + "DG.Profession.Dialog.Title": "Pick Skills", + "DG.Profession.Dialog.Skill": "Skill", + "DG.Profession.Dialog.Value": "Value", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)", + "DG.Profession.Dialog.BonusSkillsHeader": "Bonus Skills", + "DG.Profession.Dialog.BondsHeader": "Bonds", + "DG.Profession.Dialog.Bond": "Bond", + "DG.Profession.Dialog.SelectSkill": "Select a skill…", + "DG.Profession.Dialog.SkillTypePlaceholder": "Type or specialization", + "DG.Profession.Dialog.Submit": "Submit", + "DG.Profession.Dialog.CapWarning": "{skill} is being raised to {attempted} but is capped at 80 during agent creation. This is wasting {waste} skill points.", + "DG.Profession.Dialog.DuplicateTypedName": "Duplicate {type} ({name}) detected. Please choose unique names.", + "DG.Profession.ChooseOnePlaceholder": "choose one", + "DG.Profession.Dialog.ChooseOneNamesHeader": "Name your skills", + "DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization", + "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.PickStatistics.Intro": "Обери, як задати характеристики (стати) агента перед вибором навичок.", + "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", + "DG.ProfessionSetup.PickStatistics.AssignStats": "Assign Stats", + "DG.ProfessionSetup.PickStatistics.AssignStatsTooltip": "Divide 72 points between stats as desired.", + "DG.ProfessionSetup.PickStatistics.LeaveStatsAlone": "Leave my stats alone!", + "DG.ProfessionSetup.RollStats.Title": "Roll Stats", + "DG.ProfessionSetup.RollStats.PoolHeader": "Drag each rolled value onto a stat.", + "DG.ProfessionSetup.RollStats.Submit": "Submit", + "DG.ProfessionSetup.RollStats.ChatLabel": "Кидки статів (4к6, відкинь найнижчий)", + "DG.ProfessionSetup.AssignStats.Title": "Assign Stats", + "DG.ProfessionSetup.AssignStats.PointsRemaining": "Points remaining: {remaining}", + "DG.ProfessionSetup.AssignStats.Submit": "Submit", + "DG.ItemWindow.Profession.Description": "Description", + "DG.ItemWindow.Profession.SkillsTab": "Skills", + "DG.ItemWindow.Profession.Name": "Profession Name", + "DG.ItemWindow.Profession.Bonds": "Bonds", + "DG.ItemWindow.Profession.BondsTooltip": "Number of bond slots granted when this profession is assigned to an agent (0–5).", + "DG.ItemWindow.Profession.AutomaticSkills": "Automatic Skills", + "DG.ItemWindow.Profession.OptionSkills": "Skill Options", + "DG.ItemWindow.Profession.OptionPicks": "Option Picks", + "DG.ItemWindow.Profession.AddSkill": "Add Skill", + "DG.ItemWindow.Profession.AddSkillTitle": "Add Skill to Profession", + "DG.ItemWindow.Profession.RemoveSkill": "Remove Skill", + "DG.ItemWindow.Profession.Skill": "Skill", + "DG.ItemWindow.Profession.SelectSkill": "Select a skill…", + "DG.ItemWindow.Profession.Rating": "Rating (%)", + "DG.ItemWindow.Profession.SkillType": "Skill Type", + "DG.ItemWindow.Profession.SkillTypePlaceholder": "e.g. German, Painting", + "DG.ItemWindow.Profession.TypedSkillRequired": "Enter a skill type before adding this skill.", + "DG.ItemWindow.Profession.ChooseOne": "Choose One", + "DG.ItemWindow.Profession.ChooseOneHint": "The player will name this skill when the profession is applied to an agent.", + "DG.ItemWindow.Profession.TypedSkillNameConflict": "Another automatic or option skill in this category already uses the name \"{name}\".", + "DG.Profession.Dialog.OptionPicksRequired": "Select exactly {picks} skill option(s).", + "DG.Profession.Dialog.BondNameRequired": "Enter a name for each bond.", + "DG.Profession.Dialog.BondRelationshipRequired": "Enter a relationship for each bond.", + "DG.Profession.Dialog.BonusSkillRequired": "Select all {count} bonus skills.", + "DG.Profession.Dialog.BonusSkillTypeRequired": "Enter a specialization for each typed bonus skill.", + "DG.Profession.Dialog.TypedNameRequired": "Enter a name for each {type} skill.", + "DG.Physical.Chat.ExhaustedLabel": "Exhausted", + "DG.Physical.Chat.RestedLabel": "Rest", + "DG.Physical.Chat.StimulantsRegularLabel": "Regular Stimulants", + "DG.Physical.Chat.StimulantsHardLabel": "Hard Drugs" } diff --git a/module/applications/add-profession-dialog.js b/module/applications/add-profession-dialog.js new file mode 100644 index 0000000..7538faf --- /dev/null +++ b/module/applications/add-profession-dialog.js @@ -0,0 +1,549 @@ +import { BASE_TEMPLATE_PATH } from "../config.js"; +import { + BONUS_SKILL_COUNT, + buildSkillCatalog, + buildSortedFixedSkillRows, + buildSortedTypedSkillRows, + computeSkillValues, + formatProfessionValidationMessages, + getTypedGroupDisplayName, + parseProfessionSkillKey, + prepareProfessionSkillRows, + splitProfessionSkillMap, +} from "../utils/profession-skills.js"; +import { reorderForColumnSorting } from "../utils/skill-layout.js"; + +const { DialogV2 } = foundry.applications.api; +const ADD_PROFESSION_SKILL_COLUMNS = 4; +const { renderTemplate } = foundry.applications.handlebars; + +/** + * @param {Item} professionItem + * @param {Actor} actor + * @returns {Promise} + */ +export async function showPickSkillsDialog(professionItem, actor) { + // eslint-disable-next-line no-use-before-define -- class defined below in this module + const controller = new AddProfessionDialogController(professionItem, actor); + return controller.run(); +} + +/** + * @param {{ sortLabel: string, rowId: string, label: string, value: number, isModified: boolean }[]} rows + * @returns {{ rowId: string, label: string, value: number, isModified: boolean }[]} + */ +function orderSkillRowsForDisplay(rows) { + const sorted = [...rows].sort((a, b) => + a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), + ); + + const ordered = game.settings.get("deltagreen", "sortSkills") + ? reorderForColumnSorting(sorted, ADD_PROFESSION_SKILL_COLUMNS) + : sorted; + + return ordered.map(({ rowId, label, value, isModified }) => ({ + rowId, + label, + value, + isModified: Boolean(isModified), + })); +} + +class AddProfessionDialogController { + /** + * @param {Item} professionItem + * @param {Actor} actor + */ + constructor(professionItem, actor) { + this.professionItem = professionItem; + this.actor = actor; + + const { optionPicks, skills: optionSkillMap } = splitProfessionSkillMap( + professionItem.system.optionSkills ?? {}, + ); + this.automaticSkills = { ...(professionItem.system.automaticSkills ?? {}) }; + this.automaticSkillMeta = { + ...(professionItem.system.automaticSkillMeta ?? {}), + }; + this.optionSkills = { ...optionSkillMap }; + this.optionSkillMeta = { ...(professionItem.system.optionSkillMeta ?? {}) }; + this.optionPicks = optionPicks; + + /** @type {Set} */ + this.checkedOptionKeys = new Set(); + /** @type {Record} */ + this.chooseOneLabels = {}; + /** @type {string[]} */ + this.bonusCatalogIds = Array(BONUS_SKILL_COUNT).fill(""); + /** @type {string[]} */ + this.bonusTypedLabels = Array(BONUS_SKILL_COUNT).fill(""); + /** @type {string[]} */ + this.bondNames = []; + /** @type {string[]} */ + this.bondRelationships = []; + + /** @type {DialogV2 | null} */ + this.dialog = null; + this.submitted = false; + } + + /** + * @returns {Promise} + */ + async run() { + const content = await this.#buildTemplate(); + this.submitted = false; + + return DialogV2.wait({ + content, + window: { + title: game.i18n.localize("DG.Profession.Dialog.Title"), + }, + position: { width: 960 }, + classes: ["add-profession-dialog-app"], + form: { closeOnSubmit: false }, + render: async (_event, dialog) => { + this.dialog = dialog; + this.#bindListeners(); + await this.#refreshUi(); + }, + close: () => this.submitted, + buttons: [ + { + action: "cancel", + label: game.i18n.localize("Cancel"), + callback: async (_event, _button, dialog) => { + await dialog.close(); + return false; + }, + }, + { + action: "submit", + label: game.i18n.localize("DG.Profession.Dialog.Submit"), + default: true, + disabled: true, + callback: async () => { + await this.#onSubmit(); + return false; + }, + }, + ], + }); + } + + #getFormState() { + this.#syncFormStateFromDom(); + return { + checkedOptionKeys: this.checkedOptionKeys, + chooseOneLabels: { ...this.chooseOneLabels }, + bonusCatalogIds: [...this.bonusCatalogIds], + bonusTypedLabels: [...this.bonusTypedLabels], + bondNames: [...this.bondNames], + bondRelationships: [...this.bondRelationships], + }; + } + + #bondCount() { + return Number(this.professionItem.system.bonds) || 0; + } + + #computeContext() { + return { + automaticMeta: this.automaticSkillMeta, + optionMeta: this.optionSkillMeta, + bondCount: this.#bondCount(), + }; + } + + #compute() { + return computeSkillValues( + this.automaticSkills, + this.optionSkills, + this.optionPicks, + this.#getFormState(), + this.#computeContext(), + ); + } + + #buildAutomaticChooseOneRows() { + return Object.entries(this.automaticSkills) + .map(([key]) => { + if (!this.automaticSkillMeta[key]?.chooseOne) return null; + const ref = parseProfessionSkillKey(key); + if (!ref || ref.kind !== "typed") return null; + return { + key, + groupLabel: getTypedGroupDisplayName(ref.group), + }; + }) + .filter(Boolean); + } + + /** + * @param {ReturnType} computed + * @returns {{ rowId: string, label: string, value: number }[]} + */ + #buildSkillDisplayRows(computed) { + const modifiedFixed = new Set(computed.modifiedFixedKeys ?? []); + const modifiedTyped = new Set(computed.modifiedTypedKeys ?? []); + const fixedRows = buildSortedFixedSkillRows(computed.fixedValues); + const typedRows = buildSortedTypedSkillRows(computed.typedValues); + return orderSkillRowsForDisplay([ + ...fixedRows.map((r) => ({ + rowId: r.key, + label: r.label, + value: r.value, + sortLabel: r.sortLabel, + isModified: modifiedFixed.has(r.key), + })), + ...typedRows.map((r) => ({ + rowId: r.storageKey, + label: r.label, + value: r.value, + sortLabel: r.sortLabel, + isModified: modifiedTyped.has(r.storageKey), + })), + ]); + } + + async #buildTemplate() { + const computed = this.#compute(); + const chaScore = + this.actor.system.statistics.cha.effectiveValue ?? + this.actor.system.statistics.cha.value; + + const skillRows = this.#buildSkillDisplayRows(computed); + + const optionRows = prepareProfessionSkillRows( + this.optionSkills, + this.optionSkillMeta, + ).map((row) => ({ + ...row, + isChooseOne: Boolean(this.optionSkillMeta[row.key]?.chooseOne), + })); + + const automaticChooseOneRows = this.#buildAutomaticChooseOneRows(); + + const capWarnings = computed.capWarnings.map((w) => ({ + warning: game.i18n.format("DG.Profession.Dialog.CapWarning", { + skill: w.label, + attempted: w.attempted, + waste: w.waste, + }), + })); + + const bondCount = Number(this.professionItem.system.bonds) || 0; + const bondRows = Array.from({ length: bondCount }, (_, index) => ({ + index, + number: index + 1, + chaScore, + })); + + const bonusSlots = Array.from( + { length: BONUS_SKILL_COUNT }, + (_, index) => ({ + index, + }), + ); + + return renderTemplate(`${BASE_TEMPLATE_PATH}/dialog/add-profession.html`, { + skillRows, + showOptionSkills: this.optionPicks > 0, + optionPicks: this.optionPicks, + optionRows, + automaticChooseOneRows, + catalog: buildSkillCatalog(), + bonusSlots, + validationMessages: formatProfessionValidationMessages( + computed.validationErrors, + { optionPicks: this.optionPicks }, + ), + capWarnings, + bondCount, + bondRows, + }); + } + + #contentRoot() { + return this.dialog?.element?.querySelector(".dialog-content"); + } + + #bindListeners() { + const root = this.#contentRoot(); + if (!root) return; + + root.querySelectorAll('[name="optionSkillChecked"]').forEach((el) => { + el.addEventListener("change", () => this.#onOptionCheckChange(el)); + }); + + root.querySelectorAll("[data-choose-one-key]").forEach((el) => { + el.addEventListener("input", () => this.#refreshUi()); + }); + + root.querySelectorAll("select[data-bonus-index]").forEach((el) => { + el.addEventListener("change", () => this.#onBonusChange(el)); + }); + + root.querySelectorAll("[data-bonus-typed-index]").forEach((el) => { + el.addEventListener("input", () => this.#refreshUi()); + }); + + for (let i = 0; i < this.#bondCount(); i++) { + root + .querySelector(`[name="bondName-${i}"]`) + ?.addEventListener("input", () => this.#refreshUi()); + root + .querySelector(`[name="bondRelationship-${i}"]`) + ?.addEventListener("input", () => this.#refreshUi()); + } + } + + /** + * @param {HTMLInputElement} checkbox + */ + #onOptionCheckChange(checkbox) { + const key = checkbox.value; + const isChooseOne = checkbox.dataset.isChooseOne === "true"; + const root = this.#contentRoot(); + + if (checkbox.checked) { + if (this.checkedOptionKeys.size >= this.optionPicks) { + checkbox.checked = false; + return; + } + this.checkedOptionKeys.add(key); + if (isChooseOne && root) { + const nameInput = root.querySelector( + `[data-choose-one-key="${CSS.escape(key)}"]`, + ); + if (nameInput) nameInput.disabled = false; + } + } else { + this.checkedOptionKeys.delete(key); + if (isChooseOne && root) { + const nameInput = root.querySelector( + `[data-choose-one-key="${CSS.escape(key)}"]`, + ); + if (nameInput) { + nameInput.disabled = true; + nameInput.value = ""; + } + delete this.chooseOneLabels[key]; + } + } + this.#refreshUi(); + } + + /** + * @param {HTMLSelectElement} select + */ + #onBonusChange(select) { + const index = Number(select.dataset.bonusIndex); + this.bonusCatalogIds[index] = select.value; + + const root = this.#contentRoot(); + const typedInput = root?.querySelector(`[name="bonusTypedLabel-${index}"]`); + const isTyped = select.value.startsWith("typed:"); + if (typedInput) { + typedInput.classList.toggle("hidden", !isTyped); + if (!isTyped) { + typedInput.value = ""; + this.bonusTypedLabels[index] = ""; + } + } + this.#syncFormStateFromDom(); + this.#refreshUi(); + } + + #syncFormStateFromDom() { + const root = this.#contentRoot(); + if (!root) return; + + root.querySelectorAll("[data-choose-one-key]").forEach((input) => { + const key = input.dataset.chooseOneKey; + if (key) this.chooseOneLabels[key] = input.value; + }); + + for (let i = 0; i < BONUS_SKILL_COUNT; i++) { + const select = root.querySelector(`[name="bonusSkill-${i}"]`); + this.bonusCatalogIds[i] = select?.value ?? ""; + const typedInput = root.querySelector(`[name="bonusTypedLabel-${i}"]`); + this.bonusTypedLabels[i] = typedInput?.value ?? ""; + } + + const bondCount = this.#bondCount(); + this.bondNames.length = bondCount; + this.bondRelationships.length = bondCount; + for (let i = 0; i < bondCount; i++) { + this.bondNames[i] = + root.querySelector(`[name="bondName-${i}"]`)?.value ?? ""; + this.bondRelationships[i] = + root.querySelector(`[name="bondRelationship-${i}"]`)?.value ?? ""; + } + } + + #restoreFormSelections() { + const root = this.#contentRoot(); + if (!root) return; + + for (const key of this.checkedOptionKeys) { + const cb = root.querySelector( + `[name="optionSkillChecked"][value="${CSS.escape(key)}"]`, + ); + if (cb) cb.checked = true; + if (!this.optionSkillMeta[key]?.chooseOne) continue; + const nameInput = root.querySelector( + `[data-choose-one-key="${CSS.escape(key)}"]`, + ); + if (nameInput) { + nameInput.disabled = false; + nameInput.value = this.chooseOneLabels[key] ?? ""; + } + } + + for (const row of this.#buildAutomaticChooseOneRows()) { + const nameInput = root.querySelector( + `[data-choose-one-key="${CSS.escape(row.key)}"]`, + ); + if (nameInput) nameInput.value = this.chooseOneLabels[row.key] ?? ""; + } + + for (let i = 0; i < BONUS_SKILL_COUNT; i++) { + const select = root.querySelector(`[name="bonusSkill-${i}"]`); + if (select) select.value = this.bonusCatalogIds[i] ?? ""; + const typedInput = root.querySelector(`[name="bonusTypedLabel-${i}"]`); + const isTyped = (this.bonusCatalogIds[i] ?? "").startsWith("typed:"); + if (typedInput) { + typedInput.classList.toggle("hidden", !isTyped); + typedInput.value = this.bonusTypedLabels[i] ?? ""; + } + } + } + + /** + * @param {ReturnType} computed + * @returns {string} + */ + #buildMessagesHtml(computed) { + const validationHtml = formatProfessionValidationMessages( + computed.validationErrors, + { optionPicks: this.optionPicks }, + ) + .map( + (message) => + `

${foundry.utils.escapeHTML( + message, + )}

`, + ) + .join(""); + + const capHtml = computed.capWarnings + .map( + (w) => + `

${foundry.utils.escapeHTML( + game.i18n.format("DG.Profession.Dialog.CapWarning", { + skill: w.label, + attempted: w.attempted, + waste: w.waste, + }), + )}

`, + ) + .join(""); + + return validationHtml + capHtml; + } + + async #refreshUi() { + const root = this.#contentRoot(); + if (!root) return; + + const computed = this.#compute(); + + const rows = this.#buildSkillDisplayRows(computed); + + const body = root.querySelector("[data-profession-skills-body]"); + if (body) { + body.innerHTML = rows + .map((r) => { + const modifiedClass = r.isModified ? " is-modified" : ""; + return `
${foundry.utils.escapeHTML( + r.label, + )}${ + r.value + }%
`; + }) + .join(""); + } + + const messagesEl = root.querySelector("[data-profession-messages]"); + if (messagesEl) { + messagesEl.innerHTML = this.#buildMessagesHtml(computed); + } + + const submitBtn = this.dialog?.element?.querySelector( + 'button[data-action="submit"]', + ); + if (submitBtn) submitBtn.disabled = !computed.isValid; + + this.#restoreFormSelections(); + } + + async #onSubmit() { + this.#syncFormStateFromDom(); + const computed = this.#compute(); + if (!computed.isValid) return; + + const updateData = {}; + for (const [key, value] of Object.entries(computed.fixedValues)) { + updateData[`system.skills.${key}.proficiency`] = value; + updateData[`system.skills.${key}.failure`] = false; + } + + const typedSkills = { ...(this.actor.system.typedSkills ?? {}) }; + for (const [, data] of Object.entries(computed.typedValues)) { + const id = foundry.utils.randomID(); + typedSkills[id] = { + label: data.label, + group: data.group, + proficiency: data.value, + failure: false, + }; + } + updateData["system.typedSkills"] = typedSkills; + + updateData["system.biography.profession"] = this.professionItem.name; + + await this.actor.update(updateData); + + const chaScore = + this.actor.system.statistics.cha.effectiveValue ?? + this.actor.system.statistics.cha.value; + const bondCount = this.#bondCount(); + const bondDocs = []; + + for (let i = 0; i < bondCount; i++) { + const name = this.bondNames[i]?.trim() ?? ""; + const relationship = this.bondRelationships[i]?.trim() ?? ""; + + bondDocs.push({ + name, + type: "bond", + system: { + score: chaScore, + relationship, + }, + }); + } + + if (bondDocs.length) { + await this.actor.createEmbeddedDocuments("Item", bondDocs); + } + + this.submitted = true; + await this.dialog?.close(); + } +} + +export default { showPickSkillsDialog }; diff --git a/module/applications/assign-stats-dialog.js b/module/applications/assign-stats-dialog.js new file mode 100644 index 0000000..4b8d57f --- /dev/null +++ b/module/applications/assign-stats-dialog.js @@ -0,0 +1,156 @@ +import { BASE_TEMPLATE_PATH } from "../config.js"; +import { + STAT_KEYS, + STAT_MAX, + STAT_MIN, + applyAgentStatistics, + buildStatisticRows, + getDefaultPointBuyValues, + validatePointBuyValues, +} from "../utils/profession-stat-setup.js"; + +const { DialogV2 } = foundry.applications.api; +const { renderTemplate } = foundry.applications.handlebars; + +/** @typedef {'submitted' | 'cancelled'} AssignStatsResult */ + +/** + * @param {DialogV2} dialog + * @returns {HTMLElement|null} + */ +function contentRoot(dialog) { + return dialog.element?.querySelector(".dialog-content") ?? null; +} + +/** + * @param {DialogV2} dialog + * @param {Record} values + */ +function syncValuesFromDom(dialog, values) { + const root = contentRoot(dialog); + if (!root) return; + + for (const key of STAT_KEYS) { + const input = root.querySelector(`[data-stat-key="${key}"]`); + if (!input) continue; + let value = Number(input.value); + if (!Number.isFinite(value)) value = STAT_MIN; + values[key] = Math.clamp(Math.trunc(value), STAT_MIN, STAT_MAX); + } +} + +/** + * @param {DialogV2} dialog + * @param {Record} values + */ +function refreshAssignStatsUi(dialog, values) { + syncValuesFromDom(dialog, values); + const { isValid, remaining } = validatePointBuyValues(values); + + const root = contentRoot(dialog); + const remainingEl = root?.querySelector("[data-points-remaining]"); + if (remainingEl) { + remainingEl.textContent = game.i18n.format( + "DG.ProfessionSetup.AssignStats.PointsRemaining", + { remaining }, + ); + remainingEl.classList.toggle("is-invalid", remaining !== 0); + } + + const submitBtn = dialog.element?.querySelector( + 'button[data-action="submit"]', + ); + if (submitBtn) submitBtn.disabled = !isValid; +} + +/** + * @param {DialogV2} dialog + * @param {Record} values + */ +function bindAssignStatsListeners(dialog, values) { + const root = contentRoot(dialog); + if (!root) return; + + root.querySelectorAll("[data-stat-key]").forEach((input) => { + input.addEventListener("input", () => { + const key = input.dataset.statKey; + if (!key) return; + + let value = Number(input.value); + if (!Number.isFinite(value)) value = STAT_MIN; + value = Math.clamp(Math.trunc(value), STAT_MIN, STAT_MAX); + input.value = String(value); + values[key] = value; + refreshAssignStatsUi(dialog, values); + }); + input.addEventListener("change", () => + refreshAssignStatsUi(dialog, values), + ); + }); +} + +/** + * @param {Actor} actor + * @returns {Promise} + */ +export async function showAssignStatsDialog(actor) { + /** @type {Record} */ + const values = getDefaultPointBuyValues(); + const { remaining } = validatePointBuyValues(values); + /** @type {AssignStatsResult} */ + let result = "cancelled"; + + const content = await renderTemplate( + `${BASE_TEMPLATE_PATH}/dialog/assign-stats.html`, + { + stats: buildStatisticRows(), + values, + statMin: STAT_MIN, + statMax: STAT_MAX, + remaining, + }, + ); + + return DialogV2.wait({ + content, + window: { + title: game.i18n.localize("DG.ProfessionSetup.AssignStats.Title"), + }, + position: { width: 420 }, + classes: ["assign-stats-dialog-app"], + form: { closeOnSubmit: false }, + render: (_event, dialog) => { + bindAssignStatsListeners(dialog, values); + refreshAssignStatsUi(dialog, values); + }, + close: () => result, + buttons: [ + { + action: "cancel", + label: game.i18n.localize("Cancel"), + callback: async (_event, _button, dialog) => { + result = "cancelled"; + await dialog.close(); + }, + }, + { + action: "submit", + label: game.i18n.localize("DG.ProfessionSetup.AssignStats.Submit"), + default: true, + disabled: true, + callback: async (_event, _button, dialog) => { + syncValuesFromDom(dialog, values); + const { isValid } = validatePointBuyValues(values); + if (!isValid) return false; + + await applyAgentStatistics(actor, { ...values }); + result = "submitted"; + await dialog.close(); + return false; + }, + }, + ], + }); +} + +export default { showAssignStatsDialog }; diff --git a/module/applications/pick-statistics-dialog.js b/module/applications/pick-statistics-dialog.js new file mode 100644 index 0000000..9e29d1f --- /dev/null +++ b/module/applications/pick-statistics-dialog.js @@ -0,0 +1,55 @@ +import { BASE_TEMPLATE_PATH } from "../config.js"; + +const { DialogV2 } = foundry.applications.api; +const { renderTemplate } = foundry.applications.handlebars; + +/** @typedef {'roll' | 'assign' | 'skip'} PickStatisticsChoice */ + +/** + * @returns {Promise} + */ +export async function showPickStatisticsDialog() { + const content = await renderTemplate( + `${BASE_TEMPLATE_PATH}/dialog/pick-statistics.html`, + {}, + ); + + return DialogV2.wait({ + content, + window: { + title: game.i18n.localize("DG.ProfessionSetup.PickStatistics.Title"), + }, + position: { width: 480 }, + classes: ["pick-statistics-dialog-app"], + close: () => null, + buttons: [ + { + action: "roll", + label: game.i18n.localize("DG.ProfessionSetup.PickStatistics.RollStats"), + tooltip: game.i18n.localize( + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip", + ), + callback: () => "roll", + }, + { + action: "assign", + label: game.i18n.localize( + "DG.ProfessionSetup.PickStatistics.AssignStats", + ), + tooltip: game.i18n.localize( + "DG.ProfessionSetup.PickStatistics.AssignStatsTooltip", + ), + callback: () => "assign", + }, + { + action: "skip", + label: game.i18n.localize( + "DG.ProfessionSetup.PickStatistics.LeaveStatsAlone", + ), + callback: () => "skip", + }, + ], + }); +} + +export default { showPickStatisticsDialog }; diff --git a/module/applications/profession-setup-flow.js b/module/applications/profession-setup-flow.js new file mode 100644 index 0000000..d825d10 --- /dev/null +++ b/module/applications/profession-setup-flow.js @@ -0,0 +1,83 @@ +import { showPickSkillsDialog } from "./add-profession-dialog.js"; +import { showAssignStatsDialog } from "./assign-stats-dialog.js"; +import { showPickStatisticsDialog } from "./pick-statistics-dialog.js"; +import { showRollStatsDialog } from "./roll-stats-dialog.js"; + +/** + * @param {Actor} actor + * @param {object} itemData + * @returns {Item} + */ +function createProfessionStub(actor, itemData) { + const data = foundry.utils.duplicate(itemData); + delete data._id; + const ItemDocument = foundry.utils.getDocumentClass("Item"); + return new ItemDocument(data, { parent: actor }); +} + +/** + * Run the full profession setup flow: Pick Statistics → optional stat setup → Pick Skills. + * + * @param {Item} professionItem Profession item or unsaved stub on the actor + * @param {Actor} actor + * @param {object} [options] + * @param {TokenDocument|null} [options.token] + * @returns {Promise} True if Pick Skills was submitted successfully. + */ +export async function runProfessionSetupFlow( + professionItem, + actor, + { token = null } = {}, +) { + let statsComplete = false; + + while (!statsComplete) { + const choice = await showPickStatisticsDialog(); + if (choice === null) return false; + + if (choice === "skip") { + statsComplete = true; + } else if (choice === "roll") { + const result = await showRollStatsDialog(actor, { token }); + if (result === "submitted") statsComplete = true; + } else if (choice === "assign") { + const result = await showAssignStatsDialog(actor); + if (result === "submitted") statsComplete = true; + } + } + + return showPickSkillsDialog(professionItem, actor); +} + +/** + * Assign a profession to an agent: full setup flow, then embed the item only on success. + * + * @param {Actor} actor + * @param {object} itemData Item source data (e.g. from drop `toObject()` or create template) + * @param {object} [options] + * @param {TokenDocument|null} [options.token] + * @returns {Promise} + */ +export async function assignProfessionToAgent( + actor, + itemData, + { token = null } = {}, +) { + if (actor.items.some((i) => i.type === "profession")) { + ui.notifications.info(game.i18n.localize("DG.Profession.AlreadyAssigned"), { + localize: false, + }); + return null; + } + + const stub = createProfessionStub(actor, itemData); + const submitted = await runProfessionSetupFlow(stub, actor, { token }); + if (!submitted) return null; + + const createData = stub.toObject(); + delete createData._id; + const created = await actor.createEmbeddedDocuments("Item", [createData]); + return created[0] ?? null; +} + +export default { runProfessionSetupFlow, assignProfessionToAgent }; diff --git a/module/applications/roll-stats-dialog.js b/module/applications/roll-stats-dialog.js new file mode 100644 index 0000000..4a4f67c --- /dev/null +++ b/module/applications/roll-stats-dialog.js @@ -0,0 +1,274 @@ +import { BASE_TEMPLATE_PATH } from "../config.js"; +import { createDGChatMessage } from "../chat/dg-chat-card.js"; +import { + STAT_KEYS, + applyAgentStatistics, + buildRollStatsChatContent, + buildStatisticRows, + rollStatisticScores, +} from "../utils/profession-stat-setup.js"; + +const { DialogV2 } = foundry.applications.api; +const { renderTemplate } = foundry.applications.handlebars; + +/** @typedef {'submitted' | 'cancelled'} RollStatsResult */ + +/** + * @param {DialogV2} dialog + * @returns {HTMLElement|null} + */ +function contentRoot(dialog) { + return dialog.element?.querySelector(".dialog-content") ?? null; +} + +/** + * @param {object} state + * @param {DialogV2} dialog + * @param {number|null} rollIndex + * @returns {string|null} + */ +function findStatKeyForRollIndex(state, rollIndex) { + for (const key of STAT_KEYS) { + if (state.assignments[key] === rollIndex) return key; + } + return null; +} + +/** + * @param {object} state + * @param {DialogV2} dialog + */ +function syncDomFromAssignments(state, dialog) { + const root = contentRoot(dialog); + if (!root) return; + + const pool = root.querySelector("[data-roll-stats-pool]"); + if (!pool) return; + + root.querySelectorAll(".roll-stat-token").forEach((token) => { + pool.appendChild(token); + }); + + for (const key of STAT_KEYS) { + const rollIndex = state.assignments[key]; + if (rollIndex === null) continue; + + const slot = root.querySelector(`[data-stat-slot][data-stat-key="${key}"]`); + const token = pool.querySelector(`[data-roll-index="${rollIndex}"]`); + if (slot && token) { + slot.appendChild(token); + } + } +} + +/** + * @param {object} state + * @param {DialogV2} dialog + */ +function refreshRollStatsUi(state, dialog) { + const submitBtn = dialog.element?.querySelector( + 'button[data-action="submit"]', + ); + if (submitBtn) { + submitBtn.disabled = !STAT_KEYS.every( + (key) => state.assignments[key] !== null, + ); + } +} + +/** + * @param {object} state + * @param {DialogV2} dialog + */ +function bindRollStatsListeners(state, dialog) { + const root = contentRoot(dialog); + if (!root) return; + + root.querySelectorAll(".roll-stat-token").forEach((token) => { + token.addEventListener("dragstart", (event) => { + const rollIndex = Number(token.dataset.rollIndex); + state.dragRollIndex = rollIndex; + token.classList.add("is-dragging"); + event.dataTransfer?.setData("text/plain", String(rollIndex)); + if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; + }); + token.addEventListener("dragend", () => { + token.classList.remove("is-dragging"); + state.dragRollIndex = null; + root + .querySelectorAll(".drag-over") + .forEach((el) => el.classList.remove("drag-over")); + }); + }); + + const readDragRollIndex = (event) => { + const fromData = event.dataTransfer?.getData("text/plain"); + if (fromData !== undefined && fromData !== "") { + const parsed = Number(fromData); + if (Number.isInteger(parsed)) return parsed; + } + if (state.dragRollIndex !== null) return state.dragRollIndex; + return null; + }; + + root.querySelectorAll("[data-stat-slot]").forEach((slot) => { + slot.addEventListener("dragover", (event) => { + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + slot.classList.add("drag-over"); + }); + slot.addEventListener("dragleave", () => + slot.classList.remove("drag-over"), + ); + slot.addEventListener("drop", (event) => { + event.preventDefault(); + slot.classList.remove("drag-over"); + + const rollIndex = readDragRollIndex(event); + if (rollIndex === null) return; + + const { statKey } = slot.dataset; + if (!statKey) return; + + const sourceStatKey = findStatKeyForRollIndex(state, rollIndex); + const displacedIndex = state.assignments[statKey]; + + if (sourceStatKey === statKey) return; + + if (sourceStatKey) { + state.assignments[sourceStatKey] = null; + } + + if (displacedIndex !== null && displacedIndex !== rollIndex) { + if (sourceStatKey) { + state.assignments[sourceStatKey] = displacedIndex; + } + } + + state.assignments[statKey] = rollIndex; + syncDomFromAssignments(state, dialog); + refreshRollStatsUi(state, dialog); + }); + }); + + const pool = root.querySelector("[data-roll-stats-pool]"); + if (!pool) return; + + pool.addEventListener("dragover", (event) => { + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + pool.classList.add("drag-over"); + }); + pool.addEventListener("dragleave", (event) => { + if ( + event.currentTarget === event.target || + !event.currentTarget.contains(event.relatedTarget) + ) { + pool.classList.remove("drag-over"); + } + }); + pool.addEventListener("drop", (event) => { + event.preventDefault(); + pool.classList.remove("drag-over"); + + const rollIndex = readDragRollIndex(event); + if (rollIndex === null) return; + + const sourceStatKey = findStatKeyForRollIndex(state, rollIndex); + if (sourceStatKey) { + state.assignments[sourceStatKey] = null; + syncDomFromAssignments(state, dialog); + refreshRollStatsUi(state, dialog); + } + }); +} + +/** + * @param {Actor} actor + * @param {object} [options] + * @param {TokenDocument|null} [options.token] + * @returns {Promise} + */ +export async function showRollStatsDialog(actor, { token = null } = {}) { + const rolled = await rollStatisticScores({ actor, token }); + const rolls = rolled.map((entry, index) => ({ + index, + total: entry.total, + })); + + await createDGChatMessage({ + actor, + token, + label: game.i18n.localize("DG.ProfessionSetup.RollStats.ChatLabel"), + content: buildRollStatsChatContent(rolls), + }); + + const state = { + rolls, + assignments: Object.fromEntries(STAT_KEYS.map((key) => [key, null])), + dragRollIndex: null, + }; + /** @type {RollStatsResult} */ + let result = "cancelled"; + + const content = await renderTemplate( + `${BASE_TEMPLATE_PATH}/dialog/roll-stats.html`, + { + rolls: state.rolls, + stats: buildStatisticRows(), + }, + ); + + return DialogV2.wait({ + content, + window: { + title: game.i18n.localize("DG.ProfessionSetup.RollStats.Title"), + }, + position: { width: 520 }, + classes: ["roll-stats-dialog-app"], + form: { closeOnSubmit: false }, + render: (_event, dialog) => { + bindRollStatsListeners(state, dialog); + refreshRollStatsUi(state, dialog); + }, + close: () => result, + buttons: [ + { + action: "cancel", + label: game.i18n.localize("Cancel"), + callback: async (_event, _button, dialog) => { + result = "cancelled"; + await dialog.close(); + }, + }, + { + action: "submit", + label: game.i18n.localize("DG.ProfessionSetup.RollStats.Submit"), + default: true, + disabled: true, + callback: async (_event, _button, dialog) => { + const isComplete = STAT_KEYS.every( + (key) => state.assignments[key] !== null, + ); + if (!isComplete) return false; + + /** @type {Record} */ + const valuesByKey = {}; + for (const key of STAT_KEYS) { + const rollIndex = state.assignments[key]; + const roll = state.rolls.find((entry) => entry.index === rollIndex); + if (!roll) return false; + valuesByKey[key] = roll.total; + } + + await applyAgentStatistics(actor, valuesByKey); + result = "submitted"; + await dialog.close(); + return false; + }, + }, + ], + }); +} + +export default { showRollStatsDialog }; diff --git a/module/chat/dg-chat-card.js b/module/chat/dg-chat-card.js new file mode 100644 index 0000000..5f515f0 --- /dev/null +++ b/module/chat/dg-chat-card.js @@ -0,0 +1,259 @@ +/* global TokenDocument */ + +import DG from "../config.js"; + +const CHAT_CARD_TEMPLATE = "systems/deltagreen/templates/chat/dg-chat-card.hbs"; +const { renderTemplate } = foundry.applications.handlebars; + +/** + * Resolve a TokenDocument from sheet/macro context. + * @param {TokenDocument|object|string|null} token + * @param {Scene|SceneDocument|string|null} [scene] + * @returns {TokenDocument|null} + */ +export function resolveDGTokenDocument(token, scene = null) { + if (!token) return null; + if (token.documentName === "Token" || token instanceof TokenDocument) { + return token; + } + if (token.document?.documentName === "Token") { + return token.document; + } + const tokenId = typeof token === "string" ? token : token.id ?? token._id; + if (!tokenId) return null; + + const sceneId = + typeof scene === "string" + ? scene + : scene?.id ?? token.parent?.id ?? canvas.scene?.id; + const sceneDoc = sceneId ? game.scenes.get(sceneId) : canvas.scene; + return ( + sceneDoc?.tokens.get(tokenId) ?? + canvas.tokens.get(tokenId)?.document ?? + null + ); +} + +/** + * Speaker for Delta Green chat; uses token name for unlinked tokens. + * @param {object} params + * @param {Actor|null} [params.actor] + * @param {TokenDocument|object|string|null} [params.token] + * @param {Scene|SceneDocument|string|null} [params.scene] + * @returns {ChatMessage.SpeakerData} + */ +export function getDGSpeaker({ + actor = null, + token = null, + scene = null, +} = {}) { + const tokenDoc = resolveDGTokenDocument(token, scene); + const sceneDoc = scene ?? tokenDoc?.parent ?? canvas.scene ?? null; + const alias = tokenDoc?.name ?? actor?.name; + + return ChatMessage.getSpeaker({ + actor, + token: tokenDoc, + scene: sceneDoc, + alias, + }); +} + +/** + * Token document from an existing chat speaker payload. + * @param {ChatMessage.SpeakerData|null} speaker + * @returns {TokenDocument|null} + */ +export function getTokenFromChatSpeaker(speaker) { + if (!speaker?.token) return null; + return resolveDGTokenDocument(speaker.token, speaker.scene); +} + +/** + * Best token for a roll from sheet or macro context. + * @param {Actor|null} actor + * @param {TokenDocument|object|string|null} [sheetToken] + * @returns {TokenDocument|null} + */ +export function getDGRollToken(actor, sheetToken = null) { + const fromSheet = resolveDGTokenDocument(sheetToken); + if (fromSheet) return fromSheet; + + if (!actor) return null; + + const controlled = canvas.tokens?.controlled?.find((t) => t.actor === actor); + if (controlled?.document) return controlled.document; + + const active = actor.getActiveTokens?.(true, true)?.[0]; + return active ?? null; +} + +/** + * @param {ChatMessage.SpeakerData|null} speaker + * @returns {string} + */ +export function getDGChatSpeakerName(speaker) { + if (!speaker) { + return game.i18n.localize("CHAT.UnknownUser"); + } + return speaker.alias ?? game.i18n.localize("CHAT.UnknownUser"); +} + +/** + * @param {object} params + * @param {string} params.speakerName + * @param {string} [params.label] + * @param {string} params.content + * @returns {Promise} + */ +export async function renderDGChatCard({ speakerName, label = "", content }) { + return renderTemplate(CHAT_CARD_TEMPLATE, { + speakerName, + label, + content, + }); +} + +/** + * Wrap message content in the Delta Green chat card shell. + * @param {object} messageData + * @param {object} [options] + * @param {string} [options.label] Card subtitle (falls back to flavor) + * @returns {Promise} + */ +export async function wrapDGChatMessageData(messageData, { label } = {}) { + const labelText = label ?? messageData.flavor; + if (!labelText) return messageData; + + const speakerName = getDGChatSpeakerName(messageData.speaker); + const wrappedContent = await renderDGChatCard({ + speakerName, + label: labelText, + content: messageData.content ?? "", + }); + + const data = foundry.utils.deepClone(messageData); + data.content = wrappedContent; + data.flavor = ""; + foundry.utils.setProperty(data, `flags.${DG.ID}.chatCard`, true); + return data; +} + +/** + * Build chat message data for a roll with optional Delta Green card wrapping. + * + * @param {object} params + * @param {Roll} params.roll + * @param {Actor|null} [params.actor] + * @param {TokenDocument|null} [params.token] + * @param {Scene|SceneDocument|null} [params.scene] + * @param {string} [params.label] + * @param {string} [params.content] + * @param {string} [params.messageMode] + * @param {object} [params.flags] + * @returns {Promise<{ messageData: object, mappedMode: string }>} + */ +export async function prepareDGRollChatMessageData({ + roll, + actor = null, + token = null, + scene = null, + label = "", + content, + messageMode, + flags = {}, +}) { + const mode = messageMode ?? game.settings.get("core", "messageMode"); + const mappedMode = foundry.dice.Roll._mapLegacyRollMode(mode); + + if (!roll._evaluated) { + await roll.evaluate({ allowInteractive: mappedMode !== "blind" }); + } + + const speaker = getDGSpeaker({ + actor: actor ?? roll.actor ?? null, + token: token ?? roll.options?.token ?? null, + scene, + }); + + let messageData = { + author: game.user.id, + speaker, + content: content ?? String(roll.total), + sound: CONFIG.sounds.dice, + rolls: [roll], + flags: foundry.utils.mergeObject( + { [DG.ID]: { chatCard: Boolean(label) } }, + flags, + ), + }; + + if (label) { + messageData = await wrapDGChatMessageData(messageData, { label }); + } + + return { messageData, mappedMode }; +} + +/** + * Create a roll chat message with the Delta Green card shell and an explicit rolls array. + * Matches the Dolmenwood/DSN pattern: styled content in `content`, Roll objects in `rolls`. + * + * @see https://gitlab.com/riccisi/foundryvtt-dice-so-nice/-/wikis/Integration + * @param {object} params + * @param {Roll} params.roll Evaluated or unevaluated roll to attach to the message. + * @param {Actor|null} [params.actor] + * @param {TokenDocument|null} [params.token] + * @param {Scene|SceneDocument|null} [params.scene] + * @param {string} [params.label] Card subtitle + * @param {string} [params.content] Card body HTML + * @param {string} [params.messageMode] + * @param {object} [params.flags] + * @returns {Promise} + */ +export async function createDGRollChatMessage(params) { + const { messageData, mappedMode } = + await prepareDGRollChatMessageData(params); + + const ChatMessageDocument = foundry.utils.getDocumentClass("ChatMessage"); + const msg = new ChatMessageDocument(messageData); + msg.applyMode(mappedMode); + return ChatMessageDocument.create(msg); +} + +/** + * @param {object} params + * @param {Actor} params.actor + * @param {TokenDocument|null} [params.token] + * @param {Scene|SceneDocument|null} [params.scene] + * @param {string} [params.label] Card subtitle + * @param {string} params.content Card body HTML + * @param {string} [params.messageMode] + * @param {object} [params.flags] + * @returns {Promise} + */ +export async function createDGChatMessage({ + actor, + token = null, + scene = null, + label = "", + content, + messageMode, + flags = {}, +}) { + const speaker = getDGSpeaker({ actor, token, scene }); + const speakerName = getDGChatSpeakerName(speaker); + const wrappedContent = await renderDGChatCard({ + speakerName, + label, + content, + }); + + return ChatMessage.create({ + speaker, + content: wrappedContent, + flavor: "", + messageMode: messageMode ?? game.settings.get("core", "messageMode"), + flags: foundry.utils.mergeObject({ [DG.ID]: { chatCard: true } }, flags), + }); +} diff --git a/module/data/item/profession.js b/module/data/item/profession.js new file mode 100644 index 0000000..1233355 --- /dev/null +++ b/module/data/item/profession.js @@ -0,0 +1,21 @@ +import defineBaseItemSystemFields from "./base-fields.js"; + +const { NumberField, ObjectField } = foundry.data.fields; + +/** Reserved key inside {@link ProfessionItemData.optionSkills}. */ +export const PROFESSION_OPTION_PICKS_KEY = "optionPicks"; + +export default class ProfessionItemData extends foundry.abstract.TypeDataModel { + static defineSchema() { + return { + ...defineBaseItemSystemFields(), + bonds: new NumberField({ initial: 0, min: 0, max: 5, integer: true }), + automaticSkills: new ObjectField({ initial: {} }), + automaticSkillMeta: new ObjectField({ initial: {} }), + optionSkills: new ObjectField({ initial: { [PROFESSION_OPTION_PICKS_KEY]: 0 } }), + optionSkillMeta: new ObjectField({ initial: {} }), + }; + } +} + +/** @typedef {{ chooseOne?: boolean }} ProfessionSkillMeta */ diff --git a/module/deltagreen.js b/module/deltagreen.js index 2b82d29..e1dcb25 100644 --- a/module/deltagreen.js +++ b/module/deltagreen.js @@ -38,6 +38,7 @@ import BondItemData from "./data/item/bond.js"; import GearItemData from "./data/item/gear.js"; import TomeItemData from "./data/item/tome.js"; import RitualItemData from "./data/item/ritual.js"; +import ProfessionItemData from "./data/item/profession.js"; import DGActiveEffect from "./documents/dg-active-effect.js"; import DGActiveEffectConfig from "./applications/dg-active-effect-config.js"; import DGActiveEffectTypeDataModel from "./data/active-effect/dg-active-effect-data.js"; @@ -67,6 +68,7 @@ Hooks.once("init", async () => { gear: GearItemData, tome: TomeItemData, ritual: RitualItemData, + profession: ProfessionItemData, }); CONFIG.ActiveEffect.documentClass = DGActiveEffect; @@ -205,11 +207,21 @@ Hooks.on("updateItem", (item, changes) => { "system.crossedOut", "system.disorderCured", ]; - if (!transferStateKeys.some((key) => key in flat)) return; - const { actor } = item; - if (!actor) return; - actor.reset(); - if (actor.sheet?.rendered) actor.sheet.render(); + if (transferStateKeys.some((key) => key in flat)) { + const { actor } = item; + if (actor) { + actor.reset(); + if (actor.sheet?.rendered) actor.sheet.render(); + } + } + + if ( + item.type === "profession" && + "name" in flat && + item.actor?.type === "agent" + ) { + item.actor.update({ "system.biography.profession": item.name }); + } }); Hooks.once("ready", async () => { @@ -326,8 +338,13 @@ Hooks.on("renderChatLog", async (app, element) => { addEventListenerToChatMessage(element); }); -Hooks.on("renderChatMessageHTML", async (app, element, context) => { +Hooks.on("renderChatMessageHTML", async (message, element, context) => { // ignore non chat card notifications if (!context.canClose) return; + + if (message.getFlag(DG.ID, "chatCard")) { + element.classList.add("dg-chat-card-message"); + } + addEventListenerToChatMessage(element); }); diff --git a/module/item/item.js b/module/item/item.js index 96aa820..785f9ac 100644 --- a/module/item/item.js +++ b/module/item/item.js @@ -1,3 +1,4 @@ +import { getDGRollToken } from "../chat/dg-chat-card.js"; import { DGDamageRoll, DGLethalityRoll } from "../roll/roll.js"; /** @@ -28,7 +29,12 @@ export default class DeltaGreenItem extends Item { roll = new DGLethalityRoll( "1D100", {}, - { rollType: "lethality", actor, item }, + { + rollType: "lethality", + actor, + item, + token: getDGRollToken(actor, actor.sheet?.token), + }, ); } else { // regular damage roll @@ -46,7 +52,12 @@ export default class DeltaGreenItem extends Item { roll = new DGDamageRoll( diceFormula, {}, - { rollType: "damage", actor, item }, + { + rollType: "damage", + actor, + item, + token: getDGRollToken(actor, actor.sheet?.token), + }, ); } return actor.sheet.processRoll({}, roll); diff --git a/module/macros/macro-functions.js b/module/macros/macro-functions.js index 0296e4d..c678dc0 100644 --- a/module/macros/macro-functions.js +++ b/module/macros/macro-functions.js @@ -1,3 +1,4 @@ +import { getTokenFromChatSpeaker } from "../chat/dg-chat-card.js"; import * as DGRolls from "../roll/roll.js"; const { fromUuid } = foundry.utils; @@ -108,11 +109,11 @@ export function rollItemSkillCheckMacro(itemId) { ); const skillName = item.system.skill.toString(); - + const token = getTokenFromChatSpeaker(speaker); const roll = new DGRolls.DGPercentileRoll( "1D100", {}, - { rollType: "weapon", key: skillName, actor, item }, + { rollType: "weapon", key: skillName, actor, item, token }, ); return actor.sheet.processRoll({}, roll); } @@ -130,10 +131,11 @@ export function rollSkillMacro(skillName) { if (!skill) return ui.notifications.warn("Bad skill name passed to macro."); + const token = getTokenFromChatSpeaker(speaker); const roll = new DGRolls.DGPercentileRoll( "1D100", {}, - { rollType: "skill", key: skillName, actor }, + { rollType: "skill", key: skillName, actor, token }, ); return actor.sheet.processRoll({}, roll); } @@ -166,12 +168,15 @@ export async function rollSkillTestAndDamageForOwnedItem( return ui.notifications.warn("Invalid item targeted in macro."); } + const speaker = ChatMessage.getSpeaker(); + const token = getTokenFromChatSpeaker(speaker); const rollOptions = { rollType: "weapon", key: item.system.skill, actor: item.parent, specialTrainingName: null, // Only applies to Special Training Rolls item, + token, }; const roll = new DGRolls.DGPercentileRoll("1D100", {}, rollOptions); diff --git a/module/roll/roll-classes.js b/module/roll/roll-classes.js index 1b7aa12..4ee80ff 100644 --- a/module/roll/roll-classes.js +++ b/module/roll/roll-classes.js @@ -1,10 +1,18 @@ /* eslint-disable max-classes-per-file */ +import { + createDGRollChatMessage, + prepareDGRollChatMessageData, +} from "../chat/dg-chat-card.js"; import DGUtils from "../utils/utility-functions.js"; import DG from "../config.js"; import { showDamageRollModifyDialog, showPercentileRollModifyDialog, } from "./roll-dialogs.js"; +import { + isDiceSoNiceAvailable, + waitForDiceSoNiceMessageAnimation, +} from "../utils/dice-so-nice.js"; const { renderTemplate } = foundry.applications.handlebars; @@ -33,9 +41,8 @@ export class DGRoll extends Roll { } /** - * Simple function that actually creates the message and sends it to chat. - * We override this to have a little more control over certain aspects of the message, - * right now, its `speaker` and `messageMode`. + * Posts a roll to chat with the Delta Green card shell and an explicit `rolls` + * array for Dice So Nice (listen-path compatible). * * @override * The following `@param` descriptions comes from the Foundry VTT code. @@ -48,17 +55,41 @@ export class DGRoll extends Roll { * true, or the Object of prepared chatData otherwise. */ async toMessage(messageData = {}, { messageMode, create = true } = {}) { - // eslint-disable-next-line no-param-reassign - messageData.speaker = ChatMessage.getSpeaker({ actor: this.actor }); + const label = messageData.label ?? messageData.flavor; + delete messageData.label; + const mode = messageMode ?? this.options.messageMode ?? this.options.rollMode; - const opts = { create }; - if (mode) { - opts.messageMode = foundry.dice.Roll._mapLegacyRollMode(mode); + + if (create) { + return createDGRollChatMessage({ + roll: this, + actor: this.actor, + token: this.options.token, + label, + content: messageData.content, + messageMode: mode, + flags: messageData.flags ?? {}, + }); } - return super.toMessage(messageData, opts); + + const { messageData: prepared, mappedMode } = + await prepareDGRollChatMessageData({ + roll: this, + actor: this.actor, + token: this.options.token, + label, + content: messageData.content, + messageMode: mode, + flags: messageData.flags ?? {}, + }); + + const cls = foundry.utils.getDocumentClass("ChatMessage"); + const msg = new cls(prepared); + msg.applyMode(mappedMode); + return msg.toObject(); } } @@ -187,10 +218,6 @@ export class DGPercentileRoll extends DGRoll { this.options.messageMode = "blind"; } - const diceSoNice = - game.modules.has("dice-so-nice") && - game.modules.get("dice-so-nice").active; - const label = this.createLabel(); let resultString = ""; @@ -234,6 +261,7 @@ export class DGPercentileRoll extends DGRoll { // TODO: add setting for it? if (failureMark) { const keyForUpdate = `${this.skillPath}.failure`; + const diceSoNice = isDiceSoNiceAvailable(); const message = await this.toMessage({ flags: { @@ -244,11 +272,11 @@ export class DGPercentileRoll extends DGRoll { }, }, content: html, - flavor: label, + label, }); if (diceSoNice) { - await game.dice3d.waitFor3DAnimationByMessageID(message.id); + await waitForDiceSoNiceMessageAnimation(message.id); } // TODO: auto-update actor or post icon with manual apply @@ -259,7 +287,7 @@ export class DGPercentileRoll extends DGRoll { return message; } - return this.toMessage({ content: html, flavor: label }); + return this.toMessage({ content: html, label }); } /** @@ -313,9 +341,7 @@ export class DGPercentileRoll extends DGRoll { * @returns {string} */ createLabel() { - const startOfLabel = `${game.i18n.localize("DG.Roll.Rolling")} ${ - this.localizedKey - }`; + const startOfLabel = `${this.localizedKey}`; const endOfLabel = `${game.i18n.localize("DG.Roll.Target")} ${ this.effectiveTarget }`; @@ -520,7 +546,7 @@ export class DGLethalityRoll extends DGPercentileRoll { } const { nonLethalDamage } = this; - let label = `${game.i18n.localize("DG.Roll.Rolling")} ${game.i18n + let label = `${game.i18n .localize("DG.Roll.Lethality") .toUpperCase()} ${game.i18n.localize( "DG.Roll.For", @@ -544,7 +570,7 @@ export class DGLethalityRoll extends DGPercentileRoll { }, ); - return this.toMessage({ content: html, flavor: label }); + return this.toMessage({ content: html, label }); } /** @@ -603,7 +629,7 @@ export class DGDamageRoll extends DGRoll { async toChat() { let label = this.formula; try { - label = `${game.i18n.localize("DG.Roll.Rolling")} ${game.i18n + label = `${game.i18n .localize("DG.Roll.Damage") .toUpperCase()} ${game.i18n.localize("DG.Roll.For")} ${ this.item.name @@ -612,9 +638,17 @@ export class DGDamageRoll extends DGRoll { } armor penetration)`; } catch (ex) { // console.log(ex); - label = `Rolling DAMAGE for ${label.toUpperCase()}`; + label = `DAMAGE for ${label.toUpperCase()}`; } - return this.toMessage({ content: this.total, flavor: label }); + const html = await renderTemplate( + "systems/deltagreen/templates/roll/damage-roll.hbs", + { + formula: this.formula, + total: this.total, + }, + ); + + return this.toMessage({ content: html, label }); } async showDialog() { @@ -640,7 +674,7 @@ export class DGSanityDamageRoll extends DGRoll { const [lowResult, highResult] = this.damageResults; - const flavor = `Rolling ${DGUtils.localizeWithFallback( + const label = `${DGUtils.localizeWithFallback( "DG.Generic.SanDamage", "SAN DAMAGE", )} For ${lowDie.formula} / ${highDie.formula}`; @@ -657,7 +691,7 @@ export class DGSanityDamageRoll extends DGRoll { }, ); - return this.toMessage({ content: html, flavor }); + return this.toMessage({ content: html, label }); } /** diff --git a/module/roll/roll-dialogs.js b/module/roll/roll-dialogs.js index 1472715..d8d7e25 100644 --- a/module/roll/roll-dialogs.js +++ b/module/roll/roll-dialogs.js @@ -8,6 +8,41 @@ const PERCENTILE_ROLL_DIALOG_TEMPLATE = const DAMAGE_ROLL_DIALOG_TEMPLATE = "systems/deltagreen/templates/dialog/modify-damage-roll.html"; +const QUICK_MODIFIER_PRESETS = [ + { + action: "modMinus40", + mod: -40, + label: "−40", + explanationKey: "DG.ModifySkillRollDialogue.ModifierMuchHarder", + }, + { + action: "modMinus20", + mod: -20, + label: "−20", + explanationKey: "DG.ModifySkillRollDialogue.ModifierHarder", + }, + { + action: "modPlus20", + mod: 20, + label: "+20", + explanationKey: "DG.ModifySkillRollDialogue.ModifierEasier", + }, + { + action: "modPlus40", + mod: 40, + label: "+40", + explanationKey: "DG.ModifySkillRollDialogue.ModifierMuchEasier", + }, +]; + +/** + * @param {DialogV2} dialog + * @returns {string|undefined} + */ +function readMessageMode(dialog) { + return dialog.element.querySelector("[name='messageMode']")?.value; +} + /** * @param {object} options * @param {string} options.label @@ -17,96 +52,106 @@ const DAMAGE_ROLL_DIALOG_TEMPLATE = * @param {Actor|null} [options.actor] * @returns {Promise<{ targetModifier: number, messageMode?: string }|void>} */ -export function showPercentileRollModifyDialog({ +export async function showPercentileRollModifyDialog({ label, target, hideTarget = false, defaultModifier = 20, actor = null, }) { - const backingData = { + const quickModifiers = QUICK_MODIFIER_PRESETS.map((preset) => ({ + ...preset, + explanation: game.i18n.localize(preset.explanationKey), + })); + + const content = await renderTemplate(PERCENTILE_ROLL_DIALOG_TEMPLATE, { data: { label, originalTarget: target, targetModifier: defaultModifier, hideTarget, }, + quickModifiers, + }); + + /** @type {{ targetModifier: number, messageMode?: string } | null} */ + let dialogResult = null; + + /** + * @param {DialogV2} dialog + * @param {{ targetModifier: number, messageMode?: string }} result + */ + const resolveAndClose = async (dialog, result) => { + dialogResult = result; + await dialog.close(); + return result; }; - return renderTemplate(PERCENTILE_ROLL_DIALOG_TEMPLATE, backingData).then( - (content) => - new Promise((resolve, reject) => { - const modButtons = [-40, -20, 20, 40].map((mod) => { - const sign = mod > 0 ? "Positive" : "Negative"; - return { - action: `roll${Math.abs(mod)}${sign}`, - label: mod > 0 ? `+${mod}` : String(mod), - callback: (event, button, dialog) => { - try { - const messageMode = - dialog.element.querySelector("[name='messageMode']")?.value; - resolve({ targetModifier: mod, messageMode }); - } catch (ex) { - reject(console.log(ex)); - } - }, - }; - }); + // Footer buttons are callback-only; visible controls live in modify-percentile-roll.html. + const hiddenFooterButton = { class: "hidden" }; - new DialogV2({ - content, - window: { - title: DGUtils.localizeWithFallback( - "DG.ModifySkillRollDialogue.Title", - "Modify Roll", - ), - }, - buttons: [ - { - default: true, - action: "roll", - label: DGUtils.localizeWithFallback("DG.Roll.Roll", "Roll"), - callback: (event, button, dialog) => { - try { - let targetModifier = dialog.element.querySelector( - "[name='targetModifier']", - )?.value; - - const messageMode = - dialog.element.querySelector("[name='messageMode']")?.value; - - const plusMinus = dialog.element.querySelector( - "[name='plusOrMinus']", - )?.value; - - if ( - targetModifier.trim() !== "" && - !Number.isNaN(targetModifier) - ) { - targetModifier = Math.abs(parseInt(targetModifier, 10)); - - if (plusMinus === "-") { - targetModifier *= -1; - } - - if (actor != null) { - actor.update({ - "system.settings.rolling.defaultPercentileModifier": - targetModifier, - }); - } - } - resolve({ targetModifier, messageMode }); - } catch (ex) { - reject(console.log(ex)); - } - }, - }, - ...modButtons, - ], - }).render(true); + const presetButtons = QUICK_MODIFIER_PRESETS.map(({ action, mod }) => ({ + action, + label: "", + ...hiddenFooterButton, + callback: async (_event, _button, dialog) => + resolveAndClose(dialog, { + targetModifier: mod, + messageMode: readMessageMode(dialog), }), - ); + })); + + return DialogV2.wait({ + content, + classes: ["modify-roll-dialog-app"], + form: { closeOnSubmit: false }, + position: { width: 420 }, + window: { + title: DGUtils.localizeWithFallback( + "DG.ModifySkillRollDialogue.Title", + "Modify Roll", + ), + }, + close: () => dialogResult, + buttons: [ + { + default: true, + action: "roll", + label: "", + ...hiddenFooterButton, + callback: async (_event, _button, dialog) => { + const raw = + dialog.element + .querySelector("[name='targetModifier']") + ?.value?.trim() ?? ""; + const targetModifier = Number(raw); + + if (raw === "" || Number.isNaN(targetModifier)) { + ui.notifications.warn( + DGUtils.localizeWithFallback( + "DG.ModifySkillRollDialogue.InvalidModifier", + "Enter a valid modifier to roll.", + ), + ); + return false; + } + + if (actor != null) { + await actor.update({ + "system.settings.rolling.defaultPercentileModifier": + targetModifier, + }); + } + + return resolveAndClose(dialog, { + targetModifier, + messageMode: readMessageMode(dialog), + }); + }, + }, + ...presetButtons, + ], + }); } /** @@ -115,63 +160,55 @@ export function showPercentileRollModifyDialog({ * @param {string} options.formula * @returns {Promise<{ newFormula: string, messageMode?: string }|void>} */ -export function showDamageRollModifyDialog({ itemName, formula }) { - const backingData = { +export async function showDamageRollModifyDialog({ itemName, formula }) { + const content = await renderTemplate(DAMAGE_ROLL_DIALOG_TEMPLATE, { data: { label: itemName, originalFormula: formula, outerModifier: "2 * ", innerModifier: "+ 0", }, - }; + }); - return renderTemplate(DAMAGE_ROLL_DIALOG_TEMPLATE, backingData).then( - (content) => - new Promise((resolve, reject) => { - new DialogV2({ - content, - window: { - title: game.i18n.localize("DG.ModifySkillRollDialogue.Title"), - }, - buttons: [ - { - default: true, - label: game.i18n.translations.DG.Roll.Roll, - action: "roll", - callback: (event, button, dialog) => { - try { - const outerModifier = dialog.element.querySelector( - "[name='outerModifier']", - )?.value; - let innerModifier = dialog.element.querySelector( - "[name='innerModifier']", - )?.value; - const modifiedBaseRoll = dialog.element.querySelector( - "[name='originalFormula']", - )?.value; - const messageMode = dialog.element.querySelector( - "[name='targetRollMode']", - )?.value; + return DialogV2.wait({ + content, + window: { + title: game.i18n.localize("DG.ModifySkillRollDialogue.Title"), + }, + close: () => null, + buttons: [ + { + default: true, + label: game.i18n.translations.DG.Roll.Roll, + action: "roll", + callback: (_event, _button, dialog) => { + const outerModifier = dialog.element.querySelector( + "[name='outerModifier']", + )?.value; + let innerModifier = dialog.element.querySelector( + "[name='innerModifier']", + )?.value; + const modifiedBaseRoll = dialog.element.querySelector( + "[name='originalFormula']", + )?.value; + const messageMode = dialog.element.querySelector( + "[name='targetRollMode']", + )?.value; - if (innerModifier.replace(" ", "") === "+0") { - innerModifier = ""; - } + if (innerModifier.replace(" ", "") === "+0") { + innerModifier = ""; + } - let newFormula = ""; - if (outerModifier.trim() !== "") { - newFormula += `${outerModifier}(${modifiedBaseRoll}${innerModifier.trim()})`; - } else { - newFormula += modifiedBaseRoll + innerModifier.trim(); - } + let newFormula = ""; + if (outerModifier.trim() !== "") { + newFormula += `${outerModifier}(${modifiedBaseRoll}${innerModifier.trim()})`; + } else { + newFormula += modifiedBaseRoll + innerModifier.trim(); + } - resolve({ newFormula, messageMode }); - } catch (ex) { - reject(console.log(ex)); - } - }, - }, - ], - }).render(true); - }), - ); + return { newFormula, messageMode }; + }, + }, + ], + }); } diff --git a/module/roll/roll.js b/module/roll/roll.js index 6f7708c..83c7a1b 100644 --- a/module/roll/roll.js +++ b/module/roll/roll.js @@ -27,7 +27,13 @@ export { */ export function createDGRollFromDataset( dataset, - { actor = null, item = null, element = null, sanityDamageSource = "actor" } = {}, + { + actor = null, + item = null, + element = null, + sanityDamageSource = "actor", + token = null, + } = {}, ) { const rollOptions = { rollType: dataset.rolltype, @@ -35,6 +41,7 @@ export function createDGRollFromDataset( actor, specialTrainingName: dataset?.name || null, item, + token, }; let roll = new Roll("1d100", {}); diff --git a/module/roll/skill-improvement-roll.js b/module/roll/skill-improvement-roll.js index e295106..e688369 100644 --- a/module/roll/skill-improvement-roll.js +++ b/module/roll/skill-improvement-roll.js @@ -1,3 +1,4 @@ +import { createDGChatMessage } from "../chat/dg-chat-card.js"; import DG from "../config.js"; /** @@ -86,21 +87,18 @@ export async function createSkillImprovementChatMessage({ const failedTypedSkillNames = localizeFailedSkills(failedTypedSkills); const content = [...failedSkillNames, ...failedTypedSkillNames].join(", "); - const flavor = game.i18n.format( + const label = game.i18n.format( "DG.Skills.ApplySkillImprovements.ChatFlavor", { formula: getSkillImprovementFormulaAsPercent(baseFormula), }, ); - return ChatMessage.create({ - speaker: ChatMessage.getSpeaker({ - actor, - token, - alias: actor.name, - }), + return createDGChatMessage({ + actor, + token, + label, content, - flavor, messageMode: game.settings.get("core", "messageMode"), }); } diff --git a/module/sheets/agent-sheet.js b/module/sheets/agent-sheet.js index fb1a7c4..5938d7b 100644 --- a/module/sheets/agent-sheet.js +++ b/module/sheets/agent-sheet.js @@ -15,6 +15,8 @@ import { getEffectiveSuppressExhaustion, hasActiveStimulantEffect, } from "../utils/stimulant-effect.js"; +import { assignProfessionToAgent } from "../applications/profession-setup-flow.js"; +import { PROFESSION_OPTION_PICKS_KEY } from "../data/item/profession.js"; const { DialogV2 } = foundry.applications.api; const { renderTemplate } = foundry.applications.handlebars; @@ -50,6 +52,7 @@ export default class DGAgentSheet extends AgentSheetBase { exhaustAgent: DGAgentSheet._exhaustAgent, restAgent: DGAgentSheet._restAgent, takeStimulants: DGAgentSheet._takeStimulants, + openProfessionItem: DGAgentSheet._openProfessionItem, }, }); @@ -87,6 +90,7 @@ export default class DGAgentSheet extends AgentSheetBase { `${this.TEMPLATE_PATH}/partials/left-bar-exhaustion-section.html`, `${this.TEMPLATE_PATH}/partials/left-bar-rest-tab.html`, `${this.TEMPLATE_PATH}/partials/left-bar-sanity-tab.html`, + `${this.TEMPLATE_PATH}/partials/profession-subname.html`, ], }, tabs: this.BASE_PARTS.tabs, @@ -124,7 +128,11 @@ export default class DGAgentSheet extends AgentSheetBase { _prepareTabs(group) { const tabs = super._prepareTabs(group); - if (!this.actor.limited || this.actor.type !== "agent" || group !== "primary") { + if ( + !this.actor.limited || + this.actor.type !== "agent" || + group !== "primary" + ) { return tabs; } @@ -145,6 +153,9 @@ export default class DGAgentSheet extends AgentSheetBase { context.typedSkillColumns = this._prepareTypedSkillColumns(); context.customSkillsEditMode = this._customSkillsEditMode; context.physicalUi = this._preparePhysicalUi(); + context.professionItem = this.actor.items.find( + (i) => i.type === "profession", + ); return context; } @@ -190,7 +201,8 @@ export default class DGAgentSheet extends AgentSheetBase { static async _toggleAcuteEpisode(event, target) { event.preventDefault(); const itemId = - target.dataset.itemId ?? target.closest("[data-item-id]")?.dataset?.itemId; + target.dataset.itemId ?? + target.closest("[data-item-id]")?.dataset?.itemId; const item = this.actor.items.get(itemId); if (!item || item.type !== "motivation") return; await item.update({ @@ -198,6 +210,18 @@ export default class DGAgentSheet extends AgentSheetBase { }); } + /** + * @param {PointerEvent} _event + * @param {HTMLElement} target + */ + static _openProfessionItem(_event, target) { + const itemId = + target.dataset.itemId ?? + target.closest("[data-item-id]")?.dataset?.itemId; + const item = this.actor.items.get(itemId); + if (item?.type === "profession") item.sheet.render(true); + } + static _clearBondDamage() { for (const bond of this.actor.itemTypes.bond) { if (!bond.system.hasBeenDamagedSinceLastHomeScene) continue; @@ -329,7 +353,7 @@ export default class DGAgentSheet extends AgentSheetBase { } static async _exhaustAgent() { - const actor = this.actor; + const { actor } = this; const content = await renderTemplate( `${BASE_TEMPLATE_PATH}/dialog/exhaust-agent.html`, { @@ -350,7 +374,9 @@ export default class DGAgentSheet extends AgentSheetBase { default: true, icon: "fas fa-dice", callback: (_event, _button, dialog) => { - const input = dialog.element.querySelector('[name="exhaustionPenalty"]'); + const input = dialog.element.querySelector( + '[name="exhaustionPenalty"]', + ); penalty = DGAgentSheet._normalizeExhaustionPenalty(input?.value); return true; }, @@ -376,6 +402,7 @@ export default class DGAgentSheet extends AgentSheetBase { actor, token: this.token, contentKey: "DG.Physical.Chat.Exhausted", + labelKey: "DG.Physical.Chat.ExhaustedLabel", i18nData: { name: actor.name, loss, @@ -386,7 +413,7 @@ export default class DGAgentSheet extends AgentSheetBase { } static async _restAgent() { - const actor = this.actor; + const { actor } = this; if (getEffectiveSuppressExhaustion(actor)) { ui.notifications.warn( @@ -415,6 +442,7 @@ export default class DGAgentSheet extends AgentSheetBase { actor, token: this.token, contentKey: "DG.Physical.Chat.Rested", + labelKey: "DG.Physical.Chat.RestedLabel", i18nData: { name: actor.name, gain, @@ -433,7 +461,7 @@ export default class DGAgentSheet extends AgentSheetBase { } static async _takeStimulants() { - const actor = this.actor; + const { actor } = this; if (!DGAgentSheet._isActorExhausted(actor)) { ui.notifications.info( @@ -482,10 +510,16 @@ export default class DGAgentSheet extends AgentSheetBase { const appliedHours = await applyStimulantEffect(actor, hours); + const labelKey = + choice === "hard" + ? "DG.Physical.Chat.StimulantsHardLabel" + : "DG.Physical.Chat.StimulantsRegularLabel"; + await createAgentResourceChatMessage({ actor, token: this.token, contentKey, + labelKey, i18nData: { name: actor.name, hours: appliedHours, @@ -621,6 +655,60 @@ export default class DGAgentSheet extends AgentSheetBase { }); } + /** + * @param {DragEvent} event + * @param {Item} item + * @returns {Promise} + */ + async _onDropItem(event, item) { + const dropType = item.type ?? item.system?.type; + if (dropType !== "profession") { + return super._onDropItem(event, item); + } + + const itemData = + typeof item.toObject === "function" + ? item.toObject() + : foundry.utils.duplicate(item); + + return assignProfessionToAgent(this.actor, itemData, { + token: this.token, + }); + } + + /** + * @param {string} type + * @returns {Promise} + */ + _onItemCreate(type) { + if (type !== "profession") { + return super._onItemCreate(type); + } + + const name = game.i18n.format( + game.i18n.translations.DOCUMENT?.New || "DG.FallbackText.newItem", + { + type: game.i18n.localize("TYPES.Item.profession"), + }, + ); + + const itemData = { + name, + type: "profession", + system: { + bonds: 0, + automaticSkills: {}, + automaticSkillMeta: {}, + optionSkills: { [PROFESSION_OPTION_PICKS_KEY]: 0 }, + optionSkillMeta: {}, + }, + }; + + return assignProfessionToAgent(this.actor, itemData, { + token: this.token, + }).then((created) => (created ? [created] : [])); + } + _applySkillImprovements(failedSkills, failedTypedSkills, resultObj) { const updateSkills = (skillsArray, updatedTarget) => { skillsArray.forEach((skill) => { diff --git a/module/sheets/base-item-sheet.js b/module/sheets/base-item-sheet.js index e333a91..aae8d10 100644 --- a/module/sheets/base-item-sheet.js +++ b/module/sheets/base-item-sheet.js @@ -1,6 +1,8 @@ import { BASE_TEMPLATE_PATH } from "../config.js"; import DGSheetMixin from "./base-sheet.js"; import EffectsTabMixin from "./mixins/effects-tab-mixin.js"; +import ProfessionItemMixin from "./mixins/profession-item-mixin.js"; +import { getDGRollToken } from "../chat/dg-chat-card.js"; import { createDGRollFromDataset, processDGRoll } from "../roll/roll.js"; const { ItemSheetV2 } = foundry.applications.sheets; @@ -128,9 +130,32 @@ const ITEM_SHEET_LAYOUT = /** @type {const} */ ({ }, }, }, + profession: { + tabs: { + initial: "description", + tabs: [ + { id: "description", label: "DG.ItemWindow.Profession.Description" }, + { id: "skills", label: "DG.ItemWindow.Profession.SkillsTab" }, + ], + }, + parts: { + header: { template: `${ITEM_PARTS_PATH}/profession-header.html` }, + ...TABS_PART, + description: { + template: `${ITEM_PARTS_PATH}/profession-description.html`, + scrollable: [""], + }, + skills: { + template: `${ITEM_PARTS_PATH}/profession-skills-tab.html`, + scrollable: [".profession-skills-scroll"], + }, + }, + }, }); -const ItemSheetBase = EffectsTabMixin(DGSheetMixin(ItemSheetV2)); +const ItemSheetBase = ProfessionItemMixin( + EffectsTabMixin(DGSheetMixin(ItemSheetV2)), +); /** * @extends {ItemSheetBase} @@ -143,6 +168,10 @@ export default class DGItemSheet extends ItemSheetBase { actions: { roll: DGItemSheet._onRoll, toggleTomeRevealed: DGItemSheet._toggleTomeRevealed, + addAutomaticSkill: DGItemSheet.addAutomaticSkill, + addOptionSkill: DGItemSheet.addOptionSkill, + removeAutomaticSkill: DGItemSheet.removeAutomaticSkill, + removeOptionSkill: DGItemSheet.removeOptionSkill, createEffect: ItemSheetBase.createEffect, openEffect: ItemSheetBase.openEffect, deleteEffect: ItemSheetBase.deleteEffect, @@ -246,6 +275,7 @@ export default class DGItemSheet extends ItemSheetBase { item: this.item, element: target, sanityDamageSource: "item", + token: getDGRollToken(this.actor, this.token), }); await processDGRoll(event, roll); } diff --git a/module/sheets/mixins/profession-item-mixin.js b/module/sheets/mixins/profession-item-mixin.js new file mode 100644 index 0000000..34eb572 --- /dev/null +++ b/module/sheets/mixins/profession-item-mixin.js @@ -0,0 +1,277 @@ +import { BASE_TEMPLATE_PATH } from "../../config.js"; +import { PROFESSION_OPTION_PICKS_KEY } from "../../data/item/profession.js"; +import { + allocateProfessionSkillStorageKey, + buildSkillCatalog, + catalogIdToSkillRef, + findTypedSkillNameConflict, + prepareProfessionSkillRows, + splitProfessionSkillMap, +} from "../../utils/profession-skills.js"; + +const { DialogV2 } = foundry.applications.api; +const { renderTemplate } = foundry.applications.handlebars; +const { ForcedDeletion } = foundry.data.operators; + +/** @param {typeof foundry.applications.api.ApplicationV2} Base */ +export default function ProfessionItemMixin(Base) { + return class extends Base { + /** @inheritdoc */ + async _preparePartContext(partId, context, options) { + const partContext = await super._preparePartContext(partId, context, options); + if (this.item.type !== "profession" || partId !== "skills") { + return partContext; + } + + const automatic = this.item.system.automaticSkills ?? {}; + const automaticMeta = this.item.system.automaticSkillMeta ?? {}; + const { optionPicks, skills: optionSkillMap } = splitProfessionSkillMap( + this.item.system.optionSkills ?? {}, + ); + const optionMeta = this.item.system.optionSkillMeta ?? {}; + + partContext.automaticSkillRows = prepareProfessionSkillRows( + automatic, + automaticMeta, + ).map((row) => ({ + ...row, + isChooseOne: Boolean(automaticMeta[row.key]?.chooseOne), + })); + partContext.optionSkillRows = prepareProfessionSkillRows( + optionSkillMap, + optionMeta, + ).map((row) => ({ + ...row, + isChooseOne: Boolean(optionMeta[row.key]?.chooseOne), + })); + partContext.optionPicks = optionPicks; + + return partContext; + } + + /** @param {PointerEvent} _event */ + static addAutomaticSkill(_event) { + return this._showAddProfessionSkillDialog("automatic"); + } + + /** @param {PointerEvent} _event */ + static addOptionSkill(_event) { + return this._showAddProfessionSkillDialog("option"); + } + + /** + * @param {"automatic" | "option"} section + */ + async _showAddProfessionSkillDialog(section) { + const catalog = buildSkillCatalog(); + const content = await renderTemplate( + `${BASE_TEMPLATE_PATH}/dialog/add-profession-skill.html`, + { catalog, isTyped: false }, + ); + + const confirmed = await DialogV2.wait({ + content, + position: { width: 300 }, + classes: ["add-profession-skill-dialog-app"], + window: { + title: game.i18n.localize("DG.ItemWindow.Profession.AddSkillTitle"), + }, + form: { closeOnSubmit: false }, + render: (_event, dialog) => { + const root = dialog.element?.querySelector(".dialog-content"); + const select = root?.querySelector('[name="skillCatalogId"]'); + const typedGroup = root?.querySelector(".profession-typed-label-group"); + const typeInput = root?.querySelector('[name="typedSkillLabel"]'); + const chooseOneInput = root?.querySelector('[name="chooseOne"]'); + + const syncTypedVisibility = () => { + const isTyped = select?.value.startsWith("typed:") ?? false; + typedGroup?.classList.toggle("hidden", !isTyped); + if (!isTyped) { + if (chooseOneInput) chooseOneInput.checked = false; + if (typeInput) { + typeInput.value = ""; + typeInput.disabled = false; + } + } + }; + + const syncChooseOne = () => { + const chooseOne = Boolean(chooseOneInput?.checked); + if (typeInput) { + typeInput.disabled = chooseOne; + if (chooseOne) typeInput.value = ""; + } + }; + + select?.addEventListener("change", syncTypedVisibility); + chooseOneInput?.addEventListener("change", syncChooseOne); + syncTypedVisibility(); + syncChooseOne(); + }, + close: () => false, + buttons: [ + { + action: "ok", + label: game.i18n.localize("DG.Profession.Dialog.Submit"), + default: true, + callback: async (_event, _button, dialog) => { + const root = dialog.element.querySelector(".dialog-content"); + const select = root?.querySelector('[name="skillCatalogId"]'); + const ratingInput = root?.querySelector('[name="skillRating"]'); + const typeInput = root?.querySelector('[name="typedSkillLabel"]'); + const chooseOneInput = root?.querySelector('[name="chooseOne"]'); + + const catalogId = select?.value ?? ""; + const rating = Number(ratingInput?.value); + const typedLabel = typeInput?.value?.trim() ?? ""; + const chooseOne = Boolean(chooseOneInput?.checked); + + if (!catalogId || Number.isNaN(rating)) return false; + + const isTyped = catalogId.startsWith("typed:"); + if (isTyped && !chooseOne && !typedLabel) { + ui.notifications.warn( + game.i18n.localize("DG.ItemWindow.Profession.TypedSkillRequired"), + ); + return false; + } + + const ref = catalogIdToSkillRef(catalogId, chooseOne ? "" : typedLabel, { + allowEmptyTypedLabel: chooseOne, + }); + if (!ref) return false; + + const skillKey = allocateProfessionSkillStorageKey(ref, { + chooseOne, + }); + + const automaticSkills = { + ...(this.item.system.automaticSkills ?? {}), + }; + const optionSkills = { ...(this.item.system.optionSkills ?? {}) }; + const automaticMeta = { + ...(this.item.system.automaticSkillMeta ?? {}), + }; + const optionMeta = { ...(this.item.system.optionSkillMeta ?? {}) }; + + const conflict = + ref.kind === "typed" + ? findTypedSkillNameConflict({ + group: ref.group, + label: chooseOne ? "" : typedLabel, + chooseOne, + automaticSkills, + optionSkills, + automaticMeta, + optionMeta, + }) + : null; + + if (conflict) { + ui.notifications.warn( + game.i18n.format( + "DG.ItemWindow.Profession.TypedSkillNameConflict", + { name: typedLabel }, + ), + { localize: true }, + ); + return false; + } + + const skillField = + section === "automatic" ? "automaticSkills" : "optionSkills"; + const metaField = + section === "automatic" + ? "automaticSkillMeta" + : "optionSkillMeta"; + + const currentSkills = + section === "automatic" ? automaticSkills : optionSkills; + const currentMeta = + section === "automatic" ? automaticMeta : optionMeta; + + if ( + section === "option" && + currentSkills[PROFESSION_OPTION_PICKS_KEY] === undefined + ) { + currentSkills[PROFESSION_OPTION_PICKS_KEY] = 0; + } + + currentSkills[skillKey] = rating; + if (chooseOne) currentMeta[skillKey] = { chooseOne: true }; + else delete currentMeta[skillKey]; + + await this.item.update({ + [`system.${skillField}`]: currentSkills, + [`system.${metaField}`]: currentMeta, + }); + await dialog.close(); + return true; + }, + }, + { + action: "cancel", + label: game.i18n.localize("Cancel"), + callback: async (_event, _button, dialog) => { + await dialog.close(); + return false; + }, + }, + ], + }); + + if (confirmed) this.render(); + } + + /** + * @param {HTMLElement} target + * @returns {string|undefined} + */ + #getProfessionSkillKeyFromTarget(target) { + return target.closest("[data-skill-key]")?.dataset.skillKey; + } + + /** + * @param {"automaticSkills" | "optionSkills"} field + * @param {string} skillKey + */ + async #removeProfessionSkill(field, skillKey) { + const metaField = + field === "automaticSkills" ? "automaticSkillMeta" : "optionSkillMeta"; + + await this.item.update({ + system: { + [field]: { + [skillKey]: new ForcedDeletion(), + }, + [metaField]: { + [skillKey]: new ForcedDeletion(), + }, + }, + }); + return this.render(); + } + + /** + * @param {PointerEvent} event + * @param {HTMLElement} target + */ + static removeAutomaticSkill(event, target) { + const skillKey = this.#getProfessionSkillKeyFromTarget(target); + if (!skillKey) return; + return this.#removeProfessionSkill("automaticSkills", skillKey); + } + + /** + * @param {PointerEvent} event + * @param {HTMLElement} target + */ + static removeOptionSkill(event, target) { + const skillKey = this.#getProfessionSkillKeyFromTarget(target); + if (!skillKey) return; + if (skillKey === PROFESSION_OPTION_PICKS_KEY) return; + return this.#removeProfessionSkill("optionSkills", skillKey); + } + }; +} diff --git a/module/sheets/mixins/roll-sheet-mixin.js b/module/sheets/mixins/roll-sheet-mixin.js index f6235b8..210c386 100644 --- a/module/sheets/mixins/roll-sheet-mixin.js +++ b/module/sheets/mixins/roll-sheet-mixin.js @@ -1,3 +1,4 @@ +import { getDGRollToken } from "../../chat/dg-chat-card.js"; import { createDGRollFromDataset, processDGRoll, @@ -46,6 +47,7 @@ export default function RollSheetMixin(Base) { item, element: target, sanityDamageSource: "actor", + token: getDGRollToken(this.actor, this.token), }); await this.processRoll(event, roll); } @@ -55,7 +57,10 @@ export default function RollSheetMixin(Base) { const roll = createDGRollFromDataset( { rolltype: "luck", key: "luck" }, - { actor: this.actor }, + { + actor: this.actor, + token: getDGRollToken(this.actor, this.token), + }, ); await this.processRoll(event, roll); } diff --git a/module/utils/dice-so-nice.js b/module/utils/dice-so-nice.js new file mode 100644 index 0000000..9c2f32d --- /dev/null +++ b/module/utils/dice-so-nice.js @@ -0,0 +1,86 @@ +import { getDGSpeaker } from "../chat/dg-chat-card.js"; + +/** + * Whether Dice So Nice is active and its Roll API is available. + * @returns {boolean} + */ +export function isDiceSoNiceAvailable() { + return Boolean( + game.modules.get("dice-so-nice")?.active && + typeof game.dice3d?.showForRoll === "function", + ); +} + +/** + * Show a 3D dice animation for a Foundry Roll via the DSN Roll API. + * Used for rolls that are not posted as chat messages (e.g. stat generation). + * Chat-based rolls should rely on DSN's createChatMessage hook instead. + * + * @see https://gitlab.com/riccisi/foundryvtt-dice-so-nice/-/wikis/Integration + * @see https://gitlab.com/riccisi/foundryvtt-dice-so-nice/-/wikis/API/Roll + * @param {Roll} roll + * @param {object} [options] + * @param {boolean} [options.synchronize=true] Broadcast animation to other clients. + * @param {ChatMessage.SpeakerData|null} [options.speaker] Chat speaker for NPC roll hiding. + * @param {string[]|null} [options.whisper] User IDs who can see the roll; null for everyone. + * @param {boolean} [options.blind=false] Whether the roll is blind for the rolling user. + * @param {string|null} [options.chatMessageId] Chat message to reveal when the animation ends. + * @returns {Promise} True if animation was displayed. + */ +export async function showDiceSoNiceRoll( + roll, + { + synchronize = true, + speaker = null, + whisper = null, + blind = false, + chatMessageId = null, + } = {}, +) { + if (!isDiceSoNiceAvailable()) return false; + + return game.dice3d.showForRoll( + roll, + game.user, + synchronize, + whisper, + blind, + chatMessageId, + speaker ?? undefined, + { ghost: false, secret: false }, + ); +} + +/** + * Merge evaluated rolls into a pool and show one 3D animation for all of them. + * + * @see https://gitlab.com/riccisi/foundryvtt-dice-so-nice/-/wikis/Integration#step-4-select-roll-order-for-multiple-rolls + * @param {Roll[]} rolls + * @param {object} [options] + * @param {Actor|null} [options.actor] + * @param {TokenDocument|null} [options.token] + * @returns {Promise} + */ +export async function showDiceSoNicePooledRolls( + rolls, + { actor = null, token = null } = {}, +) { + if (!isDiceSoNiceAvailable() || !rolls?.length) return; + + const pool = foundry.dice.terms.PoolTerm.fromRolls(rolls); + const pooledRoll = Roll.fromTerms([pool]); + const speaker = actor ? getDGSpeaker({ actor, token }) : null; + await showDiceSoNiceRoll(pooledRoll, { speaker }); +} + +/** + * Wait for the 3D animation tied to a chat message, if DSN is available. + * + * @param {string} messageId + * @returns {Promise} + */ +export async function waitForDiceSoNiceMessageAnimation(messageId) { + if (!isDiceSoNiceAvailable()) return; + if (typeof game.dice3d.waitFor3DAnimationByMessageID !== "function") return; + await game.dice3d.waitFor3DAnimationByMessageID(messageId); +} diff --git a/module/utils/profession-skills.js b/module/utils/profession-skills.js new file mode 100644 index 0000000..ee3c946 --- /dev/null +++ b/module/utils/profession-skills.js @@ -0,0 +1,918 @@ +import DG from "../config.js"; +import { PROFESSION_OPTION_PICKS_KEY } from "../data/item/profession.js"; + +/** @typedef {"fixed" | "typed"} ProfessionSkillKind */ + +/** @typedef {{ kind: "fixed", key: string }} FixedSkillRef */ + +/** @typedef {{ kind: "typed", group: string, label: string }} TypedSkillRef */ + +/** @typedef {FixedSkillRef | TypedSkillRef} ProfessionSkillRef */ + +/** @typedef {{ chooseOne?: boolean }} ProfessionSkillMeta */ + +/** Typed skill template groups (camelCase storage keys). */ +export const TYPED_SKILL_TEMPLATE_GROUPS = /** @type {const} */ ([ + "Art", + "Craft", + "ForeignLanguage", + "MilitaryScience", + "Pilot", + "Science", + "Other", +]); + +const TYPED_GROUP_I18N = { + Art: "DG.TypeSkills.Art", + Craft: "DG.TypeSkills.Craft", + ForeignLanguage: "DG.TypeSkills.ForeignLanguage", + MilitaryScience: "DG.TypeSkills.MilitaryScience", + Pilot: "DG.TypeSkills.Pilot", + Science: "DG.TypeSkills.Science", + Other: "DG.TypeSkills.Other", +}; + +/** Agent default skill ratings (from human-skills schema initials). */ +const AGENT_SKILL_DEFAULTS = /** @type {Record} */ ({ + accounting: 10, + alertness: 20, + anthropology: 0, + archeology: 0, + artillery: 0, + athletics: 30, + bureaucracy: 10, + computer_science: 0, + criminology: 10, + demolitions: 0, + disguise: 10, + dodge: 30, + drive: 20, + firearms: 20, + first_aid: 10, + forensics: 0, + heavy_machiner: 10, + heavy_weapons: 0, + history: 10, + humint: 10, + law: 0, + medicine: 0, + melee_weapons: 30, + navigate: 10, + occult: 10, + persuade: 20, + pharmacy: 0, + psychotherapy: 10, + ride: 10, + search: 20, + sigint: 0, + stealth: 10, + surgery: 0, + survival: 10, + swim: 20, + unarmed_combat: 40, + unnatural: 0, +}); + +const BONUS_SKILL_COUNT = 8; +const BONUS_SKILL_INCREMENT = 20; +const SKILL_CAP = 80; + +const TYPED_KEY_PATTERN = + /^(Art|Craft|Foreign Language|ForeignLanguage|Military Science|MilitaryScience|Pilot|Science|Other)\s*\(([^)]+)\)\s*$/i; + +/** Legacy internal keys from earlier builds (`typed:ForeignLanguage:abc12345`). */ +const INTERNAL_TYPED_KEY_PATTERN = + /^typed:([A-Za-z]+):([A-Za-z0-9]{8,16})(?::(.+))?$/; + +/** + * @returns {string} + */ +export function getChooseOnePlaceholderLabel() { + return game.i18n.localize("DG.Profession.ChooseOnePlaceholder"); +} + +/** + * @param {string} label + * @returns {string} + */ +export function normalizeTypedSkillName(label) { + return label.trim().toLocaleLowerCase(game.i18n.lang); +} + +/** + * @param {string} label + * @returns {boolean} + */ +export function isChooseOnePlaceholderLabel(label) { + return ( + normalizeTypedSkillName(label) === + normalizeTypedSkillName(getChooseOnePlaceholderLabel()) + ); +} + +/** + * @param {string} mapKey + * @param {Record} automaticMeta + * @param {Record} optionMeta + * @returns {boolean} + */ +export function isChooseOneProfessionSkillKey( + mapKey, + automaticMeta = {}, + optionMeta = {}, +) { + return Boolean( + automaticMeta[mapKey]?.chooseOne ?? optionMeta[mapKey]?.chooseOne, + ); +} + +/** + * @param {string} mapKey + * @param {Record} automaticMeta + * @param {Record} optionMeta + * @returns {ProfessionSkillMeta} + */ +export function getProfessionSkillMeta(mapKey, automaticMeta, optionMeta) { + return automaticMeta[mapKey] ?? optionMeta[mapKey] ?? {}; +} + +/** + * @param {object} params + * @param {string} params.group + * @param {string} params.label + * @param {boolean} params.chooseOne + * @param {Record} params.automaticSkills + * @param {Record} params.optionSkills + * @param {Record} [params.automaticMeta] + * @param {Record} [params.optionMeta] + * @param {string} [params.excludeKey] + * @returns {string | null} Conflicting map key, if any. + */ +export function findTypedSkillNameConflict({ + group, + label, + chooseOne, + automaticSkills, + optionSkills, + automaticMeta = {}, + optionMeta = {}, + excludeKey = null, +}) { + // Multiple choose-one slots per category are allowed on the profession item. + if (chooseOne) return null; + if (!group) return null; + + const normalizedGroup = normalizeTypedGroup(group); + const normalizedNew = normalizeTypedSkillName(label); + if (!normalizedNew) return "__empty__"; + + const maps = [ + [automaticSkills, automaticMeta], + [optionSkills, optionMeta], + ]; + + for (const [skillMap, metaMap] of maps) { + for (const mapKey of Object.keys(skillMap ?? {})) { + if (mapKey === excludeKey || mapKey === PROFESSION_OPTION_PICKS_KEY) continue; + + const ref = parseProfessionSkillKey(mapKey); + if (!ref || ref.kind !== "typed") continue; + if (normalizeTypedGroup(ref.group) !== normalizedGroup) continue; + + // Unresolved choose-one slots have no fixed name yet. + if (metaMap[mapKey]?.chooseOne) continue; + + if (normalizeTypedSkillName(ref.label) === normalizedNew) return mapKey; + } + } + + return null; +} + +/** + * @returns {Record} + */ +export function getAgentSkillDefaults() { + return { ...AGENT_SKILL_DEFAULTS }; +} + +/** + * @param {string} group + * @returns {string} + */ +export function normalizeTypedGroup(group) { + if (group == null) return ""; + const trimmed = String(group).trim(); + const map = { + "Foreign Language": "ForeignLanguage", + "Military Science": "MilitaryScience", + }; + return map[trimmed] ?? trimmed.replace(/\s+/g, ""); +} + +/** + * @param {string} group + * @returns {string} + */ +export function getTypedGroupDisplayName(group) { + const key = normalizeTypedGroup(group); + const i18nKey = TYPED_GROUP_I18N[key]; + if (i18nKey) return game.i18n.localize(i18nKey); + return group; +} + +/** + * @param {ProfessionSkillRef} ref + * @returns {string} + */ +export function formatProfessionSkillKey(ref) { + if (ref.kind === "fixed") return ref.key; + const groupLabel = getTypedGroupDisplayName(ref.group); + return `${groupLabel} (${ref.label.trim()})`; +} + +/** + * Storage key for a profession skill map entry. + * + * @param {ProfessionSkillRef} ref + * @param {{ chooseOne?: boolean }} [options] + * @returns {string} + */ +export function allocateProfessionSkillStorageKey(ref, { chooseOne = false } = {}) { + if (ref.kind === "fixed") return ref.key; + if (chooseOne) { + return `typed:${normalizeTypedGroup(ref.group)}:${foundry.utils.randomID(8)}`; + } + const label = ref.label?.trim() ?? ""; + return formatProfessionSkillKey({ + kind: "typed", + group: ref.group, + label, + }); +} + +/** + * @param {string} key + * @returns {ProfessionSkillRef | null} + */ +export function parseProfessionSkillKey(key) { + if (!key || key === PROFESSION_OPTION_PICKS_KEY) return null; + if (DG.skills.includes(key)) return { kind: "fixed", key }; + + const internalMatch = key.match(INTERNAL_TYPED_KEY_PATTERN); + if (internalMatch) { + let label = ""; + if (internalMatch[3] !== undefined) { + try { + label = decodeURIComponent(internalMatch[3]); + } catch { + label = internalMatch[3]; + } + } + return { + kind: "typed", + group: internalMatch[1], + label, + }; + } + + const match = key.match(TYPED_KEY_PATTERN); + if (match) { + return { + kind: "typed", + group: normalizeTypedGroup(match[1]), + label: match[2].trim(), + }; + } + return null; +} + +/** + * @param {ProfessionSkillRef} ref + * @returns {string} + */ +export function formatProfessionSkillLabel(ref) { + if (ref.kind === "fixed") { + if (game.i18n.lang === "ja") { + const ja = game.i18n.localize(`DG.Skills.ruby.${ref.key}`); + if (ja && ja !== `DG.Skills.ruby.${ref.key}`) return ja; + } + const label = game.i18n.localize(`DG.Skills.${ref.key}`); + if (label && label !== `DG.Skills.${ref.key}`) return label; + return ref.key; + } + if (!ref.label?.trim()) return getTypedGroupDisplayName(ref.group); + return formatProfessionSkillKey(ref); +} + +/** + * @param {string} key + * @param {object} [metaContext] + * @param {Record} [metaContext.automaticMeta] + * @param {Record} [metaContext.optionMeta] + * @returns {string} + */ +export function formatProfessionSkillKeyLabel( + key, + { automaticMeta = {}, optionMeta = {} } = {}, +) { + const ref = parseProfessionSkillKey(key); + if (!ref) return key; + if ( + ref.kind === "typed" && + isChooseOneProfessionSkillKey(key, automaticMeta, optionMeta) + ) { + return formatProfessionSkillKey({ + kind: "typed", + group: ref.group, + label: getChooseOnePlaceholderLabel(), + }); + } + if (ref.kind === "typed" && !ref.label?.trim()) { + return getTypedGroupDisplayName(ref.group); + } + return formatProfessionSkillLabel(ref); +} + +/** + * @param {Record} obj + * @returns {{ optionPicks: number, skills: Record }} + */ +export function splitProfessionSkillMap(obj) { + const source = obj ?? {}; + const optionPicks = Number(source[PROFESSION_OPTION_PICKS_KEY]) || 0; + /** @type {Record} */ + const skills = {}; + for (const [key, value] of Object.entries(source)) { + if (key === PROFESSION_OPTION_PICKS_KEY) continue; + skills[key] = Number(value); + } + return { optionPicks, skills }; +} + +/** + * @typedef {object} SkillCatalogEntry + * @property {string} id - Select value (`fixed:accounting` or `typed:Craft`) + * @property {ProfessionSkillRef} ref + * @property {string} label + * @property {string} sortLabel + */ + +/** + * @returns {SkillCatalogEntry[]} + */ +export function buildSkillCatalog() { + /** @type {SkillCatalogEntry[]} */ + const entries = []; + + for (const key of DG.skills) { + const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key }); + const label = formatProfessionSkillLabel(ref); + entries.push({ + id: `fixed:${key}`, + ref, + label, + sortLabel: label, + }); + } + + for (const group of TYPED_SKILL_TEMPLATE_GROUPS) { + const ref = /** @type {TypedSkillRef} */ ({ kind: "typed", group, label: "" }); + const label = getTypedGroupDisplayName(group); + entries.push({ + id: `typed:${group}`, + ref, + label, + sortLabel: label, + }); + } + + entries.sort((a, b) => + a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), + ); + return entries; +} + +/** + * @param {string} catalogId + * @param {string} [typedLabel] + * @param {{ allowEmptyTypedLabel?: boolean }} [options] + * @returns {ProfessionSkillRef | null} + */ +export function catalogIdToSkillRef( + catalogId, + typedLabel = "", + { allowEmptyTypedLabel = false } = {}, +) { + if (!catalogId) return null; + if (catalogId.startsWith("fixed:")) { + const key = catalogId.slice(6); + if (!DG.skills.includes(key)) return null; + return { kind: "fixed", key }; + } + if (catalogId.startsWith("typed:")) { + const group = catalogId.slice(6); + if (!TYPED_SKILL_TEMPLATE_GROUPS.includes(group)) return null; + const label = typedLabel.trim(); + if (!label && !allowEmptyTypedLabel) return null; + return { kind: "typed", group, label }; + } + return null; +} + +/** + * @param {Record} automaticSkills + * @param {Record} optionSkills + * @param {number} optionPicks + * @param {object} formState + * @param {Set} formState.checkedOptionKeys + * @param {Record} formState.chooseOneLabels - keyed by profession map key + * @param {string[]} formState.bonusCatalogIds - length 8 + * @param {string[]} formState.bonusTypedLabels - length 8 + * @param {string[]} [formState.bondNames] + * @param {string[]} [formState.bondRelationships] + * @param {object} [context] + * @param {Record} [context.automaticMeta] + * @param {Record} [context.optionMeta] + * @param {number} [context.bondCount] + * @returns {{ + * fixedValues: Record, + * typedValues: Record, + * capWarnings: { label: string, attempted: number, waste: number }[], + * isValid: boolean, + * validationErrors: string[], + * modifiedFixedKeys: string[], + * modifiedTypedKeys: string[], + * }} + */ +export function computeSkillValues( + automaticSkills, + optionSkills, + optionPicks, + formState, + { automaticMeta = {}, optionMeta = {}, bondCount = 0 } = {}, +) { + const defaults = getAgentSkillDefaults(); + /** @type {Record} */ + const fixedValues = { ...defaults }; + /** @type {Record} */ + const typedValues = {}; + /** @type {Set} */ + const modifiedFixedKeys = new Set(); + /** @type {Set} */ + const modifiedTypedKeys = new Set(); + + /** + * @param {TypedSkillRef} ref + * @param {string} label + */ + const markTypedModified = (ref, label) => { + modifiedTypedKeys.add( + formatProfessionSkillKey({ kind: "typed", group: ref.group, label }), + ); + }; + + /** + * @param {string} mapKey + * @param {number} rating + * @param {{ labelOverride?: string }} [options] + * @returns {string | null} Merged typed storage key, if applied. + */ + const applyOverride = (mapKey, rating, options = {}) => { + const ref = parseProfessionSkillKey(mapKey); + if (!ref) return null; + const value = Number(rating); + if (ref.kind === "fixed") { + fixedValues[ref.key] = value; + return null; + } + + const label = + options.labelOverride?.trim() ?? ref.label?.trim() ?? ""; + if (!label) return null; + + const storageKey = formatProfessionSkillKey({ + kind: "typed", + group: ref.group, + label, + }); + typedValues[storageKey] = { + group: ref.group, + label, + value, + }; + return storageKey; + }; + + for (const [key, rating] of Object.entries(automaticSkills ?? {})) { + const ref = parseProfessionSkillKey(key); + if (!ref) continue; + + let labelOverride; + if (ref.kind === "typed" && automaticMeta[key]?.chooseOne) { + labelOverride = formState.chooseOneLabels?.[key]?.trim(); + if (!labelOverride) continue; + } + + const storageKey = applyOverride(key, rating, { labelOverride }); + if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key); + else if (storageKey) { + markTypedModified(ref, labelOverride ?? ref.label); + } + } + + for (const key of formState.checkedOptionKeys ?? []) { + if (!optionSkills[key] && optionSkills[key] !== 0) continue; + const ref = parseProfessionSkillKey(key); + if (!ref) continue; + + let labelOverride; + if (ref.kind === "typed" && optionMeta[key]?.chooseOne) { + labelOverride = formState.chooseOneLabels?.[key]?.trim(); + if (!labelOverride) continue; + } else if (ref.kind === "typed") { + labelOverride = ref.label?.trim(); + if (!labelOverride) continue; + } + + const storageKey = applyOverride(key, optionSkills[key], { labelOverride }); + if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key); + else if (storageKey) { + markTypedModified(ref, labelOverride ?? ref.label); + } + } + + /** @type {Record} */ + const bonusCounts = {}; + + const bonusIds = formState.bonusCatalogIds ?? []; + const bonusLabels = formState.bonusTypedLabels ?? []; + + for (let i = 0; i < BONUS_SKILL_COUNT; i++) { + const catalogId = bonusIds[i]; + if (!catalogId) continue; + const typedLabel = bonusLabels[i] ?? ""; + const ref = catalogIdToSkillRef(catalogId, typedLabel); + if (!ref) continue; + + if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key); + else modifiedTypedKeys.add(formatProfessionSkillKey(ref)); + + let trackKey; + if (ref.kind === "fixed") { + trackKey = `fixed:${ref.key}`; + } else { + trackKey = formatProfessionSkillKey(ref); + } + bonusCounts[trackKey] = (bonusCounts[trackKey] ?? 0) + 1; + } + + /** Pre-bonus base for cap warnings */ + const baseFixed = { ...fixedValues }; + const baseTyped = foundry.utils.deepClone(typedValues); + + for (const [trackKey, count] of Object.entries(bonusCounts)) { + const bonus = count * BONUS_SKILL_INCREMENT; + if (trackKey.startsWith("fixed:")) { + const key = trackKey.slice(6); + fixedValues[key] = (fixedValues[key] ?? defaults[key] ?? 0) + bonus; + } else { + const ref = parseProfessionSkillKey(trackKey); + if (ref?.kind !== "typed") continue; + + const base = typedValues[trackKey]?.value ?? 0; + typedValues[trackKey] = { + group: ref.group, + label: ref.label, + value: base + bonus, + }; + } + } + + /** @type {{ label: string, attempted: number, waste: number }[]} */ + const capWarnings = []; + + const checkCap = (label, base, final) => { + if (final > SKILL_CAP) { + capWarnings.push({ + label, + attempted: final, + waste: final - SKILL_CAP, + }); + } + }; + + for (const [key, final] of Object.entries(fixedValues)) { + const base = baseFixed[key] ?? defaults[key] ?? 0; + const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key }); + checkCap(formatProfessionSkillLabel(ref), base, final); + } + + for (const [storageKey, data] of Object.entries(typedValues)) { + const base = baseTyped[storageKey]?.value ?? 0; + const ref = /** @type {TypedSkillRef} */ ({ + kind: "typed", + group: data.group, + label: data.label, + }); + checkCap(formatProfessionSkillLabel(ref), base, data.value); + } + + /** Apply cap for display (values shown capped at 80) */ + for (const key of Object.keys(fixedValues)) { + if (fixedValues[key] > SKILL_CAP) fixedValues[key] = SKILL_CAP; + } + for (const data of Object.values(typedValues)) { + if (data.value > SKILL_CAP) data.value = SKILL_CAP; + } + + const validationErrors = validateProfessionFormState(optionPicks, formState, { + automaticSkills, + optionSkills, + automaticMeta, + optionMeta, + bondCount, + }); + + return { + fixedValues, + typedValues, + capWarnings, + isValid: validationErrors.length === 0, + validationErrors, + modifiedFixedKeys: [...modifiedFixedKeys], + modifiedTypedKeys: [...modifiedTypedKeys], + }; +} + +/** + * @param {number} optionPicks + * @param {object} formState + * @param {object} [context] + * @param {Record} [context.automaticSkills] + * @param {Record} [context.optionSkills] + * @param {Record} [context.automaticMeta] + * @param {Record} [context.optionMeta] + * @param {number} [context.bondCount] + * @returns {string[]} + */ +export function validateProfessionFormState(optionPicks, formState, context = {}) { + const errors = []; + const checked = formState.checkedOptionKeys ?? new Set(); + const picks = Number(optionPicks) || 0; + const { + automaticSkills = {}, + optionSkills = {}, + automaticMeta = {}, + optionMeta = {}, + bondCount = 0, + } = context; + + if (checked.size !== picks) { + errors.push("optionPicks"); + } + + for (let i = 0; i < bondCount; i++) { + if (!formState.bondNames?.[i]?.trim()) errors.push(`bondName${i}`); + if (!formState.bondRelationships?.[i]?.trim()) { + errors.push(`bondRelationship${i}`); + } + } + + for (let i = 0; i < BONUS_SKILL_COUNT; i++) { + const catalogId = formState.bonusCatalogIds?.[i]; + if (!catalogId) { + errors.push(`bonus${i}`); + continue; + } + const ref = catalogIdToSkillRef( + catalogId, + formState.bonusTypedLabels?.[i] ?? "", + ); + if (!ref) { + errors.push(`bonus${i}`); + continue; + } + if (ref.kind === "typed" && !formState.bonusTypedLabels?.[i]?.trim()) { + errors.push(`bonusType${i}`); + } + } + + /** @type {Map>} */ + const resolvedByGroup = new Map(); + + const registerResolvedName = (ref, rawLabel) => { + const label = rawLabel?.trim(); + if (!label) { + errors.push(`typedNameRequired:${ref.group}`); + return; + } + const group = normalizeTypedGroup(ref.group); + const normalized = normalizeTypedSkillName(label); + if (!resolvedByGroup.has(group)) resolvedByGroup.set(group, new Set()); + const names = resolvedByGroup.get(group); + if (names.has(normalized)) { + errors.push(`typedNameConflict:${group}:${label}`); + return; + } + names.add(normalized); + }; + + for (const [key] of Object.entries(automaticSkills)) { + const ref = parseProfessionSkillKey(key); + if (!ref || ref.kind !== "typed") continue; + if (automaticMeta[key]?.chooseOne) { + registerResolvedName(ref, formState.chooseOneLabels?.[key]); + } else { + registerResolvedName(ref, ref.label); + } + } + + for (const key of checked) { + const ref = parseProfessionSkillKey(key); + if (!ref || ref.kind !== "typed") continue; + if (optionMeta[key]?.chooseOne) { + registerResolvedName(ref, formState.chooseOneLabels?.[key]); + } else { + registerResolvedName(ref, ref.label); + } + } + + return errors; +} + +/** + * User-facing messages for Add Profession validation error codes. + * @param {string[]} errors + * @param {object} [context] + * @param {number} [context.optionPicks] + * @returns {string[]} + */ +export function formatProfessionValidationMessages( + errors, + { optionPicks = 0 } = {}, +) { + const messages = []; + const seen = new Set(); + + const push = (msg) => { + if (seen.has(msg)) return; + seen.add(msg); + messages.push(msg); + }; + + let needsOptionPicks = false; + let needsBondName = false; + let needsBondRelationship = false; + let needsBonusSkill = false; + let needsBonusSkillType = false; + /** @type {Set} */ + const typedNameRequiredGroups = new Set(); + + for (const code of errors) { + if (code === "optionPicks") { + needsOptionPicks = true; + continue; + } + if (code.startsWith("bondName")) { + needsBondName = true; + continue; + } + if (code.startsWith("bondRelationship")) { + needsBondRelationship = true; + continue; + } + if (code.startsWith("bonusType")) { + needsBonusSkillType = true; + continue; + } + if (code.startsWith("bonus")) { + needsBonusSkill = true; + continue; + } + + const typedNameRequired = code.match(/^typedNameRequired:(.+)$/); + if (typedNameRequired) { + typedNameRequiredGroups.add(typedNameRequired[1]); + continue; + } + + const conflict = code.match(/^typedNameConflict:([^:]+):(.+)$/); + if (conflict) { + const [, group, name] = conflict; + push( + game.i18n.format("DG.Profession.Dialog.DuplicateTypedName", { + type: getTypedGroupDisplayName(group), + name, + }), + ); + } + } + + if (needsOptionPicks) { + push( + game.i18n.format("DG.Profession.Dialog.OptionPicksRequired", { + picks: optionPicks, + }), + ); + } + if (needsBondName) { + push(game.i18n.localize("DG.Profession.Dialog.BondNameRequired")); + } + if (needsBondRelationship) { + push(game.i18n.localize("DG.Profession.Dialog.BondRelationshipRequired")); + } + if (needsBonusSkill) { + push( + game.i18n.format("DG.Profession.Dialog.BonusSkillRequired", { + count: BONUS_SKILL_COUNT, + }), + ); + } + if (needsBonusSkillType) { + push(game.i18n.localize("DG.Profession.Dialog.BonusSkillTypeRequired")); + } + for (const group of typedNameRequiredGroups) { + push( + game.i18n.format("DG.Profession.Dialog.TypedNameRequired", { + type: getTypedGroupDisplayName(group), + }), + ); + } + + return messages; +} + +/** + * Sorted fixed skill rows for display. + * @param {Record} fixedValues + * @returns {{ key: string, label: string, value: number }[]} + */ +export function buildSortedFixedSkillRows(fixedValues) { + const rows = Object.entries(fixedValues).map(([key, value]) => { + const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key }); + return { + key, + label: formatProfessionSkillLabel(ref), + value, + sortLabel: formatProfessionSkillLabel(ref), + }; + }); + rows.sort((a, b) => + a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), + ); + return rows; +} + +/** + * Sorted typed skill rows for display. + * @param {Record} typedValues + * @returns {{ storageKey: string, label: string, value: number, sortLabel: string }[]} + */ +export function buildSortedTypedSkillRows(typedValues) { + const rows = Object.entries(typedValues).map(([storageKey, data]) => { + const ref = { + kind: "typed", + group: data.group, + label: data.label, + }; + const label = formatProfessionSkillLabel(ref); + return { + storageKey, + label, + value: data.value, + sortLabel: label, + }; + }); + rows.sort((a, b) => + a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), + ); + return rows; +} + +/** + * Prepare sorted skill rows from profession item maps. + * @param {Record} skillMap + * @param {Record} [skillMeta] + * @returns {{ key: string, label: string, rating: number }[]} + */ +export function prepareProfessionSkillRows(skillMap, skillMeta = {}) { + const rows = Object.entries(skillMap ?? {}).map(([key, rating]) => { + const label = formatProfessionSkillKeyLabel(key, { + automaticMeta: skillMeta, + optionMeta: skillMeta, + }); + return { + key, + label, + rating: Number(rating), + sortLabel: label, + }; + }); + rows.sort((a, b) => + a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), + ); + return rows; +} + +export { BONUS_SKILL_COUNT, BONUS_SKILL_INCREMENT, SKILL_CAP }; diff --git a/module/utils/profession-stat-setup.js b/module/utils/profession-stat-setup.js new file mode 100644 index 0000000..1f39af5 --- /dev/null +++ b/module/utils/profession-stat-setup.js @@ -0,0 +1,112 @@ +import DG from "../config.js"; +import { showDiceSoNicePooledRolls } from "./dice-so-nice.js"; + +export const STAT_KEYS = DG.statistics; +export const POINT_BUY_TOTAL = 72; +export const STAT_MIN = 3; +export const STAT_MAX = 18; +export const ROLL_STAT_FORMULA = "4d6dl"; + +/** + * @param {string} key + * @returns {string} + */ +export function getStatisticLabel(key) { + return game.i18n.localize(`DG.Attributes.${key}`); +} + +/** + * @returns {{ key: string, label: string }[]} + */ +export function buildStatisticRows() { + return STAT_KEYS.map((key) => ({ + key, + label: getStatisticLabel(key), + })); +} + +/** + * @param {Actor} actor + * @param {Record} valuesByKey + * @returns {Promise} + */ +export async function applyAgentStatistics(actor, valuesByKey) { + const updateData = {}; + for (const key of STAT_KEYS) { + updateData[`system.statistics.${key}.value`] = valuesByKey[key]; + } + await actor.update(updateData); +} + +/** + * @param {Record} values + * @returns {{ isValid: boolean, remaining: number }} + */ +export function validatePointBuyValues(values) { + let sum = 0; + for (const key of STAT_KEYS) { + const value = Number(values[key]); + if ( + !Number.isInteger(value) || + value < STAT_MIN || + value > STAT_MAX + ) { + return { isValid: false, remaining: POINT_BUY_TOTAL - sum }; + } + sum += value; + } + const remaining = POINT_BUY_TOTAL - sum; + return { isValid: remaining === 0, remaining }; +} + +/** + * @param {object} [options] + * @param {Actor|null} [options.actor] + * @param {TokenDocument|null} [options.token] + * @param {boolean} [options.animate=true] + * @returns {Promise<{ total: number, roll: Roll }[]>} + */ +export async function rollStatisticScores({ + actor = null, + token = null, + animate = true, +} = {}) { + const rollPromises = []; + for (let i = 0; i < STAT_KEYS.length; i++) { + rollPromises.push(new Roll(ROLL_STAT_FORMULA).evaluate()); + } + const rolls = await Promise.all(rollPromises); + + if (animate) { + await showDiceSoNicePooledRolls(rolls, { actor, token }); + } + + return rolls.map((roll, index) => ({ + index, + total: roll.total, + roll, + })); +} + +/** + * @param {{ total: number }[]} rolls + * @returns {string} + */ +export function buildRollStatsChatContent(rolls) { + const cells = rolls.map( + (entry) => + `${foundry.utils.escapeHTML(String(entry.total))}`, + ); + const rows = []; + for (let i = 0; i < cells.length; i += 3) { + rows.push(`${cells.slice(i, i + 3).join("")}`); + } + return `${rows.join("")}
`; +} + +/** + * @returns {Record} + */ +export function getDefaultPointBuyValues() { + return Object.fromEntries(STAT_KEYS.map((key) => [key, STAT_MIN])); +} diff --git a/module/utils/resource-chat.js b/module/utils/resource-chat.js index a366244..e3ac65c 100644 --- a/module/utils/resource-chat.js +++ b/module/utils/resource-chat.js @@ -1,8 +1,11 @@ +import { createDGChatMessage } from "../chat/dg-chat-card.js"; + /** * @param {object} params * @param {Actor} params.actor * @param {TokenDocument|null} [params.token] * @param {string} params.contentKey i18n key for message body. + * @param {string} params.labelKey i18n key for card subtitle. * @param {Record} params.i18nData * @returns {Promise} */ @@ -10,15 +13,15 @@ export async function createAgentResourceChatMessage({ actor, token = null, contentKey, + labelKey, i18nData, }) { - return ChatMessage.create({ - speaker: ChatMessage.getSpeaker({ - actor, - token, - alias: actor.name, - }), - content: game.i18n.format(contentKey, i18nData), + const content = game.i18n.format(contentKey, i18nData); + return createDGChatMessage({ + actor, + token, + label: game.i18n.localize(labelKey), + content, messageMode: game.settings.get("core", "messageMode"), }); } diff --git a/system.json b/system.json index 784ff8c..bce0c25 100644 --- a/system.json +++ b/system.json @@ -239,6 +239,9 @@ "ritual": { "htmlFields": ["description", "handlerNotes"], "gmOnlyFields": ["handlerNotes"] + }, + "profession": { + "htmlFields": ["description"] } } } diff --git a/templates/actor/partials/profession-subname.html b/templates/actor/partials/profession-subname.html new file mode 100644 index 0000000..0f0baad --- /dev/null +++ b/templates/actor/partials/profession-subname.html @@ -0,0 +1,14 @@ +{{#if professionItem}} + + {{professionItem.name}} + +{{else}} + +{{/if}} diff --git a/templates/actor/parts/left-bar.html b/templates/actor/parts/left-bar.html index 31b29c7..6b147a1 100644 --- a/templates/actor/parts/left-bar.html +++ b/templates/actor/parts/left-bar.html @@ -11,11 +11,7 @@ type="text" value="{{actor.name}}" data-tooltip="{{localize 'DG.AgentNameFieldTooltip' }}" /> - + {{> "systems/deltagreen/templates/actor/partials/profession-subname.html" }} diff --git a/templates/chat/dg-chat-card.hbs b/templates/chat/dg-chat-card.hbs new file mode 100644 index 0000000..82c74c0 --- /dev/null +++ b/templates/chat/dg-chat-card.hbs @@ -0,0 +1,12 @@ +
+
+
+ +
+
+

{{speakerName}}

+
{{{label}}}
+
+
+
{{{content}}}
+
diff --git a/templates/dialog/add-profession-skill.html b/templates/dialog/add-profession-skill.html new file mode 100644 index 0000000..45472b1 --- /dev/null +++ b/templates/dialog/add-profession-skill.html @@ -0,0 +1,39 @@ +
+
+ + +
+
+ + +
+ +
diff --git a/templates/dialog/add-profession.html b/templates/dialog/add-profession.html new file mode 100644 index 0000000..825a3ab --- /dev/null +++ b/templates/dialog/add-profession.html @@ -0,0 +1,136 @@ +
+
+
+
+ {{localize "DG.Profession.Dialog.Skill"}} + {{localize "DG.Profession.Dialog.Value"}} +
+
+ {{localize "DG.Profession.Dialog.Skill"}} + {{localize "DG.Profession.Dialog.Value"}} +
+
+ {{localize "DG.Profession.Dialog.Skill"}} + {{localize "DG.Profession.Dialog.Value"}} +
+
+ {{localize "DG.Profession.Dialog.Skill"}} + {{localize "DG.Profession.Dialog.Value"}} +
+
+
+ {{#each skillRows}} +
+ {{label}} + {{value}}% +
+ {{/each}} +
+
+ + {{#if automaticChooseOneRows.length}} +
+

{{localize "DG.Profession.Dialog.ChooseOneNamesHeader"}}

+
+ {{#each automaticChooseOneRows}} + + {{/each}} +
+
+ {{/if}} + + {{#if showOptionSkills}} +
+

{{localize "DG.Profession.Dialog.SkillsOptionsHeader" picks=optionPicks}}

+
+ {{#each optionRows}} +
+ + {{#if isChooseOne}} + + {{/if}} +
+ {{/each}} +
+
+ {{/if}} + +
+

{{localize "DG.Profession.Dialog.BonusSkillsHeader"}}

+
+ {{#each bonusSlots}} +
+ + +
+ {{/each}} +
+
+ +
+ {{#each validationMessages}} +

{{this}}

+ {{/each}} + {{#each capWarnings}} +

{{warning}}

+ {{/each}} +
+ + {{#if bondCount}} +
+

{{localize "DG.Profession.Dialog.BondsHeader"}}

+
+ {{#each bondRows}} +
+ {{localize "DG.Profession.Dialog.Bond"}} {{number}} + + + +
+ {{/each}} +
+
+ {{/if}} +
diff --git a/templates/dialog/assign-stats.html b/templates/dialog/assign-stats.html new file mode 100644 index 0000000..266378e --- /dev/null +++ b/templates/dialog/assign-stats.html @@ -0,0 +1,19 @@ +
+

+ {{localize "DG.ProfessionSetup.AssignStats.PointsRemaining" remaining=remaining}} +

+
+ {{#each stats}} + + {{/each}} +
+
diff --git a/templates/dialog/modify-percentile-roll.html b/templates/dialog/modify-percentile-roll.html index d899d01..f817562 100644 --- a/templates/dialog/modify-percentile-roll.html +++ b/templates/dialog/modify-percentile-roll.html @@ -1,32 +1,48 @@ -
-
- {{localize 'DG.ModifySkillRollDialogue.Test'}} - - {{data.label}} - - {{localize 'DG.ModifySkillRollDialogue.OriginalTarget'}} - - {{#if data.hideTarget}} - - {{/if}} - - {{#unless data.hideTarget}} - - {{/unless}} - - - {{localize 'DG.ModifySkillRollDialogue.TargetModifier'}} -
- - -
- - - {{selectOptions (getAvailableRollModes) selected=(getDefaultRollMode) localize=true}}
-
\ No newline at end of file + +
+

{{localize "DG.ModifySkillRollDialogue.QuickModifiersHeader"}}

+

{{localize "DG.ModifySkillRollDialogue.QuickModifiersHint"}}

+
+ {{#each quickModifiers}} +
+ +

{{explanation}}

+
+ {{/each}} +
+
+ +
+

{{localize "DG.ModifySkillRollDialogue.CustomModifierHeader"}}

+

{{localize "DG.ModifySkillRollDialogue.CustomModifierHint"}}

+
+ + +
+
+ \ No newline at end of file diff --git a/templates/dialog/pick-statistics.html b/templates/dialog/pick-statistics.html new file mode 100644 index 0000000..4a05e25 --- /dev/null +++ b/templates/dialog/pick-statistics.html @@ -0,0 +1,3 @@ +
+

{{localize "DG.ProfessionSetup.PickStatistics.Intro"}}

+
diff --git a/templates/dialog/roll-stats.html b/templates/dialog/roll-stats.html new file mode 100644 index 0000000..23da887 --- /dev/null +++ b/templates/dialog/roll-stats.html @@ -0,0 +1,23 @@ +
+
+
+ {{#each stats}} +
+ {{label}} +
+
+ {{/each}} +
+
+
+ {{#each rolls}} +
{{total}}
+ {{/each}} +
+
+
+

{{localize "DG.ProfessionSetup.RollStats.PoolHeader"}}

+
diff --git a/templates/item/parts/profession-description.html b/templates/item/parts/profession-description.html new file mode 100644 index 0000000..5f81c26 --- /dev/null +++ b/templates/item/parts/profession-description.html @@ -0,0 +1,5 @@ +
+ {{> "systems/deltagreen/templates/partials/prose-mirror-host.html" html=richText.description}} +
diff --git a/templates/item/parts/profession-header.html b/templates/item/parts/profession-header.html new file mode 100644 index 0000000..81b16dc --- /dev/null +++ b/templates/item/parts/profession-header.html @@ -0,0 +1,16 @@ +
+
+ +
+ {{localize 'DG.ItemWindow.Profession.Name'}}: + +
+
+
diff --git a/templates/item/parts/profession-skills-tab.html b/templates/item/parts/profession-skills-tab.html new file mode 100644 index 0000000..ea58146 --- /dev/null +++ b/templates/item/parts/profession-skills-tab.html @@ -0,0 +1,75 @@ +
+
+
+
+ {{localize 'DG.ItemWindow.Profession.AutomaticSkills'}} + + {{localize 'DG.ItemWindow.Profession.Bonds'}} + +
+
    + {{#each automaticSkillRows}} +
  1. + {{label}} + {{rating}}% + +
  2. + {{/each}} +
+
+ +
+
+ {{localize 'DG.ItemWindow.Profession.OptionSkills'}} + + {{localize 'DG.ItemWindow.Profession.OptionPicks'}} + +
+
    + {{#each optionSkillRows}} +
  1. + {{label}} + {{rating}}% + +
  2. + {{/each}} +
+
+
+
diff --git a/templates/roll/damage-roll.hbs b/templates/roll/damage-roll.hbs new file mode 100644 index 0000000..426b698 --- /dev/null +++ b/templates/roll/damage-roll.hbs @@ -0,0 +1,21 @@ +
+
+
{{formula}}
+
+
+
+
+
+ {{formula}} + {{total}} +
+
    +
  1. {{total}}
  2. +
+
+
+
+
+

{{total}}

+
+