From b3fd7e1aa13240045b17e35bae13113ee8a6427d Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Fri, 20 Mar 2026 17:01:38 +0100 Subject: [PATCH] feat: add Settlement actor type with Overview/Buildings/Inventory tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New TypeDataModel: archetype, territory, renown, currency (gp/sp/cp), garrison, underSiege, isCapital, founded, taxNotes, description, notes - 3-tab ApplicationV2 sheet with drag & drop for building/weapon/armor/equipment - Currency steppers (+/−), building constructed toggle, qty controls - LESS-based CSS (settlement-sheet.less) + base.less updated for shared styles - Full i18n keys in lang/en.json (8 settlement archetypes) - system.json: registered settlement actor type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- css/fvtt-oath-hammer.css | 404 +++++++----------- lang/en.json | 59 ++- less/base.less | 3 +- less/fvtt-oath-hammer.less | 1 + less/settlement-sheet.less | 172 ++++++++ module/applications/_module.mjs | 1 + module/applications/armor-dialog.mjs | 2 +- module/applications/defense-dialog.mjs | 2 +- module/applications/miracle-dialog.mjs | 14 + module/applications/roll-dialog.mjs | 2 +- .../applications/sheets/character-sheet.mjs | 14 + .../applications/sheets/settlement-sheet.mjs | 132 ++++++ module/applications/spell-dialog.mjs | 23 + module/applications/weapon-dialog.mjs | 6 +- module/config/system.mjs | 12 + module/models/_module.mjs | 1 + module/models/character.mjs | 7 +- module/models/settlement.mjs | 40 ++ module/rolls.mjs | 26 +- oath-hammer.mjs | 8 +- system.json | 6 + templates/actor/character-magic.hbs | 22 +- templates/actor/settlement-buildings.hbs | 41 ++ templates/actor/settlement-inventory.hbs | 96 +++++ templates/actor/settlement-overview.hbs | 61 +++ templates/actor/settlement-sheet.hbs | 47 ++ templates/miracle-cast-dialog.hbs | 12 + templates/spell-cast-dialog.hbs | 22 + 28 files changed, 966 insertions(+), 270 deletions(-) create mode 100644 less/settlement-sheet.less create mode 100644 module/applications/sheets/settlement-sheet.mjs create mode 100644 module/models/settlement.mjs create mode 100644 templates/actor/settlement-buildings.hbs create mode 100644 templates/actor/settlement-inventory.hbs create mode 100644 templates/actor/settlement-overview.hbs create mode 100644 templates/actor/settlement-sheet.hbs diff --git a/css/fvtt-oath-hammer.css b/css/fvtt-oath-hammer.css index 6f3ac70..9f0ee96 100644 --- a/css/fvtt-oath-hammer.css +++ b/css/fvtt-oath-hammer.css @@ -27,7 +27,8 @@ background-size: 100% 100%; } .oathhammer .character-content, -.oathhammer .npc-content { +.oathhammer .npc-content, +.oathhammer .settlement-content { font-family: "Calibri", "Segoe UI", sans-serif; font-size: 0.86rem; color: #2a1a0a; @@ -38,32 +39,39 @@ padding: 10px 20px; } .oathhammer .character-content nav.tabs [data-tab], -.oathhammer .npc-content nav.tabs [data-tab] { +.oathhammer .npc-content nav.tabs [data-tab], +.oathhammer .settlement-content nav.tabs [data-tab] { color: #535128; } .oathhammer .character-content nav.tabs [data-tab].active, -.oathhammer .npc-content nav.tabs [data-tab].active { +.oathhammer .npc-content nav.tabs [data-tab].active, +.oathhammer .settlement-content nav.tabs [data-tab].active { color: #084a74; } .oathhammer .character-content input:disabled, .oathhammer .npc-content input:disabled, +.oathhammer .settlement-content input:disabled, .oathhammer .character-content select:disabled, -.oathhammer .npc-content select:disabled { +.oathhammer .npc-content select:disabled, +.oathhammer .settlement-content select:disabled { background-color: rgba(0, 0, 0, 0.08); border-color: transparent; color: #2a1a0a; } .oathhammer .character-content input, .oathhammer .npc-content input, +.oathhammer .settlement-content input, .oathhammer .character-content select, -.oathhammer .npc-content select { +.oathhammer .npc-content select, +.oathhammer .settlement-content select { height: 1.5rem; background-color: rgba(255, 255, 255, 0.3); border-color: #084a74; color: #2a1a0a; } .oathhammer .character-content input[name="name"], -.oathhammer .npc-content input[name="name"] { +.oathhammer .npc-content input[name="name"], +.oathhammer .settlement-content input[name="name"] { height: 2.5rem; font-family: "Sherwood", "Palatino Linotype", serif; font-size: calc(0.86rem * 1.2); @@ -73,13 +81,15 @@ background: transparent; } .oathhammer .character-content fieldset, -.oathhammer .npc-content fieldset { +.oathhammer .npc-content fieldset, +.oathhammer .settlement-content fieldset { margin-bottom: 4px; border-radius: 4px; border-color: #535128; } .oathhammer .character-content legend, -.oathhammer .npc-content legend { +.oathhammer .npc-content legend, +.oathhammer .settlement-content legend { font-family: "BlueDragon", "Palatino Linotype", serif; font-size: calc(0.86rem * 1.1); font-weight: bold; @@ -87,7 +97,8 @@ color: #084a74; } .oathhammer .character-content label, -.oathhammer .npc-content label { +.oathhammer .npc-content label, +.oathhammer .settlement-content label { font-family: "BlueDragon", "Palatino Linotype", serif; font-size: 0.86rem; color: #2a1a0a; @@ -229,11 +240,6 @@ font-size: calc(0.86rem * 0.9); font-weight: bold; } -.oathhammer .skills-container .skill-row a.skill-name-col { - display: inline-flex; - align-items: center; - gap: 5px; -} .oathhammer .skills-container .skill-row .skill-rank-col select, .oathhammer .skills-container .skill-row .skill-modifier-col input { width: 100%; @@ -438,19 +444,6 @@ font-size: 0.86rem; font-weight: bold; } -.oathhammer .character-main .character-identity-bar .identity-slot a.class-open-link { - flex: 1; - font-family: "BlueDragon", "Palatino Linotype", serif; - font-size: 0.86rem; - font-weight: bold; - opacity: 1; - text-decoration: none; - cursor: pointer; -} -.oathhammer .character-main .character-identity-bar .identity-slot a.class-open-link:hover { - text-decoration: underline; - opacity: 1; -} .oathhammer .character-main .character-identity-bar .identity-slot .slot-icon { font-size: calc(0.86rem * 0.9); opacity: 0.8; @@ -575,51 +568,6 @@ width: 4rem; text-align: center; } -.oathhammer .currency-stepper { - display: flex; - align-items: center; - gap: 2px; -} -.oathhammer .currency-stepper input { - width: 3.5rem; - text-align: center; -} -/* Shared +/- button style */ -.oathhammer .qty-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - font-size: 0.9rem; - font-weight: bold; - line-height: 1; - color: #2a1a0a; - background: rgba(200, 168, 75, 0.2); - border: 1px solid rgba(200, 168, 75, 0.5); - border-radius: 3px; - cursor: pointer; - user-select: none; - flex-shrink: 0; - text-decoration: none; -} -.oathhammer .qty-btn:hover { - background: rgba(200, 168, 75, 0.45); - color: #2a1a0a; -} -/* Quantity stepper in item rows */ -.oathhammer .item-qty-stepper { - display: flex; - align-items: center; - gap: 3px; - justify-content: center; -} -.oathhammer .item-qty-stepper .qty-value { - min-width: 1.6rem; - text-align: center; - font-size: calc(0.86rem * 0.9); - font-weight: bold; -} .oathhammer .identity-lineage-class { gap: 8px; margin-bottom: 8px; @@ -734,9 +682,6 @@ .oathhammer .item-list-header .col-name { text-align: left; } -.oathhammer .item-list-header .col-oath-effect { - text-align: left; -} .oathhammer .item-entry { display: grid; align-items: center; @@ -833,7 +778,7 @@ } .oathhammer .item-list--ammo .item-list-header, .oathhammer .item-list--ammo .item-entry { - grid-template-columns: 24px 1fr 5.5rem 3.5rem; + grid-template-columns: 24px 1fr 4rem 3.5rem; } .oathhammer .item-list--spell .item-list-header, .oathhammer .item-list--spell .item-entry { @@ -849,7 +794,7 @@ } .oathhammer .item-list--equipment .item-list-header, .oathhammer .item-list--equipment .item-entry { - grid-template-columns: 24px 1fr 5rem 5.5rem 3.5rem; + grid-template-columns: 24px 1fr 5rem 3rem 3.5rem; } .oathhammer .item-list--magic-item .item-list-header, .oathhammer .item-list--magic-item .item-entry { @@ -865,21 +810,7 @@ } .oathhammer .item-list--oath .item-list-header, .oathhammer .item-list--oath .item-entry { - grid-template-columns: 24px 1fr 10rem 3.5rem 3.5rem; -} -.oathhammer .item-oath-effect { - font-size: calc(0.86rem * 0.88); - color: #2a1a0a; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.oathhammer .item-oath-effect.oath-boon { - color: #1a5c2a; -} -.oathhammer .item-oath-effect.oath-bane { - color: #c0392b; - font-style: italic; + grid-template-columns: 24px 1fr 7rem 3.5rem 3.5rem; } .oathhammer .item-usage { font-size: calc(0.86rem * 0.9); @@ -978,44 +909,6 @@ background: rgba(192, 57, 43, 0.1); border-color: rgba(192, 57, 43, 0.4); } - -/* Initiative bar */ -.initiative-bar { - display: flex; - align-items: center; - gap: 10px; - padding: 4px 6px 6px; -} -.initiative-roll-btn { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 3px 10px; - font-size: calc(0.86rem * 0.9); - font-weight: bold; - color: #2a1a0a; - background: rgba(200, 168, 75, 0.2); - border: 1px solid rgba(200, 168, 75, 0.5); - border-radius: 4px; - cursor: pointer; - text-decoration: none; - text-transform: uppercase; - letter-spacing: 0.04em; - transition: background 0.15s; -} -.initiative-roll-btn:hover { - background: rgba(200, 168, 75, 0.4); - color: #2a1a0a; -} -.initiative-score { - font-size: calc(0.86rem * 0.95); - font-weight: bold; - color: #2a1a0a; - background: rgba(200, 168, 75, 0.15); - border: 1px solid rgba(200, 168, 75, 0.4); - border-radius: 4px; - padding: 2px 10px; -} .oathhammer .item-sheet-common { overflow: auto; padding: 10px 20px; @@ -1237,14 +1130,6 @@ .fvtt-oath-hammer .window-content { background: #f5ead0; padding: 6px 8px; - /* iOS Safari flex-overflow fix: prevent flex child collapse */ - overflow-y: auto; - -webkit-overflow-scrolling: touch; -} -/* iOS Safari: ensure dialog content is not hidden due to flex-height collapse */ -.fvtt-oath-hammer.dialog .window-content { - min-height: 80px; - height: auto; } .fvtt-oath-hammer .oh-roll-dialog { font-family: "Calibri", "Segoe UI", sans-serif; @@ -1413,27 +1298,6 @@ cursor: pointer; flex: 1 1 auto; } -/* Range conditions stacked checkbox block */ -.fvtt-oath-hammer .oh-roll-dialog .roll-option-block { - display: flex; - flex-direction: column; - gap: 4px; - padding: 4px 0; - border-top: 1px solid rgba(83, 81, 40, 0.15); -} -.fvtt-oath-hammer .oh-roll-dialog .roll-option-block-label { - font-size: calc(0.86rem * 0.85); - font-weight: bold; - color: #5a3e1b; - text-transform: uppercase; - letter-spacing: 0.03em; -} -.fvtt-oath-hammer .oh-roll-dialog .range-conditions { - display: flex; - flex-direction: column; - gap: 2px; - padding-left: 4px; -} .fvtt-oath-hammer .oh-roll-dialog .roll-visibility-block select { width: 100%; padding: 4px 6px; @@ -1886,98 +1750,150 @@ .item-list--armor .item-actions a[data-action="edit"] { margin-left: 6px; } - -/* ============================================================ - FREE ROLL BAR — Chat sidebar widget - ============================================================ */ -.oh-free-roll-bar { - pointer-events: all; - display: flex; - align-items: center; - gap: 6px; - padding: 5px 8px; - background: linear-gradient(135deg, #2a1a0a 0%, #3d2812 100%); - border-top: 1px solid rgba(255, 200, 80, 0.25); - border-bottom: 1px solid rgba(0, 0, 0, 0.3); - flex-wrap: wrap; +.oathhammer .settlement-main { + padding: 0 0 8px; + border-bottom: 1px solid rgba(42, 26, 10, 0.25); + margin-bottom: 4px; } -.oh-free-roll-bar .oh-frb-label { - color: #f5d78e; - font-family: "BlueDragon", "Palatino Linotype", serif; - font-size: calc(0.86rem * 0.9); +.oathhammer .settlement-header { + align-items: flex-start; + gap: 10px; +} +.oathhammer .settlement-header .actor-img { + width: 72px; + height: 72px; + -o-object-fit: cover; + object-fit: cover; + border: 2px solid rgba(42, 26, 10, 0.4); + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} +.oathhammer .settlement-header-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} +.oathhammer .settlement-name-row { + align-items: center; + gap: 8px; +} +.oathhammer .settlement-archetype-badge { + font-size: calc(0.86rem * 0.85); + color: #cd9d2c; + background: rgba(200, 168, 75, 0.15); + border: 1px solid rgba(200, 168, 75, 0.4); + border-radius: 3px; + padding: 1px 6px; white-space: nowrap; +} +.oathhammer .settlement-stats { + gap: 8px; + flex-wrap: wrap; + align-items: flex-start; +} +.oathhammer .settlement-stats .stat-item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 80px; +} +.oathhammer .settlement-stats .stat-item label { + font-size: 0.72rem; + color: #2a1a0a; + opacity: 0.8; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.oathhammer .settlement-stats .stat-item input { + padding: 2px 4px; +} +.oathhammer .settlement-stats .stat-item--flags { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + flex-wrap: wrap; + min-width: unset; +} +.oathhammer .settlement-stats .stat-item--flags label { display: flex; align-items: center; gap: 4px; - opacity: 0.9; -} -.oh-free-roll-bar .oh-frb-controls { - display: flex; - align-items: center; - gap: 5px; - flex: 1; - flex-wrap: wrap; -} -.oh-free-roll-bar .oh-frb-pool { - height: 1.6rem; - font-size: 0.8rem; - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 200, 80, 0.3); - border-radius: 3px; - color: #f5ead0; - cursor: pointer; - padding: 0 3px; -} -.oh-free-roll-bar .oh-frb-pool option { - background: #2a1a0a; - color: #f5ead0; -} -.oh-free-roll-bar .oh-frb-color { - height: 1.6rem; - font-size: 0.8rem; - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 200, 80, 0.3); - border-radius: 3px; - color: #f5ead0; - cursor: pointer; - padding: 0 3px; -} -.oh-free-roll-bar .oh-frb-color option { - background: #2a1a0a; - color: #f5ead0; -} -.oh-free-roll-bar .oh-frb-explode-label { - display: flex; - align-items: center; - gap: 3px; - color: #f5d78e; - font-size: 0.78rem; - cursor: pointer; - white-space: nowrap; - opacity: 0.85; -} -.oh-free-roll-bar .oh-frb-explode-label input[type="checkbox"] { - cursor: pointer; - accent-color: #c9a227; -} -.oh-free-roll-bar .oh-frb-roll-btn { - margin-left: auto; - height: 1.7rem; - padding: 0 10px; - background: linear-gradient(135deg, #c9a227 0%, #8a6a10 100%); - border: 1px solid rgba(255, 200, 80, 0.4); - border-radius: 4px; - color: #1a0e00; - font-family: "BlueDragon", "Palatino Linotype", serif; font-size: calc(0.86rem * 0.85); - font-weight: bold; + text-transform: none; + letter-spacing: 0; cursor: pointer; - white-space: nowrap; - transition: filter 0.15s; } -.oh-free-roll-bar .oh-frb-roll-btn:hover { - filter: brightness(1.15); +.oathhammer .settlement-badge { + font-size: 0.75rem; + font-weight: bold; + border-radius: 3px; + padding: 2px 7px; } -.oh-free-roll-bar .oh-frb-roll-btn:active { - filter: brightness(0.9); +.oathhammer .settlement-badge--capital { + background: rgba(200, 168, 75, 0.2); + border: 1px solid rgba(200, 168, 75, 0.5); + color: #5d4d1d; +} +.oathhammer .settlement-badge--siege { + background: rgba(180, 40, 40, 0.12); + border: 1px solid rgba(180, 40, 40, 0.4); + color: #8a1a1a; +} +.oathhammer .settlement-content .tab { + padding: 8px 0; +} +.oathhammer .settlement-overview-grid { + display: grid; + grid-template-columns: 1fr 1fr 2fr; + gap: 8px; + margin-bottom: 8px; +} +.oathhammer .settlement-hint { + font-size: 0.78rem; + color: #2a1a0a; + opacity: 0.7; + margin: 0 0 8px; + font-style: italic; +} +.oathhammer .item-list--buildings .item-list-header, +.oathhammer .item-list--buildings .item-entry { + display: grid; + grid-template-columns: 28px 1fr 3rem 5rem 4rem 3rem; + align-items: center; + gap: 4px; +} +.oathhammer .item-entry.building-constructed { + background: rgba(60, 140, 60, 0.08); +} +.oathhammer .item-entry.building-constructed .item-name { + font-weight: 600; +} +.oathhammer .item-constructed { + text-align: center; + font-size: 1rem; + color: #3a7a3a; +} +.oathhammer .item-tax { + font-size: calc(0.86rem * 0.85); + color: #312f17; + text-align: center; +} +.oathhammer .item-tax--inactive { + opacity: 0.4; +} +.oathhammer .item-cost { + font-size: calc(0.86rem * 0.85); + color: #856d28; + text-align: right; +} +.oathhammer .construct-toggle { + color: #3a7a3a; + cursor: pointer; + font-size: 1rem; +} +.oathhammer .construct-toggle:hover { + color: #214621; } diff --git a/lang/en.json b/lang/en.json index d53b51b..daeb149 100644 --- a/lang/en.json +++ b/lang/en.json @@ -14,7 +14,8 @@ "Oath": "Oath Hammer Oath Sheet", "Condition": "Oath Hammer Condition Sheet", "Class": "Oath Hammer Class Sheet", - "Building": "Oath Hammer Building Sheet" + "Building": "Oath Hammer Building Sheet", + "Settlement": "Oath Hammer Settlement Sheet" }, "Tab": { "Identity": "Identity", @@ -22,7 +23,10 @@ "Combat": "Combat", "Magic": "Magic", "Equipment": "Equipment", - "Notes": "Notes" + "Notes": "Notes", + "Overview": "Overview", + "Buildings": "Buildings", + "Inventory": "Inventory" }, "Attribute": { "Might": "Might", @@ -208,6 +212,7 @@ "Movement": "Movement", "ArcaneStress": "Arcane Stress", "StressValue": "Stress", + "ThresholdBonus": "Threshold Bonus", "Attributes": "Attributes", "Biodata": "Background", "Background": "Background", @@ -277,10 +282,25 @@ "Ritual": "Ritual", "MagicMissile": "Magic Missile", "SpellSave": "Save", + "Range": "Range", + "Duration": "Duration", "StressBlocked": "BLOCKED — over stress threshold!", - "ArcaneStressShort": "AS", "InitiativeBonus": "Initiative Bonus", - "Initiative": "Initiative" + "Initiative": "Initiative", + "Treasury": "Treasury", + "Renown": "Renown", + "Territory": "Territory", + "Population": "Population", + "Garrison": "Garrison", + "UnderSiege": "Under Siege", + "IsCapital": "Capital", + "Capital": "Capital", + "Founded": "Founded", + "TaxNotes": "Tax Notes", + "TaxRevenue": "Tax Revenue", + "Cost": "Cost", + "Built": "Built", + "Armors": "Armors & Shields" }, "ColorDice": { "White": "White (4+)", @@ -301,7 +321,8 @@ "CastSpell": "Cast Spell", "InvokeMiracle": "Invoke Miracle", "ResetMiracleBlocked": "Restore divine favour (new day)", - "NewDay": "New day" + "NewDay": "New day", + "ClearStress": "Clear Stress (full rest)" }, "Dialog": { "SkillCheckTitle": "Skill Check: {skill}", @@ -355,6 +376,9 @@ "Grimoire": "Grimoire", "GrimoireHas": "Has Grimoire", "GrimoireNo": "No Grimoire (−2)", + "PoolSize": "Pool Size", + "PoolSizeHint": "Roll fewer dice to reduce Arcane Stress risk (p.101)", + "PoolSizeReduced": "reduced pool", "MiracleCastTitle": "Invoke Miracle: {miracle}", "InvokeMiracle": "Invoke Miracle", "InvokeOptions": "Invoke Options", @@ -947,6 +971,25 @@ "label": "Violated" } } + }, + "Warning": { + "MiracleBlocked": "Divine favour has been lost — you cannot invoke miracles until a new day." + }, + "SettlementArchetype": { + "CenterOfLearning": "Center of Learning", + "DwarvenBorough": "Dwarven Borough", + "FreeCity": "Free City", + "GuildMunicipality": "Guild Municipality", + "NocklanderOutpost": "Nocklander Outpost", + "PilgrimMission": "Pilgrim Mission", + "PortTown": "Port Town", + "VelathiColony": "Velathi Colony" + }, + "Settlement": { + "BuildingHint": "Drag & drop building items here. Toggle the checkbox to mark them as constructed.", + "InventoryHint": "Drag & drop weapons, armor, or equipment here to store them in the settlement reserve.", + "NoBuildings": "No buildings yet. Drag building items here.", + "NoInventory": "No stored inventory. Drag weapons, armor, or equipment here." } }, "TYPES": { @@ -965,10 +1008,8 @@ }, "Actor": { "character": "Character", - "npc": "NPC" - }, - "Warning": { - "MiracleBlocked": "Divine favour has been lost — you cannot invoke miracles until a new day." + "npc": "NPC", + "settlement": "Settlement" } } } \ No newline at end of file diff --git a/less/base.less b/less/base.less index 829a0a0..bf1d77a 100644 --- a/less/base.less +++ b/less/base.less @@ -36,7 +36,8 @@ // Shared actor content base .oathhammer .character-content, -.oathhammer .npc-content { +.oathhammer .npc-content, +.oathhammer .settlement-content { font-family: @font-body; // Calibri — standard text per design_rules.md font-size: @font-size-base; color: @color-dark; diff --git a/less/fvtt-oath-hammer.less b/less/fvtt-oath-hammer.less index f5285b2..dc5d234 100644 --- a/less/fvtt-oath-hammer.less +++ b/less/fvtt-oath-hammer.less @@ -11,3 +11,4 @@ @import "item-sheets"; @import "rolls"; @import "roll-dialog"; +@import "settlement-sheet"; diff --git a/less/settlement-sheet.less b/less/settlement-sheet.less new file mode 100644 index 0000000..2ccbd05 --- /dev/null +++ b/less/settlement-sheet.less @@ -0,0 +1,172 @@ +// ============================================================ +// SETTLEMENT SHEET +// ============================================================ + +.oathhammer .settlement-main { + padding: 0 0 8px; + border-bottom: 1px solid fade(@color-dark, 25%); + margin-bottom: 4px; +} + +.oathhammer .settlement-header { + align-items: flex-start; + gap: 10px; +} + +.oathhammer .settlement-header .actor-img { + width: 72px; + height: 72px; + object-fit: cover; + border: 2px solid fade(@color-dark, 40%); + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} + +.oathhammer .settlement-header-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} + +.oathhammer .settlement-name-row { + align-items: center; + gap: 8px; +} + +.oathhammer .settlement-archetype-badge { + font-size: @font-size-sm; + color: darken(@color-paper, 40%); + background: fade(@color-gold, 15%); + border: 1px solid fade(@color-gold, 40%); + border-radius: 3px; + padding: 1px 6px; + white-space: nowrap; +} + +.oathhammer .settlement-stats { + gap: 8px; + flex-wrap: wrap; + align-items: flex-start; + + .stat-item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 80px; + + label { + font-size: 0.72rem; + color: @color-dark; + opacity: 0.8; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + input { padding: 2px 4px; } + + &--flags { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + flex-wrap: wrap; + min-width: unset; + + label { + display: flex; + align-items: center; + gap: 4px; + font-size: @font-size-sm; + text-transform: none; + letter-spacing: 0; + cursor: pointer; + } + } + } +} + +.oathhammer .settlement-badge { + font-size: 0.75rem; + font-weight: bold; + border-radius: 3px; + padding: 2px 7px; + + &--capital { + background: fade(@color-gold, 20%); + border: 1px solid fade(@color-gold, 50%); + color: darken(@color-gold, 30%); + } + + &--siege { + background: rgba(180, 40, 40, 0.12); + border: 1px solid rgba(180, 40, 40, 0.4); + color: #8a1a1a; + } +} + +.oathhammer .settlement-content .tab { + padding: 8px 0; +} + +// Overview grid: garrison | founded | tax notes +.oathhammer .settlement-overview-grid { + display: grid; + grid-template-columns: 1fr 1fr 2fr; + gap: 8px; + margin-bottom: 8px; +} + +.oathhammer .settlement-hint { + font-size: 0.78rem; + color: @color-dark; + opacity: 0.7; + margin: 0 0 8px; + font-style: italic; +} + +// Buildings item list +.oathhammer .item-list--buildings { + .item-list-header, + .item-entry { + display: grid; + grid-template-columns: 28px 1fr 3rem 5rem 4rem 3rem; + align-items: center; + gap: 4px; + } +} + +.oathhammer .item-entry.building-constructed { + background: rgba(60, 140, 60, 0.08); + + .item-name { font-weight: 600; } +} + +.oathhammer .item-constructed { + text-align: center; + font-size: 1rem; + color: #3a7a3a; +} + +.oathhammer .item-tax { + font-size: @font-size-sm; + color: darken(@color-olive, 10%); + text-align: center; + + &--inactive { opacity: 0.4; } +} + +.oathhammer .item-cost { + font-size: @font-size-sm; + color: darken(@color-gold, 20%); + text-align: right; +} + +.oathhammer .construct-toggle { + color: #3a7a3a; + cursor: pointer; + font-size: 1rem; + + &:hover { color: darken(#3a7a3a, 15%); } +} diff --git a/module/applications/_module.mjs b/module/applications/_module.mjs index eebf362..cd574e0 100644 --- a/module/applications/_module.mjs +++ b/module/applications/_module.mjs @@ -11,6 +11,7 @@ export { default as OathHammerTraitSheet } from "./sheets/trait-sheet.mjs" export { default as OathHammerOathSheet } from "./sheets/oath-sheet.mjs" export { default as OathHammerClassSheet } from "./sheets/class-sheet.mjs" export { default as OathHammerBuildingSheet } from "./sheets/building-sheet.mjs" +export { default as OathHammerSettlementSheet } from "./sheets/settlement-sheet.mjs" export { default as OathHammerRollDialog } from "./roll-dialog.mjs" export { default as OathHammerWeaponDialog } from "./weapon-dialog.mjs" export { default as OathHammerSpellDialog } from "./spell-dialog.mjs" diff --git a/module/applications/armor-dialog.mjs b/module/applications/armor-dialog.mjs index 21f9339..fc95706 100644 --- a/module/applications/armor-dialog.mjs +++ b/module/applications/armor-dialog.mjs @@ -67,7 +67,7 @@ export default class OathHammerArmorDialog { ) const result = await foundry.applications.api.DialogV2.wait({ - window: { title: game.i18n.format("OATHHAMMER.Dialog.ArmorRollTitle", { armor: armor.name }) }, + window: { title: game.i18n.format("OATHHAMMER.Dialog.ArmorRollTitle", { armor: armor.name }), resizable: true }, classes: ["fvtt-oath-hammer"], position: { width: 420 }, content, diff --git a/module/applications/defense-dialog.mjs b/module/applications/defense-dialog.mjs index 74e02df..7f986b1 100644 --- a/module/applications/defense-dialog.mjs +++ b/module/applications/defense-dialog.mjs @@ -68,7 +68,7 @@ export default class OathHammerDefenseDialog { ) const result = await foundry.applications.api.DialogV2.wait({ - window: { title: game.i18n.format("OATHHAMMER.Dialog.DefenseTitle", { actor: actor.name }) }, + window: { title: game.i18n.format("OATHHAMMER.Dialog.DefenseTitle", { actor: actor.name }), resizable: true }, classes: ["fvtt-oath-hammer"], position: { width: 420 }, content, diff --git a/module/applications/miracle-dialog.mjs b/module/applications/miracle-dialog.mjs index 4b4c154..b49088e 100644 --- a/module/applications/miracle-dialog.mjs +++ b/module/applications/miracle-dialog.mjs @@ -30,6 +30,15 @@ export default class OathHammerMiracleDialog { return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 } }) + const availableLuck = actorSys.luck?.value ?? 0 + const isHuman = (actorSys.lineage?.name ?? "").toLowerCase() === "human" + const luckDicePerPoint = isHuman ? 3 : 2 + const luckOptions = Array.from({ length: availableLuck + 1 }, (_, i) => ({ + value: i, + label: i === 0 ? "0" : `${i} (+${i * luckDicePerPoint}d)`, + selected: i === 0, + })) + const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes) const context = { @@ -47,6 +56,9 @@ export default class OathHammerMiracleDialog { basePool, miracleCountOptions, bonusOptions, + availableLuck, + isHuman, + luckOptions, rollModes, visibility: game.settings.get("core", "rollMode"), } @@ -84,6 +96,8 @@ export default class OathHammerMiracleDialog { bonus: parseInt(result.bonus) || 0, visibility: result.visibility ?? game.settings.get("core", "rollMode"), explodeOn5: result.explodeOn5 === "true", + luckSpend: Math.min(Math.max(0, parseInt(result.luckSpend) || 0), availableLuck), + luckIsHuman: result.luckIsHuman === "true", } } } diff --git a/module/applications/roll-dialog.mjs b/module/applications/roll-dialog.mjs index 3cc45b6..ebe7bae 100644 --- a/module/applications/roll-dialog.mjs +++ b/module/applications/roll-dialog.mjs @@ -124,7 +124,7 @@ export default class OathHammerRollDialog { const title = game.i18n.format("OATHHAMMER.Dialog.SkillCheckTitle", { skill: context.skillLabel }) const result = await foundry.applications.api.DialogV2.wait({ - window: { title }, + window: { title, resizable: true }, classes: ["fvtt-oath-hammer"], position: { width: 420 }, content, diff --git a/module/applications/sheets/character-sheet.mjs b/module/applications/sheets/character-sheet.mjs index ca7f4c4..b136b51 100644 --- a/module/applications/sheets/character-sheet.mjs +++ b/module/applications/sheets/character-sheet.mjs @@ -37,6 +37,8 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { rollInitiative: OathHammerCharacterSheet.#onRollInitiative, adjustQty: OathHammerCharacterSheet.#onAdjustQty, adjustCurrency: OathHammerCharacterSheet.#onAdjustCurrency, + adjustStress: OathHammerCharacterSheet.#onAdjustStress, + clearStress: OathHammerCharacterSheet.#onClearStress, }, } @@ -201,6 +203,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { break case "magic": context.tab = context.tabs.magic + context.stressBlocked = doc.system.arcaneStress.value >= doc.system.arcaneStress.threshold context.spells = doc.itemTypes.spell.map(s => ({ id: s.id, uuid: s.uuid, img: s.img, name: s.name, system: s.system, _descTooltip: _stripHtml(s.system.effect) @@ -404,6 +407,17 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { const current = foundry.utils.getProperty(this.document, field) ?? 0 await this.document.update({ [field]: Math.max(0, current + delta) }) } + + static async #onAdjustStress(event, target) { + const delta = parseInt(target.dataset.delta, 10) + const current = this.document.system.arcaneStress.value ?? 0 + const max = this.document.system.arcaneStress.threshold + await this.document.update({ "system.arcaneStress.value": Math.max(0, Math.min(max, current + delta)) }) + } + + static async #onClearStress(_event, _target) { + await this.document.update({ "system.arcaneStress.value": 0 }) + } } /** Strip HTML tags and collapse whitespace for use in data-tooltip attributes. */ diff --git a/module/applications/sheets/settlement-sheet.mjs b/module/applications/sheets/settlement-sheet.mjs new file mode 100644 index 0000000..ead6f46 --- /dev/null +++ b/module/applications/sheets/settlement-sheet.mjs @@ -0,0 +1,132 @@ +import OathHammerActorSheet from "./base-actor-sheet.mjs" + +const ALLOWED_ITEM_TYPES = new Set(["building", "equipment", "weapon", "armor"]) + +export default class OathHammerSettlementSheet extends OathHammerActorSheet { + /** @override */ + static DEFAULT_OPTIONS = { + classes: ["settlement"], + position: { + width: 840, + height: "auto", + }, + window: { + contentClasses: ["settlement-content"], + }, + actions: { + adjustCurrency: OathHammerSettlementSheet.#onAdjustCurrency, + adjustQty: OathHammerSettlementSheet.#onAdjustQty, + toggleConstructed: OathHammerSettlementSheet.#onToggleConstructed, + }, + } + + /** @override */ + static PARTS = { + main: { + template: "systems/fvtt-oath-hammer/templates/actor/settlement-sheet.hbs", + }, + tabs: { + template: "templates/generic/tab-navigation.hbs", + }, + overview: { + template: "systems/fvtt-oath-hammer/templates/actor/settlement-overview.hbs", + }, + buildings: { + template: "systems/fvtt-oath-hammer/templates/actor/settlement-buildings.hbs", + }, + inventory: { + template: "systems/fvtt-oath-hammer/templates/actor/settlement-inventory.hbs", + }, + } + + /** @override */ + tabGroups = { + sheet: "overview", + } + + #getTabs() { + const tabs = { + overview: { id: "overview", group: "sheet", icon: "fa-solid fa-city", label: "OATHHAMMER.Tab.Overview" }, + buildings: { id: "buildings", group: "sheet", icon: "fa-solid fa-building", label: "OATHHAMMER.Tab.Buildings" }, + inventory: { id: "inventory", group: "sheet", icon: "fa-solid fa-boxes-stacked", label: "OATHHAMMER.Tab.Inventory" }, + } + for (const v of Object.values(tabs)) { + v.active = this.tabGroups[v.group] === v.id + v.cssClass = v.active ? "active" : "" + } + return tabs + } + + /** @override */ + async _prepareContext() { + const context = await super._prepareContext() + context.tabs = this.#getTabs() + context.archetypeChoices = Object.fromEntries( + Object.entries(SYSTEM.SETTLEMENT_ARCHETYPES).map(([k, v]) => [k, game.i18n.localize(v)]) + ) + return context + } + + /** @override */ + async _preparePartContext(partId, context) { + const doc = this.document + switch (partId) { + case "main": + break + case "overview": + context.tab = context.tabs.overview + context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML( + doc.system.description, { async: true } + ) + break + case "buildings": + context.tab = context.tabs.buildings + context.buildings = doc.itemTypes.building + break + case "inventory": { + context.tab = context.tabs.inventory + context.weapons = doc.itemTypes.weapon + context.armors = doc.itemTypes.armor + context.equipments = doc.itemTypes.equipment + break + } + } + return context + } + + /** @override */ + async _onDrop(event) { + if (!this.isEditable || !this.isEditMode) return + const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event) + if (data.type !== "Item") return + const item = await fromUuid(data.uuid) + if (!item || !ALLOWED_ITEM_TYPES.has(item.type)) return + return this._onDropItem(item) + } + + static async #onAdjustCurrency(event, target) { + const field = target.dataset.field + const delta = parseInt(target.dataset.delta, 10) + if (!field || isNaN(delta)) return + const current = foundry.utils.getProperty(this.document, field) ?? 0 + await this.document.update({ [field]: Math.max(0, current + delta) }) + } + + static async #onAdjustQty(event, target) { + const itemId = target.dataset.itemId + const delta = parseInt(target.dataset.delta, 10) + if (!itemId || isNaN(delta)) return + const item = this.document.items.get(itemId) + if (!item) return + const current = item.system.quantity ?? 0 + await item.update({ "system.quantity": Math.max(0, current + delta) }) + } + + static async #onToggleConstructed(event, target) { + const itemId = target.dataset.itemId + if (!itemId) return + const item = this.document.items.get(itemId) + if (!item) return + await item.update({ "system.constructed": !item.system.constructed }) + } +} diff --git a/module/applications/spell-dialog.mjs b/module/applications/spell-dialog.mjs index c4d7576..dd3241d 100644 --- a/module/applications/spell-dialog.mjs +++ b/module/applications/spell-dialog.mjs @@ -52,6 +52,22 @@ export default class OathHammerSpellDialog { return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 } }) + const availableLuck = actorSys.luck?.value ?? 0 + const isHuman = (actorSys.lineage?.name ?? "").toLowerCase() === "human" + const luckDicePerPoint = isHuman ? 3 : 2 + const luckOptions = Array.from({ length: availableLuck + 1 }, (_, i) => ({ + value: i, + label: i === 0 ? "0" : `${i} (+${i * luckDicePerPoint}d)`, + selected: i === 0, + })) + + // Pool size selector: casters may roll fewer dice to reduce Arcane Stress (p.101) + const poolSizeOptions = Array.from({ length: basePool }, (_, i) => ({ + value: i + 1, + label: `${i + 1}d`, + selected: i + 1 === basePool, + })) + const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes) const context = { @@ -70,11 +86,15 @@ export default class OathHammerSpellDialog { intRank, magicRank, basePool, + poolSizeOptions, currentStress, stressThreshold, isOverThreshold, enhancementOptions, bonusOptions, + availableLuck, + isHuman, + luckOptions, rollModes, visibility: game.settings.get("core", "rollMode"), } @@ -116,9 +136,12 @@ export default class OathHammerSpellDialog { noStress: enh.noStress, elementalBonus: parseInt(result.elementalBonus) || 0, bonus: parseInt(result.bonus) || 0, + poolSize: Math.min(Math.max(1, parseInt(result.poolSize) || basePool), basePool), grimPenalty: parseInt(result.noGrimoire) || 0, visibility: result.visibility ?? game.settings.get("core", "rollMode"), explodeOn5: result.explodeOn5 === "true", + luckSpend: Math.min(Math.max(0, parseInt(result.luckSpend) || 0), availableLuck), + luckIsHuman: result.luckIsHuman === "true", } } } diff --git a/module/applications/weapon-dialog.mjs b/module/applications/weapon-dialog.mjs index a3cb5f0..e153555 100644 --- a/module/applications/weapon-dialog.mjs +++ b/module/applications/weapon-dialog.mjs @@ -120,7 +120,7 @@ export default class OathHammerWeaponDialog { ) const result = await foundry.applications.api.DialogV2.wait({ - window: { title: game.i18n.format("OATHHAMMER.Dialog.AttackTitle", { weapon: weapon.name }) }, + window: { title: game.i18n.format("OATHHAMMER.Dialog.AttackTitle", { weapon: weapon.name }), resizable: true }, classes: ["fvtt-oath-hammer"], position: { width: 420 }, content, @@ -268,7 +268,7 @@ export default class OathHammerWeaponDialog { ) const result = await foundry.applications.api.DialogV2.wait({ - window: { title: game.i18n.format("OATHHAMMER.Dialog.WeaponDefenseTitle", { weapon: weapon.name }) }, + window: { title: game.i18n.format("OATHHAMMER.Dialog.WeaponDefenseTitle", { weapon: weapon.name }), resizable: true }, classes: ["fvtt-oath-hammer"], position: { width: 420 }, content, @@ -375,7 +375,7 @@ export default class OathHammerWeaponDialog { ) const result = await foundry.applications.api.DialogV2.wait({ - window: { title: game.i18n.format("OATHHAMMER.Dialog.DamageTitle", { weapon: weapon.name }) }, + window: { title: game.i18n.format("OATHHAMMER.Dialog.DamageTitle", { weapon: weapon.name }), resizable: true }, classes: ["fvtt-oath-hammer"], position: { width: 420 }, content, diff --git a/module/config/system.mjs b/module/config/system.mjs index 258c7ed..c6bca10 100644 --- a/module/config/system.mjs +++ b/module/config/system.mjs @@ -362,6 +362,17 @@ export const BUILDING_SKILL_CHOICES = { masonry: "OATHHAMMER.BuildingSkill.Masonry" } +export const SETTLEMENT_ARCHETYPES = { + "center-of-learning": "OATHHAMMER.SettlementArchetype.CenterOfLearning", + "dwarven-borough": "OATHHAMMER.SettlementArchetype.DwarvenBorough", + "free-city": "OATHHAMMER.SettlementArchetype.FreeCity", + "guild-municipality": "OATHHAMMER.SettlementArchetype.GuildMunicipality", + "nocklander-outpost": "OATHHAMMER.SettlementArchetype.NocklanderOutpost", + "pilgrim-mission": "OATHHAMMER.SettlementArchetype.PilgrimMission", + "port-town": "OATHHAMMER.SettlementArchetype.PortTown", + "velathi-colony": "OATHHAMMER.SettlementArchetype.VelathiColony" +} + export const SYSTEM = { id: SYSTEM_ID, ATTRIBUTES, @@ -389,6 +400,7 @@ export const SYSTEM = { TRAIT_TYPE_CHOICES, TRAIT_USAGE_PERIOD, BUILDING_SKILL_CHOICES, + SETTLEMENT_ARCHETYPES, STATUS_EFFECTS, ATTRIBUTE_RANK_CHOICES, ASCII diff --git a/module/models/_module.mjs b/module/models/_module.mjs index e5f1e1a..829da3e 100644 --- a/module/models/_module.mjs +++ b/module/models/_module.mjs @@ -11,3 +11,4 @@ export { default as OathHammerTrait } from "./trait.mjs" export { default as OathHammerOath } from "./oath.mjs" export { default as OathHammerClass } from "./class.mjs" export { default as OathHammerBuilding } from "./building.mjs" +export { default as OathHammerSettlement } from "./settlement.mjs" diff --git a/module/models/character.mjs b/module/models/character.mjs index 5aa325a..be6b456 100644 --- a/module/models/character.mjs +++ b/module/models/character.mjs @@ -78,8 +78,9 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel }) schema.arcaneStress = new fields.SchemaField({ - value: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }), - threshold: new fields.NumberField({ ...requiredInteger, initial: 10, min: 0 }) + value: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }), + threshold: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }), + thresholdBonus: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }) }) schema.miracleBlocked = new fields.BooleanField({ required: true, initial: false }) @@ -130,5 +131,7 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel this.luck.max = this.attributes.fate.rank // Defense score = 10 + Agility + Armor Rating + bonus this.defense.value = 10 + this.attributes.agility.rank + this.defense.armorRating + this.defense.bonus + // Stress Threshold = Willpower rank + Magic rank + bonus (rulebook p.101) + this.arcaneStress.threshold = this.attributes.willpower.rank + this.skills.magic.rank + this.arcaneStress.thresholdBonus } } diff --git a/module/models/settlement.mjs b/module/models/settlement.mjs new file mode 100644 index 0000000..d7c65dc --- /dev/null +++ b/module/models/settlement.mjs @@ -0,0 +1,40 @@ +import { SYSTEM } from "../config/system.mjs" + +export default class OathHammerSettlement extends foundry.abstract.TypeDataModel { + static defineSchema() { + const fields = foundry.data.fields + const requiredInteger = { required: true, nullable: false, integer: true } + const schema = {} + + schema.description = new fields.HTMLField({ required: true, textSearch: true }) + schema.notes = new fields.HTMLField({ required: false, textSearch: true }) + + schema.archetype = new fields.StringField({ + required: true, + nullable: false, + initial: "free-city", + choices: SYSTEM.SETTLEMENT_ARCHETYPES + }) + + schema.territory = new fields.StringField({ required: true, nullable: false, initial: "" }) + schema.founded = new fields.StringField({ required: true, nullable: false, initial: "" }) + schema.population = new fields.StringField({ required: true, nullable: false, initial: "" }) + + schema.renown = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }) + + schema.currency = new fields.SchemaField({ + gold: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }), + silver: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }), + copper: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }) + }) + + schema.garrison = new fields.StringField({ required: true, nullable: false, initial: "" }) + schema.underSiege = new fields.BooleanField({ required: true, initial: false }) + schema.isCapital = new fields.BooleanField({ required: true, initial: false }) + schema.taxNotes = new fields.StringField({ required: true, nullable: false, initial: "" }) + + return schema + } + + static LOCALIZATION_PREFIXES = ["OATHHAMMER.Settlement"] +} diff --git a/module/rolls.mjs b/module/rolls.mjs index 69bb0dd..7a460c5 100644 --- a/module/rolls.mjs +++ b/module/rolls.mjs @@ -411,9 +411,12 @@ export async function rollSpellCast(actor, spell, options = {}) { noStress = false, elementalBonus = 0, bonus = 0, + poolSize = null, grimPenalty = 0, visibility, explodeOn5 = false, + luckSpend = 0, + luckIsHuman = false, } = options const sys = spell.system @@ -421,7 +424,12 @@ export async function rollSpellCast(actor, spell, options = {}) { const intRank = actorSys.attributes.intelligence.rank const magicRank = actorSys.skills.magic.rank - const totalDice = Math.max(intRank + magicRank + bonus + poolPenalty + elementalBonus + grimPenalty, 1) + const luckDicePerPoint = luckIsHuman ? 3 : 2 + const baseDice = intRank + magicRank + bonus + poolPenalty + elementalBonus + grimPenalty + (luckSpend * luckDicePerPoint) + // poolSize: voluntary reduction (p.101) — clamped to [1, baseDice] + const totalDice = poolSize !== null + ? Math.max(1, Math.min(poolSize + bonus + poolPenalty + elementalBonus + grimPenalty + (luckSpend * luckDicePerPoint), baseDice)) + : Math.max(baseDice, 1) const threshold = redDice ? 3 : 4 const colorEmoji = redDice ? "🔴" : "⬜" @@ -439,6 +447,10 @@ export async function rollSpellCast(actor, spell, options = {}) { await actor.update({ "system.arcaneStress.value": currentStress + totalStressGain }) } + if (luckSpend > 0) { + await actor.update({ "system.luck.value": Math.max(0, (actorSys.luck?.value ?? 0) - luckSpend) }) + } + const newStress = (actorSys.arcaneStress.value ?? 0) + totalStressGain const stressMax = actorSys.arcaneStress.threshold const isBlocked = newStress >= stressMax @@ -451,10 +463,12 @@ export async function rollSpellCast(actor, spell, options = {}) { : game.i18n.localize("OATHHAMMER.Roll.Failure") const modParts = [] + if (poolSize !== null && poolSize < intRank + magicRank) modParts.push(`🎲 ${poolSize}d ${game.i18n.localize("OATHHAMMER.Dialog.PoolSizeReduced")}`) if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`) if (poolPenalty !== 0) modParts.push(`${poolPenalty} ${game.i18n.localize("OATHHAMMER.Enhancement." + _cap(enhancement))}`) if (elementalBonus > 0) modParts.push(`+${elementalBonus} ${game.i18n.localize("OATHHAMMER.Dialog.ElementMet")}`) if (grimPenalty < 0) modParts.push(`${grimPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.GrimoireNo")}`) + if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " 👤" : ""})`) const explodedCountSpell = diceResults.filter(d => d.exploded).length if (explodedCountSpell > 0) modParts.push(`💥 ${explodedCountSpell} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`) const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" @@ -517,6 +531,8 @@ export async function rollMiracleCast(actor, miracle, options = {}) { bonus = 0, visibility, explodeOn5 = false, + luckSpend = 0, + luckIsHuman = false, } = options const sys = miracle.system @@ -524,7 +540,8 @@ export async function rollMiracleCast(actor, miracle, options = {}) { const wpRank = actorSys.attributes.willpower.rank const magicRank = actorSys.skills.magic.rank - const totalDice = Math.max(wpRank + magicRank + bonus, 1) + const luckDicePerPoint = luckIsHuman ? 3 : 2 + const totalDice = Math.max(wpRank + magicRank + bonus + (luckSpend * luckDicePerPoint), 1) const threshold = 4 const colorEmoji = "⬜" @@ -532,6 +549,10 @@ export async function rollMiracleCast(actor, miracle, options = {}) { const diceHtml = _diceHtml(diceResults, threshold) const isSuccess = successes >= dv + if (luckSpend > 0) { + await actor.update({ "system.luck.value": Math.max(0, (actorSys.luck?.value ?? 0) - luckSpend) }) + } + const skillLabel = game.i18n.localize("OATHHAMMER.Skill.Magic") const attrLabel = game.i18n.localize("OATHHAMMER.Attribute.Willpower") const resultClass = isSuccess ? "roll-success" : "roll-failure" @@ -541,6 +562,7 @@ export async function rollMiracleCast(actor, miracle, options = {}) { const modParts = [] if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`) + if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " 👤" : ""})`) const explodedCountMiracle = diceResults.filter(d => d.exploded).length if (explodedCountMiracle > 0) modParts.push(`💥 ${explodedCountMiracle} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`) const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" diff --git a/oath-hammer.mjs b/oath-hammer.mjs index 2daf31d..89b11be 100644 --- a/oath-hammer.mjs +++ b/oath-hammer.mjs @@ -23,7 +23,8 @@ Hooks.once("init", function () { CONFIG.Combat.documentClass = OathHammerCombat CONFIG.Actor.dataModels = { character: models.OathHammerCharacter, - npc: models.OathHammerNPC + npc: models.OathHammerNPC, + settlement: models.OathHammerSettlement } CONFIG.Item.documentClass = documents.OathHammerItem @@ -52,6 +53,11 @@ Hooks.once("init", function () { makeDefault: true, label: "OATHHAMMER.Sheet.NPC" }) + foundry.documents.collections.Actors.registerSheet("fvtt-oath-hammer", applications.OathHammerSettlementSheet, { + types: ["settlement"], + makeDefault: true, + label: "OATHHAMMER.Sheet.Settlement" + }) foundry.documents.collections.Items.unregisterSheet("core", foundry.appv1.sheets.ItemSheet) foundry.documents.collections.Items.registerSheet("fvtt-oath-hammer", applications.OathHammerWeaponSheet, { types: ["weapon"], makeDefault: true, label: "OATHHAMMER.Sheet.Weapon" }) diff --git a/system.json b/system.json index 14c5710..0d68d90 100644 --- a/system.json +++ b/system.json @@ -36,6 +36,12 @@ "background", "notes" ] + }, + "settlement": { + "htmlFields": [ + "description", + "notes" + ] } }, "Item": { diff --git a/templates/actor/character-magic.hbs b/templates/actor/character-magic.hbs index ad8360f..a067807 100644 --- a/templates/actor/character-magic.hbs +++ b/templates/actor/character-magic.hbs @@ -1,9 +1,17 @@
{{localize "OATHHAMMER.Label.ArcaneStress"}} -
- - {{system.arcaneStress.value}} / {{system.arcaneStress.threshold}} +
+ + + {{system.arcaneStress.value}} / {{system.arcaneStress.threshold}} + + + + + + + {{localize "OATHHAMMER.Label.ThresholdBonus"}} +
@@ -17,7 +25,9 @@ {{localize "OATHHAMMER.Label.Name"}} DV {{localize "OATHHAMMER.Label.Tradition"}} - AS + {{localize "OATHHAMMER.Label.Range"}} + {{localize "OATHHAMMER.Label.Duration"}} + {{localize "OATHHAMMER.Label.SpellSave"}} {{#each spells as |spell|}} @@ -26,7 +36,9 @@ {{spell.name}} {{spell.system.difficultyValue}} {{localize spell.system.tradition}} - + {{#if spell.system.range}}{{spell.system.range}}{{else}}—{{/if}} + {{#if spell.system.duration}}{{spell.system.duration}}{{else}}—{{/if}} + {{#if spell.system.spellSave}}{{spell.system.spellSave}}{{else}}—{{/if}}
diff --git a/templates/actor/settlement-buildings.hbs b/templates/actor/settlement-buildings.hbs new file mode 100644 index 0000000..c2dd169 --- /dev/null +++ b/templates/actor/settlement-buildings.hbs @@ -0,0 +1,41 @@ +
+

{{localize "OATHHAMMER.Settlement.BuildingHint"}}

+ + {{#if buildings.length}} +
    +
  • + + {{localize "OATHHAMMER.Label.Name"}} + {{localize "OATHHAMMER.Label.Built"}} + {{localize "OATHHAMMER.Label.TaxRevenue"}} + {{localize "OATHHAMMER.Label.Cost"}} + +
  • + {{#each buildings as |building|}} +
  • + + {{building.name}} + + {{#unless ../isPlayMode}} + + + + {{else}} + + {{/unless}} + + + {{#if building.system.taxRevenue}}{{building.system.taxRevenue}}{{else}}—{{/if}} + + {{building.system.cost}} gp +
    + + {{#unless ../isPlayMode}}{{/unless}} +
    +
  • + {{/each}} +
+ {{else}} +

{{localize "OATHHAMMER.Settlement.NoBuildings"}}

+ {{/if}} +
diff --git a/templates/actor/settlement-inventory.hbs b/templates/actor/settlement-inventory.hbs new file mode 100644 index 0000000..9fc3db7 --- /dev/null +++ b/templates/actor/settlement-inventory.hbs @@ -0,0 +1,96 @@ +
+

{{localize "OATHHAMMER.Settlement.InventoryHint"}}

+ + {{#if weapons.length}} +
+ {{localize "OATHHAMMER.Label.Weapons"}} +
    +
  • + + {{localize "OATHHAMMER.Label.Name"}} + {{localize "OATHHAMMER.Label.Type"}} + +
  • + {{#each weapons as |weapon|}} +
  • + + {{weapon.name}} + {{weapon.system.weaponType}} +
    + + {{#unless ../isPlayMode}}{{/unless}} +
    +
  • + {{/each}} +
+
+ {{/if}} + + {{#if armors.length}} +
+ {{localize "OATHHAMMER.Label.Armors"}} +
    +
  • + + {{localize "OATHHAMMER.Label.Name"}} + {{localize "OATHHAMMER.Label.Type"}} + +
  • + {{#each armors as |armor|}} +
  • + + {{armor.name}} + {{localize armor.system.armorType}} +
    + + {{#unless ../isPlayMode}}{{/unless}} +
    +
  • + {{/each}} +
+
+ {{/if}} + + {{#if equipments.length}} +
+ {{localize "OATHHAMMER.Label.Equipment"}} +
    +
  • + + {{localize "OATHHAMMER.Label.Name"}} + {{localize "OATHHAMMER.Label.Type"}} + {{localize "OATHHAMMER.Label.Quantity"}} + +
  • + {{#each equipments as |equip|}} +
  • + + {{equip.name}} + {{localize equip.system.itemType}} + + {{#unless ../isPlayMode}} + + {{/unless}} + {{equip.system.quantity}} + {{#unless ../isPlayMode}} + + + {{/unless}} + +
    + + {{#unless ../isPlayMode}}{{/unless}} +
    +
  • + {{/each}} +
+
+ {{/if}} + + {{#unless weapons.length}} + {{#unless armors.length}} + {{#unless equipments.length}} +

{{localize "OATHHAMMER.Settlement.NoInventory"}}

+ {{/unless}} + {{/unless}} + {{/unless}} +
diff --git a/templates/actor/settlement-overview.hbs b/templates/actor/settlement-overview.hbs new file mode 100644 index 0000000..7ea61d4 --- /dev/null +++ b/templates/actor/settlement-overview.hbs @@ -0,0 +1,61 @@ +
+ +
+ {{localize "OATHHAMMER.Label.Treasury"}} +
+
+ +
+ + {{formInput systemFields.currency.fields.gold value=system.currency.gold name="system.currency.gold"}} + + +
+
+
+ +
+ + {{formInput systemFields.currency.fields.silver value=system.currency.silver name="system.currency.silver"}} + + +
+
+
+ +
+ + {{formInput systemFields.currency.fields.copper value=system.currency.copper name="system.currency.copper"}} + + +
+
+
+
+ +
+
+ {{localize "OATHHAMMER.Label.Garrison"}} + {{formInput systemFields.garrison value=system.garrison name="system.garrison" disabled=isPlayMode}} +
+ +
+ {{localize "OATHHAMMER.Label.Founded"}} + {{formInput systemFields.founded value=system.founded name="system.founded" disabled=isPlayMode}} +
+ +
+ {{localize "OATHHAMMER.Label.TaxNotes"}} + {{formInput systemFields.taxNotes value=system.taxNotes name="system.taxNotes" disabled=isPlayMode}} +
+
+ +
+ {{localize "OATHHAMMER.Label.Description"}} + {{#if isEditMode}} + + {{{system.description}}} + + {{else}} +
{{{enrichedDescription}}}
+ {{/if}} +
+ +
diff --git a/templates/actor/settlement-sheet.hbs b/templates/actor/settlement-sheet.hbs new file mode 100644 index 0000000..c0c31b5 --- /dev/null +++ b/templates/actor/settlement-sheet.hbs @@ -0,0 +1,47 @@ +
+
+ +
+
+ {{formInput fields.name value=source.name rootId=partId disabled=isPlayMode}} + {{#unless isPlayMode}} + {{formInput systemFields.archetype value=system.archetype name="system.archetype" localize=true}} + {{else}} + {{localize (lookup archetypeChoices system.archetype)}} + {{/unless}} + + + +
+
+
+ + {{formInput systemFields.renown value=system.renown name="system.renown" disabled=isPlayMode}} +
+
+ + {{formInput systemFields.territory value=system.territory name="system.territory" disabled=isPlayMode}} +
+
+ + {{formInput systemFields.population value=system.population name="system.population" disabled=isPlayMode}} +
+
+ {{#unless isPlayMode}} + + + {{else}} + {{#if system.isCapital}}★ {{localize "OATHHAMMER.Label.Capital"}}{{/if}} + {{#if system.underSiege}}⚔ {{localize "OATHHAMMER.Label.UnderSiege"}}{{/if}} + {{/unless}} +
+
+
+
+
diff --git a/templates/miracle-cast-dialog.hbs b/templates/miracle-cast-dialog.hbs index 4b5f58a..345b032 100644 --- a/templates/miracle-cast-dialog.hbs +++ b/templates/miracle-cast-dialog.hbs @@ -53,6 +53,18 @@ {{localize "OATHHAMMER.Dialog.AttackModifierHint"}}
+ {{#if availableLuck}} +
+ + + + + {{localize "OATHHAMMER.Dialog.LuckHint"}} ({{availableLuck}} {{localize "OATHHAMMER.Dialog.Available"}}) +
+ {{/if}} +
diff --git a/templates/spell-cast-dialog.hbs b/templates/spell-cast-dialog.hbs index e0106e2..782ed1c 100644 --- a/templates/spell-cast-dialog.hbs +++ b/templates/spell-cast-dialog.hbs @@ -34,6 +34,16 @@ = {{basePool}}d6
+ {{#if poolSizeOptions}} +
+ + + {{localize "OATHHAMMER.Dialog.PoolSizeHint"}} +
+ {{/if}} +
+ {{#if availableLuck}} +
+ + + + + {{localize "OATHHAMMER.Dialog.LuckHint"}} ({{availableLuck}} {{localize "OATHHAMMER.Dialog.Available"}}) +
+ {{/if}} +