diff --git a/css/fvtt-oath-hammer.css b/css/fvtt-oath-hammer.css
index 233ff74..e48de6a 100644
--- a/css/fvtt-oath-hammer.css
+++ b/css/fvtt-oath-hammer.css
@@ -28,7 +28,10 @@
}
.oathhammer .character-content,
.oathhammer .npc-content,
-.oathhammer .settlement-content {
+.oathhammer .settlement-content,
+.oathhammer .regiment-content,
+.oathhammer .party-content,
+.oathhammer .army-content {
font-family: "Calibri", "Segoe UI", sans-serif;
font-size: 0.86rem;
color: #2a1a0a;
@@ -40,20 +43,32 @@
}
.oathhammer .character-content nav.tabs [data-tab],
.oathhammer .npc-content nav.tabs [data-tab],
-.oathhammer .settlement-content nav.tabs [data-tab] {
+.oathhammer .settlement-content nav.tabs [data-tab],
+.oathhammer .regiment-content nav.tabs [data-tab],
+.oathhammer .party-content nav.tabs [data-tab],
+.oathhammer .army-content nav.tabs [data-tab] {
color: #535128;
}
.oathhammer .character-content nav.tabs [data-tab].active,
.oathhammer .npc-content nav.tabs [data-tab].active,
-.oathhammer .settlement-content nav.tabs [data-tab].active {
+.oathhammer .settlement-content nav.tabs [data-tab].active,
+.oathhammer .regiment-content nav.tabs [data-tab].active,
+.oathhammer .party-content nav.tabs [data-tab].active,
+.oathhammer .army-content nav.tabs [data-tab].active {
color: #084a74;
}
.oathhammer .character-content input:disabled,
.oathhammer .npc-content input:disabled,
.oathhammer .settlement-content input:disabled,
+.oathhammer .regiment-content input:disabled,
+.oathhammer .party-content input:disabled,
+.oathhammer .army-content input:disabled,
.oathhammer .character-content select:disabled,
.oathhammer .npc-content select:disabled,
-.oathhammer .settlement-content select:disabled {
+.oathhammer .settlement-content select:disabled,
+.oathhammer .regiment-content select:disabled,
+.oathhammer .party-content select:disabled,
+.oathhammer .army-content select:disabled {
background-color: rgba(0, 0, 0, 0.08);
border-color: transparent;
color: #2a1a0a;
@@ -61,9 +76,15 @@
.oathhammer .character-content input,
.oathhammer .npc-content input,
.oathhammer .settlement-content input,
+.oathhammer .regiment-content input,
+.oathhammer .party-content input,
+.oathhammer .army-content input,
.oathhammer .character-content select,
.oathhammer .npc-content select,
-.oathhammer .settlement-content select {
+.oathhammer .settlement-content select,
+.oathhammer .regiment-content select,
+.oathhammer .party-content select,
+.oathhammer .army-content select {
height: 1.5rem;
background-color: rgba(255, 255, 255, 0.3);
border-color: #084a74;
@@ -71,7 +92,10 @@
}
.oathhammer .character-content input[name="name"],
.oathhammer .npc-content input[name="name"],
-.oathhammer .settlement-content input[name="name"] {
+.oathhammer .settlement-content input[name="name"],
+.oathhammer .regiment-content input[name="name"],
+.oathhammer .party-content input[name="name"],
+.oathhammer .army-content input[name="name"] {
height: 2.5rem;
font-family: "Sherwood", "Palatino Linotype", serif;
font-size: calc(0.86rem * 1.2);
@@ -82,14 +106,20 @@
}
.oathhammer .character-content fieldset,
.oathhammer .npc-content fieldset,
-.oathhammer .settlement-content fieldset {
+.oathhammer .settlement-content fieldset,
+.oathhammer .regiment-content fieldset,
+.oathhammer .party-content fieldset,
+.oathhammer .army-content fieldset {
margin-bottom: 4px;
border-radius: 4px;
border-color: #535128;
}
.oathhammer .character-content legend,
.oathhammer .npc-content legend,
-.oathhammer .settlement-content legend {
+.oathhammer .settlement-content legend,
+.oathhammer .regiment-content legend,
+.oathhammer .party-content legend,
+.oathhammer .army-content legend {
font-family: "BlueDragon", "Palatino Linotype", serif;
font-size: calc(0.86rem * 1.1);
font-weight: bold;
@@ -98,7 +128,10 @@
}
.oathhammer .character-content label,
.oathhammer .npc-content label,
-.oathhammer .settlement-content label {
+.oathhammer .settlement-content label,
+.oathhammer .regiment-content label,
+.oathhammer .party-content label,
+.oathhammer .army-content label {
font-family: "BlueDragon", "Palatino Linotype", serif;
font-size: 0.86rem;
color: #2a1a0a;
@@ -882,6 +915,93 @@
gap: 8px;
padding: 4px 0;
}
+.oathhammer .npc-main .regiment-vitals-grid.regiment-row1 {
+ grid-template-columns: 1fr 1fr 1fr;
+}
+.oathhammer .npc-main .regiment-vitals-grid.regiment-row2 {
+ grid-template-columns: 1fr 1fr;
+ border-top: none;
+ margin-top: 4px;
+ padding-top: 4px;
+ border-top: 1px dashed rgba(83, 81, 40, 0.5);
+}
+.oathhammer .regiment-content .npc-left {
+ min-width: 94px;
+ max-width: 94px;
+}
+.oathhammer .regiment-content .npc-left .actor-img {
+ width: 94px;
+ height: 110px;
+ -o-object-fit: cover;
+ object-fit: cover;
+ -o-object-position: center top;
+ object-position: center top;
+ border: 2px solid rgba(42, 26, 10, 0.4);
+ border-radius: 4px;
+ cursor: pointer;
+ flex-shrink: 0;
+ flex-grow: 0;
+}
+.oathhammer .regiment-fieldset {
+ border: none;
+ padding: 0;
+ margin: 0;
+}
+.oathhammer .regiment-leader-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 5px;
+ padding: 4px 6px;
+ border: 1px dashed rgba(83, 81, 40, 0.6);
+ border-radius: 3px;
+ background: rgba(0, 0, 0, 0.04);
+ min-height: 28px;
+}
+.oathhammer .regiment-leader-row .regiment-leader-label {
+ font-family: "BlueDragon", "Palatino Linotype", serif;
+ font-size: calc(0.86rem * 0.85);
+ font-weight: bold;
+ color: #2a1a0a;
+ white-space: nowrap;
+ min-width: 5.5rem;
+}
+.oathhammer .regiment-leader-row .regiment-leader-img {
+ width: 22px;
+ height: 22px;
+ border-radius: 3px;
+ border: 1px solid #535128;
+ -o-object-fit: cover;
+ object-fit: cover;
+ flex-shrink: 0;
+}
+.oathhammer .regiment-leader-row .regiment-leader-name {
+ flex: 1;
+ font-family: "BlueDragon", "Palatino Linotype", serif;
+ font-size: calc(0.86rem * 0.85);
+ color: #084a74;
+ font-weight: 600;
+ cursor: pointer;
+}
+.oathhammer .regiment-leader-row .regiment-leader-name:hover {
+ text-decoration: underline;
+ color: #c8a84b;
+}
+.oathhammer .regiment-leader-row .regiment-leader-empty {
+ flex: 1;
+ font-size: calc(0.86rem * 0.9);
+ color: rgba(42, 26, 10, 0.45);
+ font-style: italic;
+}
+.oathhammer .regiment-leader-row .regiment-leader-clear {
+ color: rgba(42, 26, 10, 0.4);
+ font-size: calc(0.86rem * 0.85);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.oathhammer .regiment-leader-row .regiment-leader-clear:hover {
+ color: #cc3333;
+}
.oathhammer .item-list {
list-style: none;
margin: 0;
@@ -1012,11 +1132,11 @@
}
.oathhammer .item-list--spell .item-list-header,
.oathhammer .item-list--spell .item-entry {
- grid-template-columns: 24px 1fr 3rem 6rem 3rem 5.5rem;
+ grid-template-columns: 24px 1fr 2.5rem 5.5rem 3.5rem 4.5rem 3.5rem 5rem;
}
.oathhammer .item-list--miracle .item-list-header,
.oathhammer .item-list--miracle .item-entry {
- grid-template-columns: 24px 1fr 4.5rem 5.5rem;
+ grid-template-columns: 24px 1fr 6rem 5rem;
}
.oathhammer .miracles-blocked {
opacity: 0.45;
@@ -1333,7 +1453,7 @@
}
.oathhammer .regiment-sheet .regiment-stats-row {
display: grid;
- grid-template-columns: 1fr 1fr 1fr;
+ grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 4px;
}
.oathhammer .regiment-sheet .regiment-stats-row .form-group > label {
@@ -2328,3 +2448,307 @@
.oathhammer .settlement-buildings-header .collect-taxes-btn i {
color: #c8a84b;
}
+.oathhammer .party-main .party-header {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ align-items: stretch;
+}
+.oathhammer .party-main .party-portrait-wrap {
+ flex-shrink: 0;
+}
+.oathhammer .party-main .party-portrait-wrap .party-portrait {
+ width: 94px;
+ height: 110px;
+ -o-object-fit: cover;
+ object-fit: cover;
+ -o-object-position: center top;
+ object-position: center top;
+ border: 2px solid rgba(42, 26, 10, 0.4);
+ border-radius: 4px;
+ cursor: pointer;
+}
+.oathhammer .party-main .party-header-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 6px;
+ min-width: 0;
+}
+.oathhammer .party-main .party-header-body .character-name {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ border-bottom: 1px solid #535128;
+ padding-bottom: 4px;
+}
+.oathhammer .party-main .party-header-body .character-name input,
+.oathhammer .party-main .party-header-body .character-name span {
+ flex: 1;
+ min-width: 0;
+ font-family: "Sherwood", "Palatino Linotype", serif;
+ font-size: calc(0.86rem * 1.1);
+}
+.oathhammer .party-main .party-header-body .character-name > .control {
+ flex-shrink: 0;
+}
+.oathhammer .party-main .party-header-fieldset {
+ border: none;
+ padding: 0;
+ margin: 0;
+}
+.oathhammer .party-main .party-treasury {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 4px 6px;
+ border: 1px solid #535128;
+ border-radius: 3px;
+ background: rgba(0, 0, 0, 0.08);
+ flex-wrap: wrap;
+}
+.oathhammer .party-main .party-treasury .party-treasury-label {
+ font-family: "BlueDragon", "Palatino Linotype", serif;
+ font-size: calc(0.86rem * 0.85);
+ font-weight: bold;
+ color: #2a1a0a;
+ white-space: nowrap;
+ min-width: 4rem;
+}
+.oathhammer .party-main .party-treasury .party-currency {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex: 1;
+ min-width: 7rem;
+}
+.oathhammer .party-main .party-treasury .party-currency .currency-label {
+ font-family: "BlueDragon", "Palatino Linotype", serif;
+ font-size: calc(0.86rem * 0.9);
+ font-weight: bold;
+ color: #2a1a0a;
+ white-space: nowrap;
+ min-width: 1.8rem;
+}
+.oathhammer .party-main .party-treasury .party-currency .currency-stepper {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+.oathhammer .party-main .party-treasury .party-currency .currency-stepper input {
+ width: 3.5rem;
+ text-align: center;
+ font-size: calc(0.86rem * 0.85);
+ padding: 1px 2px;
+}
+.oathhammer .party-main .party-treasury .party-currency .currency-stepper .currency-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.1rem;
+ height: 1.1rem;
+ font-size: 0.85rem;
+ font-weight: bold;
+ line-height: 1;
+ border: 1px solid #535128;
+ border-radius: 3px;
+ background: rgba(83, 81, 40, 0.2);
+ color: #2a1a0a;
+ cursor: pointer;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+ flex-shrink: 0;
+}
+.oathhammer .party-main .party-treasury .party-currency .currency-stepper .currency-btn:hover {
+ background: #c8a84b;
+ border-color: #c8a84b;
+}
+.oathhammer .party-main .party-treasury .party-currency-gp .currency-label {
+ color: #987d2e;
+}
+.oathhammer .party-main .party-treasury .party-currency-sp .currency-label {
+ color: #888;
+}
+.oathhammer .party-main .party-treasury .party-currency-cp .currency-label {
+ color: #aa6633;
+}
+.oathhammer .item-list--party-member .item-list-header,
+.oathhammer .item-list--party-member .item-entry {
+ grid-template-columns: 1.8rem 24px 1fr 7rem 3rem 5rem 5.5rem;
+}
+.oathhammer .item-list--party-member .party-member-order {
+ font-family: "BlueDragon", "Palatino Linotype", serif;
+ font-size: calc(0.86rem * 0.9);
+ font-weight: bold;
+ color: #535128;
+ text-align: center;
+ align-self: center;
+}
+.oathhammer .item-list--party-loot .item-list-header,
+.oathhammer .item-list--party-loot .item-entry {
+ grid-template-columns: 24px 1fr 6rem 5.5rem 5rem;
+}
+.oathhammer .item-list--party-loot .item-qty {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ font-size: calc(0.86rem * 0.85);
+}
+.oathhammer .item-list--party-loot .item-qty span {
+ min-width: 1.5rem;
+ text-align: center;
+}
+.oathhammer .item-list--party-loot .item-qty .qty-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1rem;
+ height: 1rem;
+ font-size: 0.8rem;
+ font-weight: bold;
+ line-height: 1;
+ border: 1px solid #535128;
+ border-radius: 2px;
+ background: rgba(83, 81, 40, 0.2);
+ cursor: pointer;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+}
+.oathhammer .item-list--party-loot .item-qty .qty-btn:hover {
+ background: #c8a84b;
+ border-color: #c8a84b;
+}
+.oathhammer .army-main .army-header {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ align-items: stretch;
+}
+.oathhammer .army-main .army-portrait-wrap {
+ flex-shrink: 0;
+}
+.oathhammer .army-main .army-portrait-wrap .army-portrait {
+ width: 94px;
+ height: 110px;
+ -o-object-fit: cover;
+ object-fit: cover;
+ -o-object-position: center top;
+ object-position: center top;
+ border: 2px solid rgba(42, 26, 10, 0.4);
+ border-radius: 4px;
+ cursor: pointer;
+}
+.oathhammer .army-main .army-header-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 5px;
+ min-width: 0;
+}
+.oathhammer .army-main .army-header-body .character-name {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ border-bottom: 1px solid #535128;
+ padding-bottom: 4px;
+}
+.oathhammer .army-main .army-header-body .character-name input,
+.oathhammer .army-main .army-header-body .character-name span {
+ flex: 1;
+ min-width: 0;
+ font-family: "Sherwood", "Palatino Linotype", serif;
+ font-size: calc(0.86rem * 1.1);
+}
+.oathhammer .army-main .army-header-body .character-name > .control {
+ flex-shrink: 0;
+}
+.oathhammer .army-main .army-header-fieldset {
+ border: none;
+ padding: 0;
+ margin: 0;
+}
+.oathhammer .army-main .army-leader-row {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 5px;
+ border: 1px dashed #535128;
+ border-radius: 3px;
+ min-height: 2rem;
+ background: rgba(83, 81, 40, 0.05);
+}
+.oathhammer .army-main .army-leader-row .army-field-label {
+ font-family: "BlueDragon", "Palatino Linotype", serif;
+ font-size: calc(0.86rem * 0.85);
+ font-weight: bold;
+ color: #2a1a0a;
+ white-space: nowrap;
+ min-width: 6rem;
+}
+.oathhammer .army-main .army-leader-row .army-leader-img {
+ width: 24px;
+ height: 24px;
+ -o-object-fit: cover;
+ object-fit: cover;
+ border-radius: 3px;
+ border: 1px solid #535128;
+}
+.oathhammer .army-main .army-leader-row .army-leader-name {
+ flex: 1;
+ font-size: calc(0.86rem * 0.85);
+ color: #2a1a0a;
+}
+.oathhammer .army-main .army-leader-row .army-leader-name:hover {
+ color: #535128;
+ text-decoration: underline;
+}
+.oathhammer .army-main .army-leader-row .army-leader-clear {
+ color: #535128;
+}
+.oathhammer .army-main .army-leader-row .army-leader-clear:hover {
+ color: #aa3333;
+}
+.oathhammer .army-main .army-leader-row .army-field-empty {
+ flex: 1;
+ font-size: calc(0.86rem * 0.9);
+ color: rgba(42, 26, 10, 0.5);
+ font-style: italic;
+}
+.oathhammer .army-main .army-location-row {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+.oathhammer .army-main .army-location-row .army-field-label {
+ font-family: "BlueDragon", "Palatino Linotype", serif;
+ font-size: calc(0.86rem * 0.85);
+ font-weight: bold;
+ color: #2a1a0a;
+ white-space: nowrap;
+ min-width: 6rem;
+}
+.oathhammer .army-main .army-location-row input {
+ flex: 1;
+ font-size: calc(0.86rem * 0.85);
+}
+.oathhammer .item-list--army-regiment .item-list-header,
+.oathhammer .item-list--army-regiment .item-entry {
+ grid-template-columns: 24px 1fr 4.5rem 4.5rem 4.5rem 4.5rem 3rem;
+}
+.oathhammer .item-list--army-regiment .army-total-row {
+ border-top: 2px solid #535128;
+ font-weight: bold;
+}
+.oathhammer .item-list--army-regiment .army-total-row .army-total-label {
+ font-family: "BlueDragon", "Palatino Linotype", serif;
+ font-size: calc(0.86rem * 0.85);
+ color: #2a1a0a;
+}
+.oathhammer .item-list--army-regiment .army-total-row .army-total-value {
+ color: #987d2e;
+ font-family: "BlueDragon", "Palatino Linotype", serif;
+}
diff --git a/lang/en.json b/lang/en.json
index 5f52ff7..2966300 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -18,7 +18,9 @@
"Settlement": "Oath Hammer Settlement Sheet",
"SkillNPC": "Oath Hammer NPC Skill Sheet",
"NpcAttack": "Oath Hammer NPC Attack Sheet",
- "Regiment": "Oath Hammer Regiment Sheet"
+ "Regiment": "Oath Hammer Regiment Sheet",
+ "Party": "Party Sheet",
+ "Army": "Army Sheet"
},
"Tab": {
"Identity": "Oaths / Traits",
@@ -31,7 +33,9 @@
"Buildings": "Buildings",
"Inventory": "Inventory",
"Traits": "Traits",
- "Garrison": "Garrison"
+ "Garrison": "Garrison",
+ "Members": "Members",
+ "Loot": "Loot"
},
"Attribute": {
"Might": "Might",
@@ -229,7 +233,7 @@
"DefenseValue": "Defense Value",
"ArmorRating": "Armor Rating",
"DefenseBonus": "Defense Bonus",
- "Movement": "Movement",
+ "Movement": "Move",
"ArcaneStress": "Arcane Stress",
"StressValue": "Stress",
"ThresholdBonus": "Threshold Bonus",
@@ -254,7 +258,7 @@
"Conditions": "Conditions",
"Description": "Description",
"Notes": "Notes",
- "Stats": "Statistics",
+ "Stats": "Stats",
"CR": "Challenge Rating",
"AttackBonus": "Attack Bonus",
"DamageBonus": "Damage Bonus",
@@ -337,15 +341,34 @@
"Dice": "Dice",
"DiceColor": "Color",
"Special": "Special",
- "Movement": "Move",
- "Stats": "Stats",
- "NoRegiments": "No regiments. Add one with the + button.",
+ "NoRegiments": "No regiments yet — drag regiment actors here.",
+ "SupplyCost": "Supply Cost",
"SkillName": "Skill name",
"AttackName": "Attack name",
"TraitName": "Trait name",
"Edit": "Edit",
"Delete": "Delete",
- "Rank": "Rank"
+ "Rank": "Rank",
+ "RemoveFromGarrison": "Remove from Garrison",
+ "RecruitmentCost": "Recruitment Cost",
+ "UnitLeader": "Unit Leader",
+ "DropLeaderHint": "Drop a linked actor here",
+ "MarchingOrder": "Marching Order",
+ "NoMembers": "No members yet — drag characters here.",
+ "DropMemberHint": "Drag a character actor here to add them to the party.",
+ "Loot": "Loot",
+ "NoLoot": "No loot yet — drag items here.",
+ "DropLootHint": "Drag weapons, armor or equipment here to add party loot.",
+ "Qty": "Qty",
+ "GP": "GP",
+ "SP": "SP",
+ "CP": "CP",
+ "Class": "Class",
+ "Commander": "Commander",
+ "Location": "Location",
+ "Regiments": "Regiments",
+ "DropRegimentHint": "Drag a regiment actor (must be token-linked) to add it to this army.",
+ "TotalSupply": "Total Supply"
},
"ColorDice": {
"White": "White (4+)",
@@ -364,11 +387,18 @@
"Regiment": "New Regiment",
"RegimentSkill": "Add Skill",
"RegimentAttack": "Add Attack",
- "RegimentTrait": "Add Trait"
+ "RegimentTrait": "Add Trait",
+ "SkillNPC": "New Skill"
},
"ToggleSheet": "Toggle Edit/Play Mode",
"Tooltip": {
- "RollArmor": "Roll Armor Dice"
+ "RollArmor": "Roll Armor Dice",
+ "OpenLeader": "Open leader sheet",
+ "ClearLeader": "Remove unit leader",
+ "MoveUp": "Move up (march forward)",
+ "MoveDown": "Move down (march back)",
+ "RemoveMember": "Remove from party",
+ "RemoveRegiment": "Remove regiment from army"
},
"Action": {
"CastSpell": "Cast Spell",
@@ -462,7 +492,9 @@
"APPenalty": "AP (Attacker)",
"APHint": "attacker's Armor Piercing value",
"ReinforcedHint": "Reinforced — rolling red dice",
- "RollInitiative": "Roll Initiative"
+ "RollInitiative": "Roll Initiative",
+ "Default": "Default",
+ "DicePool": "Dice Pool"
},
"Enhancement": {
"None": "None",
@@ -1056,7 +1088,9 @@
}
},
"Warning": {
- "MiracleBlocked": "Divine favour has been lost — you cannot invoke miracles until a new day."
+ "MiracleBlocked": "Divine favour has been lost — you cannot invoke miracles until a new day.",
+ "LeaderNotLinked": "This actor's token is not linked. Only actors with linked tokens can be unit leaders.",
+ "RegimentNotLinked": "This regiment actor is not linked to its token. Only token-linked regiment actors can be added to an army."
},
"SettlementArchetype": {
"CenterOfLearning": "Center of Learning",
@@ -1076,7 +1110,8 @@
"CollectTaxes": "Collect Taxes",
"CollectTaxesTooltip": "Roll tax revenue for all constructed buildings and total the result.",
"NoTaxRevenue": "No constructed buildings with tax revenue defined.",
- "TotalRevenue": "Total Revenue"
+ "TotalRevenue": "Total Revenue",
+ "GarrisonHint": "Drag a regiment actor here to add it to the garrison."
},
"SkillNPC": {
"FIELDS": {
@@ -1124,7 +1159,8 @@
"NpcSubtype": {
"Creature": "Creature",
"Npc": "NPC"
- }
+ },
+ "Party": {}
},
"TYPES": {
"Item": {
@@ -1141,13 +1177,15 @@
"class": "Class",
"skillnpc": "NPC Skill",
"npcattack": "NPC Attack",
- "regiment": "Regiment",
"building": "Building"
},
"Actor": {
"character": "Character",
"npc": "NPC / Creature",
- "settlement": "Settlement"
+ "settlement": "Settlement",
+ "regiment": "Regiment",
+ "party": "Party",
+ "army": "Army"
}
}
}
\ No newline at end of file
diff --git a/less/army-sheet.less b/less/army-sheet.less
new file mode 100644
index 0000000..2c7e4c3
--- /dev/null
+++ b/less/army-sheet.less
@@ -0,0 +1,152 @@
+// ============================================================
+// ARMY ACTOR SHEET
+// ============================================================
+
+.oathhammer .army-main {
+
+ .army-header {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ align-items: stretch;
+ }
+
+ .army-portrait-wrap {
+ flex-shrink: 0;
+
+ .army-portrait {
+ width: 94px;
+ height: 110px;
+ object-fit: cover;
+ object-position: center top;
+ border: 2px solid fade(@color-dark, 40%);
+ border-radius: 4px;
+ cursor: pointer;
+ }
+ }
+
+ .army-header-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 5px;
+ min-width: 0;
+
+ .character-name {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ border-bottom: 1px solid @color-olive;
+ padding-bottom: 4px;
+
+ input, span {
+ flex: 1;
+ min-width: 0;
+ font-family: @font-primary;
+ font-size: @font-size-lg;
+ }
+
+ > .control { flex-shrink: 0; }
+ }
+ }
+
+ .army-header-fieldset {
+ border: none;
+ padding: 0;
+ margin: 0;
+ }
+
+ // ── Leader row ─────────────────────────────────────────────
+ .army-leader-row {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 5px;
+ border: 1px dashed @color-olive;
+ border-radius: 3px;
+ min-height: 2rem;
+ background: fade(@color-olive, 5%);
+
+ .army-field-label {
+ font-family: @font-secondary;
+ font-size: @font-size-sm;
+ font-weight: bold;
+ color: @color-dark;
+ white-space: nowrap;
+ min-width: 6rem;
+ }
+
+ .army-leader-img {
+ width: 24px;
+ height: 24px;
+ object-fit: cover;
+ border-radius: 3px;
+ border: 1px solid @color-olive;
+ }
+
+ .army-leader-name {
+ flex: 1;
+ font-size: @font-size-sm;
+ color: @color-dark;
+ &:hover { color: @color-olive; text-decoration: underline; }
+ }
+
+ .army-leader-clear {
+ color: @color-olive;
+ &:hover { color: #aa3333; }
+ }
+
+ .army-field-empty {
+ flex: 1;
+ font-size: @font-size-xs;
+ color: fade(@color-dark, 50%);
+ font-style: italic;
+ }
+ }
+
+ // ── Location row ───────────────────────────────────────────
+ .army-location-row {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+
+ .army-field-label {
+ font-family: @font-secondary;
+ font-size: @font-size-sm;
+ font-weight: bold;
+ color: @color-dark;
+ white-space: nowrap;
+ min-width: 6rem;
+ }
+
+ input {
+ flex: 1;
+ font-size: @font-size-sm;
+ }
+ }
+}
+
+// ── Regiment list ──────────────────────────────────────────────
+.oathhammer .item-list--army-regiment {
+ .item-list-header, .item-entry {
+ // img | name | grit | armor | movement | supply | actions
+ grid-template-columns: @item-img-size 1fr 4.5rem 4.5rem 4.5rem 4.5rem 3rem;
+ }
+
+ .army-total-row {
+ border-top: 2px solid @color-olive;
+ font-weight: bold;
+
+ .army-total-label {
+ font-family: @font-secondary;
+ font-size: @font-size-sm;
+ color: @color-dark;
+ }
+
+ .army-total-value {
+ color: darken(@color-gold, 15%);
+ font-family: @font-secondary;
+ }
+ }
+}
diff --git a/less/base.less b/less/base.less
index bf1d77a..2310127 100644
--- a/less/base.less
+++ b/less/base.less
@@ -37,7 +37,10 @@
// Shared actor content base
.oathhammer .character-content,
.oathhammer .npc-content,
-.oathhammer .settlement-content {
+.oathhammer .settlement-content,
+.oathhammer .regiment-content,
+.oathhammer .party-content,
+.oathhammer .army-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 dc5d234..8aa4ffd 100644
--- a/less/fvtt-oath-hammer.less
+++ b/less/fvtt-oath-hammer.less
@@ -12,3 +12,5 @@
@import "rolls";
@import "roll-dialog";
@import "settlement-sheet";
+@import "party-sheet";
+@import "army-sheet";
diff --git a/less/item-list.less b/less/item-list.less
index f89a952..74ea3fe 100644
--- a/less/item-list.less
+++ b/less/item-list.less
@@ -148,13 +148,15 @@
.item-list--spell {
.item-list-header, .item-entry {
- grid-template-columns: @item-img-size 1fr 3rem 6rem 3rem 5.5rem;
+ // img | name | DV | Tradition | Range | Duration | SpellSave | actions
+ grid-template-columns: @item-img-size 1fr 2.5rem 5.5rem 3.5rem 4.5rem 3.5rem 5rem;
}
}
.item-list--miracle {
.item-list-header, .item-entry {
- grid-template-columns: @item-img-size 1fr 4.5rem 5.5rem;
+ // img | name | DivineTradition | actions
+ grid-template-columns: @item-img-size 1fr 6rem 5rem;
}
}
diff --git a/less/item-sheets.less b/less/item-sheets.less
index 2751ed1..f7c1852 100644
--- a/less/item-sheets.less
+++ b/less/item-sheets.less
@@ -207,7 +207,7 @@
.regiment-stats-row {
display: grid;
- grid-template-columns: 1fr 1fr 1fr;
+ grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 4px;
.form-group > label { flex: 0 0 6rem; }
diff --git a/less/npc-sheet.less b/less/npc-sheet.less
index bcec37f..c14618a 100644
--- a/less/npc-sheet.less
+++ b/less/npc-sheet.less
@@ -207,3 +207,100 @@
padding: 4px 0;
}
+
+// ============================================================
+// REGIMENT ACTOR SHEET overrides
+// ============================================================
+
+// Regiment uses the same .npc-main structure but split into 2 rows
+.oathhammer .npc-main .regiment-vitals-grid {
+ &.regiment-row1 { grid-template-columns: 1fr 1fr 1fr; }
+ &.regiment-row2 {
+ grid-template-columns: 1fr 1fr;
+ border-top: none;
+ margin-top: 4px;
+ padding-top: 4px;
+ border-top: 1px dashed fade(@color-olive, 50%);
+ }
+}
+
+// Regiment portrait — same dimensions as settlement sheet
+.oathhammer .regiment-content .npc-left {
+ min-width: 94px;
+ max-width: 94px;
+
+ .actor-img {
+ width: 94px;
+ height: 110px;
+ object-fit: cover;
+ object-position: center top;
+ border: 2px solid fade(@color-dark, 40%);
+ border-radius: 4px;
+ cursor: pointer;
+ flex-shrink: 0;
+ flex-grow: 0;
+ }
+}
+
+// Regiment fieldset — remove default fieldset border for clean header look
+.oathhammer .regiment-fieldset {
+ border: none;
+ padding: 0;
+ margin: 0;
+}
+
+// ── Regiment leader row ───────────────────────────────────────
+.oathhammer .regiment-leader-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 5px;
+ padding: 4px 6px;
+ border: 1px dashed fade(@color-olive, 60%);
+ border-radius: 3px;
+ background: rgba(0,0,0,0.04);
+ min-height: 28px;
+
+ .regiment-leader-label {
+ font-family: @font-secondary;
+ font-size: @font-size-sm;
+ font-weight: bold;
+ color: @color-dark;
+ white-space: nowrap;
+ min-width: 5.5rem;
+ }
+
+ .regiment-leader-img {
+ width: 22px;
+ height: 22px;
+ border-radius: 3px;
+ border: 1px solid @color-olive;
+ object-fit: cover;
+ flex-shrink: 0;
+ }
+
+ .regiment-leader-name {
+ flex: 1;
+ font-family: @font-secondary;
+ font-size: @font-size-sm;
+ color: @color-blue;
+ font-weight: 600;
+ cursor: pointer;
+ &:hover { text-decoration: underline; color: @color-gold; }
+ }
+
+ .regiment-leader-empty {
+ flex: 1;
+ font-size: @font-size-xs;
+ color: fade(@color-dark, 45%);
+ font-style: italic;
+ }
+
+ .regiment-leader-clear {
+ color: fade(@color-dark, 40%);
+ font-size: @font-size-sm;
+ cursor: pointer;
+ flex-shrink: 0;
+ &:hover { color: #cc3333; }
+ }
+}
diff --git a/less/party-sheet.less b/less/party-sheet.less
new file mode 100644
index 0000000..87599bd
--- /dev/null
+++ b/less/party-sheet.less
@@ -0,0 +1,186 @@
+// ============================================================
+// PARTY ACTOR SHEET
+// ============================================================
+
+.oathhammer .party-main {
+
+ .party-header {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ align-items: stretch;
+ }
+
+ .party-portrait-wrap {
+ flex-shrink: 0;
+
+ .party-portrait {
+ width: 94px;
+ height: 110px;
+ object-fit: cover;
+ object-position: center top;
+ border: 2px solid fade(@color-dark, 40%);
+ border-radius: 4px;
+ cursor: pointer;
+ }
+ }
+
+ .party-header-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 6px;
+ min-width: 0;
+
+ .character-name {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ border-bottom: 1px solid @color-olive;
+ padding-bottom: 4px;
+
+ input, span {
+ flex: 1;
+ min-width: 0;
+ font-family: @font-primary;
+ font-size: @font-size-lg;
+ }
+
+ > .control {
+ flex-shrink: 0;
+ }
+ }
+ }
+
+ .party-header-fieldset {
+ border: none;
+ padding: 0;
+ margin: 0;
+ }
+
+ // ── Treasury ───────────────────────────────────────────────
+ .party-treasury {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 4px 6px;
+ border: 1px solid @color-olive;
+ border-radius: 3px;
+ background: rgba(0,0,0,0.08);
+ flex-wrap: wrap;
+
+ .party-treasury-label {
+ font-family: @font-secondary;
+ font-size: @font-size-sm;
+ font-weight: bold;
+ color: @color-dark;
+ white-space: nowrap;
+ min-width: 4rem;
+ }
+
+ .party-currency {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex: 1;
+ min-width: 7rem;
+
+ .currency-label {
+ font-family: @font-secondary;
+ font-size: @font-size-xs;
+ font-weight: bold;
+ color: @color-dark;
+ white-space: nowrap;
+ min-width: 1.8rem;
+ }
+
+ .currency-stepper {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+
+ input {
+ width: 3.5rem;
+ text-align: center;
+ font-size: @font-size-sm;
+ padding: 1px 2px;
+ }
+
+ .currency-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.1rem;
+ height: 1.1rem;
+ font-size: 0.85rem;
+ font-weight: bold;
+ line-height: 1;
+ border: 1px solid @color-olive;
+ border-radius: 3px;
+ background: @color-olive-faint;
+ color: @color-dark;
+ cursor: pointer;
+ user-select: none;
+ flex-shrink: 0;
+ &:hover { background: @color-gold; border-color: @color-gold; }
+ }
+ }
+ }
+
+ .party-currency-gp .currency-label { color: darken(@color-gold, 15%); }
+ .party-currency-sp .currency-label { color: #888; }
+ .party-currency-cp .currency-label { color: #aa6633; }
+ }
+}
+
+// ── Member list ────────────────────────────────────────────────
+.oathhammer .item-list--party-member {
+ .item-list-header, .item-entry {
+ // order# | img | name | class | level | grit | actions
+ grid-template-columns: 1.8rem @item-img-size 1fr 7rem 3rem 5rem 5.5rem;
+ }
+
+ .party-member-order {
+ font-family: @font-secondary;
+ font-size: @font-size-xs;
+ font-weight: bold;
+ color: @color-olive;
+ text-align: center;
+ align-self: center;
+ }
+}
+
+// ── Loot list ──────────────────────────────────────────────────
+.oathhammer .item-list--party-loot {
+ .item-list-header, .item-entry {
+ // img | name | type | qty | actions
+ grid-template-columns: @item-img-size 1fr 6rem 5.5rem 5rem;
+ }
+
+ .item-qty {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ font-size: @font-size-sm;
+
+ span { min-width: 1.5rem; text-align: center; }
+
+ .qty-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1rem;
+ height: 1rem;
+ font-size: 0.8rem;
+ font-weight: bold;
+ line-height: 1;
+ border: 1px solid @color-olive;
+ border-radius: 2px;
+ background: @color-olive-faint;
+ cursor: pointer;
+ user-select: none;
+ &:hover { background: @color-gold; border-color: @color-gold; }
+ }
+ }
+}
diff --git a/module/applications/_module.mjs b/module/applications/_module.mjs
index 9c15b57..170e230 100644
--- a/module/applications/_module.mjs
+++ b/module/applications/_module.mjs
@@ -15,6 +15,8 @@ export { default as OathHammerSettlementSheet } from "./sheets/settlement-sheet.
export { default as OathHammerSkillNPCSheet } from "./sheets/skillnpc-sheet.mjs"
export { default as OathHammerNpcAttackSheet } from "./sheets/npcattack-sheet.mjs"
export { default as OathHammerRegimentSheet } from "./sheets/regiment-sheet.mjs"
+export { default as OathHammerPartySheet } from "./sheets/party-sheet.mjs"
+export { default as OathHammerArmySheet } from "./sheets/army-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/sheets/army-sheet.mjs b/module/applications/sheets/army-sheet.mjs
new file mode 100644
index 0000000..5966868
--- /dev/null
+++ b/module/applications/sheets/army-sheet.mjs
@@ -0,0 +1,152 @@
+import OathHammerActorSheet from "./base-actor-sheet.mjs"
+
+export default class OathHammerArmySheet extends OathHammerActorSheet {
+ /** @override */
+ static DEFAULT_OPTIONS = {
+ classes: ["army"],
+ position: { width: 680, height: 560 },
+ window: { contentClasses: ["army-content"] },
+ actions: {
+ openRegiment: OathHammerArmySheet.#onOpenRegiment,
+ removeRegiment: OathHammerArmySheet.#onRemoveRegiment,
+ openLeader: OathHammerArmySheet.#onOpenLeader,
+ clearLeader: OathHammerArmySheet.#onClearLeader,
+ },
+ }
+
+ /** @override */
+ static PARTS = {
+ main: { template: "systems/fvtt-oath-hammer/templates/actor/army-sheet.hbs" },
+ tabs: { template: "templates/generic/tab-navigation.hbs" },
+ overview: { template: "systems/fvtt-oath-hammer/templates/actor/army-overview.hbs" },
+ notes: { template: "systems/fvtt-oath-hammer/templates/actor/army-notes.hbs" },
+ }
+
+ tabGroups = { sheet: "overview" }
+
+ #getTabs() {
+ const tabs = {
+ overview: { id: "overview", group: "sheet", icon: "fa-solid fa-shield-halved", label: "OATHHAMMER.Tab.Overview" },
+ notes: { id: "notes", group: "sheet", icon: "fa-solid fa-book", label: "OATHHAMMER.Tab.Notes" },
+ }
+ 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()
+ const doc = this.document
+ context.tabs = this.#getTabs()
+
+ // Resolve leader
+ const leaderUuid = doc.system.leaderUuid
+ if (leaderUuid) {
+ const leader = await fromUuid(leaderUuid)
+ context.leader = leader ? { uuid: leaderUuid, name: leader.name, img: leader.img } : null
+ } else {
+ context.leader = null
+ }
+
+ return context
+ }
+
+ /** @override */
+ async _preparePartContext(partId, context) {
+ context = await super._preparePartContext(partId, context)
+ const doc = this.document
+
+ switch (partId) {
+ case "overview": {
+ context.tab = context.tabs.overview
+ const refs = doc.system.regimentRefs ?? []
+ const regiments = []
+ let totalSupply = 0
+ for (const id of refs) {
+ const regiment = game.actors?.get(id)
+ if (!regiment) continue
+ totalSupply += regiment.system.supplyCost ?? 0
+ regiments.push({
+ id: regiment.id,
+ name: regiment.name,
+ img: regiment.img,
+ grit: regiment.system.grit?.value ?? 0,
+ gritMax: regiment.system.grit?.max ?? 0,
+ armor: regiment.system.armorDice?.value ?? 0,
+ movement: regiment.system.movement ?? 0,
+ supplyCost: regiment.system.supplyCost ?? 0,
+ })
+ }
+ context.regiments = regiments
+ context.totalSupply = totalSupply
+ break
+ }
+
+ case "notes":
+ context.tab = context.tabs.notes
+ context.enrichedNotes = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
+ doc.system.notes ?? "", { async: true }
+ )
+ 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 === "Actor") {
+ const actor = await fromUuid(data.uuid)
+ if (!actor) return
+
+ // Leader drop (on leader drop zone)
+ if (event.target.closest(".army-leader-row")) {
+ if (!actor.prototypeToken?.actorLink) {
+ ui.notifications.warn(game.i18n.localize("OATHHAMMER.Warning.LeaderNotLinked"))
+ return
+ }
+ return this.document.update({ "system.leaderUuid": actor.uuid })
+ }
+
+ // Regiment drop
+ if (actor.type !== "regiment") return
+ if (!actor.prototypeToken?.actorLink) {
+ ui.notifications.warn(game.i18n.localize("OATHHAMMER.Warning.RegimentNotLinked"))
+ return
+ }
+ const refs = foundry.utils.deepClone(this.document.system.regimentRefs ?? [])
+ if (refs.includes(actor.id)) return
+ refs.push(actor.id)
+ return this.document.update({ "system.regimentRefs": refs })
+ }
+ }
+
+ // ── Actions ─────────────────────────────────────────────────────────────────
+
+ static async #onOpenRegiment(event, target) {
+ const actor = game.actors?.get(target.dataset.actorId)
+ if (actor) actor.sheet.render(true)
+ }
+
+ static async #onRemoveRegiment(event, target) {
+ const id = target.dataset.actorId
+ const refs = (this.document.system.regimentRefs ?? []).filter(r => r !== id)
+ await this.document.update({ "system.regimentRefs": refs })
+ }
+
+ static async #onOpenLeader() {
+ const uuid = this.document.system.leaderUuid
+ if (!uuid) return
+ const leader = await fromUuid(uuid)
+ if (leader) leader.sheet.render(true)
+ }
+
+ static async #onClearLeader() {
+ await this.document.update({ "system.leaderUuid": null })
+ }
+}
diff --git a/module/applications/sheets/character-sheet.mjs b/module/applications/sheets/character-sheet.mjs
index 9a9762b..c80f7af 100644
--- a/module/applications/sheets/character-sheet.mjs
+++ b/module/applications/sheets/character-sheet.mjs
@@ -205,11 +205,13 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
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)
+ _descTooltip: _stripHtml(s.system.effect),
+ traditionLabel: game.i18n.localize(SYSTEM.SORCEROUS_TRADITIONS[s.system.tradition]?.label ?? s.system.tradition)
}))
context.miracles = doc.itemTypes.miracle.map(m => ({
id: m.id, uuid: m.uuid, img: m.img, name: m.name, system: m.system,
- _descTooltip: _stripHtml(m.system.effect)
+ _descTooltip: _stripHtml(m.system.effect),
+ traditionLabel: game.i18n.localize(SYSTEM.DIVINE_TRADITIONS[m.system.divineTradition] ?? m.system.divineTradition)
}))
break
case "equipment":
diff --git a/module/applications/sheets/npc-sheet.mjs b/module/applications/sheets/npc-sheet.mjs
index 634b697..42113b7 100644
--- a/module/applications/sheets/npc-sheet.mjs
+++ b/module/applications/sheets/npc-sheet.mjs
@@ -107,11 +107,13 @@ export default class OathHammerNPCSheet extends OathHammerActorSheet {
context.tab = context.tabs.magic
context.spells = (doc.itemTypes.spell ?? []).map(s => ({
id: s.id, uuid: s.uuid, img: s.img, name: s.name, system: s.system,
- _descTooltip: s.system.effect?.replace(/<[^>]+>/g, "").slice(0, 300) ?? ""
+ _descTooltip: s.system.effect?.replace(/<[^>]+>/g, "").slice(0, 300) ?? "",
+ traditionLabel: game.i18n.localize(SYSTEM.SORCEROUS_TRADITIONS[s.system.tradition]?.label ?? s.system.tradition)
}))
context.miracles = (doc.itemTypes.miracle ?? []).map(m => ({
id: m.id, uuid: m.uuid, img: m.img, name: m.name, system: m.system,
- _descTooltip: m.system.effect?.replace(/<[^>]+>/g, "").slice(0, 300) ?? ""
+ _descTooltip: m.system.effect?.replace(/<[^>]+>/g, "").slice(0, 300) ?? "",
+ traditionLabel: game.i18n.localize(SYSTEM.DIVINE_TRADITIONS[m.system.divineTradition] ?? m.system.divineTradition)
}))
break
case "equipment":
diff --git a/module/applications/sheets/party-sheet.mjs b/module/applications/sheets/party-sheet.mjs
new file mode 100644
index 0000000..32fea02
--- /dev/null
+++ b/module/applications/sheets/party-sheet.mjs
@@ -0,0 +1,170 @@
+import OathHammerActorSheet from "./base-actor-sheet.mjs"
+
+const ALLOWED_LOOT_TYPES = new Set(["weapon", "armor", "ammunition", "equipment", "magic-item"])
+
+export default class OathHammerPartySheet extends OathHammerActorSheet {
+ /** @override */
+ static DEFAULT_OPTIONS = {
+ classes: ["party"],
+ position: { width: 780, height: 600 },
+ window: { contentClasses: ["party-content"] },
+ actions: {
+ openMember: OathHammerPartySheet.#onOpenMember,
+ removeMember: OathHammerPartySheet.#onRemoveMember,
+ moveMemberUp: OathHammerPartySheet.#onMoveMemberUp,
+ moveMemberDown: OathHammerPartySheet.#onMoveMemberDown,
+ adjustCurrency: OathHammerPartySheet.#onAdjustCurrency,
+ adjustQty: OathHammerPartySheet.#onAdjustQty,
+ },
+ }
+
+ /** @override */
+ static PARTS = {
+ main: { template: "systems/fvtt-oath-hammer/templates/actor/party-sheet.hbs" },
+ tabs: { template: "templates/generic/tab-navigation.hbs" },
+ members: { template: "systems/fvtt-oath-hammer/templates/actor/party-members.hbs" },
+ loot: { template: "systems/fvtt-oath-hammer/templates/actor/party-loot.hbs" },
+ notes: { template: "systems/fvtt-oath-hammer/templates/actor/party-notes.hbs" },
+ }
+
+ tabGroups = { sheet: "members" }
+
+ #getTabs() {
+ const tabs = {
+ members: { id: "members", group: "sheet", icon: "fa-solid fa-users", label: "OATHHAMMER.Tab.Members" },
+ loot: { id: "loot", group: "sheet", icon: "fa-solid fa-treasure-chest", label: "OATHHAMMER.Tab.Loot" },
+ notes: { id: "notes", group: "sheet", icon: "fa-solid fa-book", label: "OATHHAMMER.Tab.Notes" },
+ }
+ 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()
+ return context
+ }
+
+ /** @override */
+ async _preparePartContext(partId, context) {
+ const doc = this.document
+ switch (partId) {
+ case "main":
+ break
+
+ case "members": {
+ context.tab = context.tabs.members
+ const refs = doc.system.memberRefs ?? []
+ context.members = refs.map((id, idx) => {
+ const actor = game.actors?.get(id)
+ if (!actor) return null
+ const sys = actor.system
+ const classItem = actor.items?.find(i => i.type === "class")
+ return {
+ id: actor.id,
+ name: actor.name,
+ img: actor.img,
+ idx,
+ position: idx + 1,
+ isFirst: idx === 0,
+ isLast: idx === refs.length - 1,
+ classLabel: classItem?.name ?? "—",
+ level: sys.level ?? "—",
+ grit: sys.grit ? `${sys.grit.value}/${sys.grit.max}` : "—",
+ }
+ }).filter(Boolean)
+ break
+ }
+
+ case "loot": {
+ context.tab = context.tabs.loot
+ const allItems = doc.items.contents.filter(i => ALLOWED_LOOT_TYPES.has(i.type))
+ context.lootItems = allItems.map(i => ({
+ id: i.id, uuid: i.uuid, img: i.img, name: i.name,
+ type: i.type,
+ typeLabel: game.i18n.localize(`TYPES.Item.${i.type}`),
+ system: i.system,
+ }))
+ break
+ }
+
+ case "notes":
+ context.tab = context.tabs.notes
+ context.enrichedNotes = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
+ doc.system.notes ?? "", { async: true }
+ )
+ 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 === "Actor") {
+ const actor = await fromUuid(data.uuid)
+ if (!actor || actor.type !== "character") return
+ const refs = foundry.utils.deepClone(this.document.system.memberRefs ?? [])
+ if (refs.includes(actor.id)) return
+ refs.push(actor.id)
+ return this.document.update({ "system.memberRefs": refs })
+ }
+
+ if (data.type === "Item") {
+ const item = await fromUuid(data.uuid)
+ if (!item || !ALLOWED_LOOT_TYPES.has(item.type)) return
+ return this._onDropItem(item)
+ }
+ }
+
+ // ── Actions ─────────────────────────────────────────────────────────────────
+
+ static async #onOpenMember(event, target) {
+ const actor = game.actors?.get(target.dataset.actorId)
+ if (actor) actor.sheet.render(true)
+ }
+
+ static async #onRemoveMember(event, target) {
+ const id = target.dataset.actorId
+ const refs = (this.document.system.memberRefs ?? []).filter(r => r !== id)
+ await this.document.update({ "system.memberRefs": refs })
+ }
+
+ static async #onMoveMemberUp(event, target) {
+ const idx = parseInt(target.dataset.idx, 10)
+ if (idx <= 0) return
+ const refs = foundry.utils.deepClone(this.document.system.memberRefs ?? []);
+ [refs[idx - 1], refs[idx]] = [refs[idx], refs[idx - 1]]
+ await this.document.update({ "system.memberRefs": refs })
+ }
+
+ static async #onMoveMemberDown(event, target) {
+ const idx = parseInt(target.dataset.idx, 10)
+ const refs = foundry.utils.deepClone(this.document.system.memberRefs ?? [])
+ if (idx >= refs.length - 1) return;
+ [refs[idx], refs[idx + 1]] = [refs[idx + 1], refs[idx]]
+ await this.document.update({ "system.memberRefs": refs })
+ }
+
+ static async #onAdjustCurrency(event, target) {
+ const field = target.dataset.field
+ const delta = parseInt(target.dataset.delta, 10)
+ const cur = foundry.utils.getProperty(this.document, field) ?? 0
+ await this.document.update({ [field]: Math.max(0, cur + delta) })
+ }
+
+ static async #onAdjustQty(event, target) {
+ const item = this.document.items.get(target.dataset.itemId)
+ const delta = parseInt(target.dataset.delta, 10)
+ if (!item) return
+ const qty = (item.system.quantity ?? 1) + delta
+ if (qty <= 0) return item.delete()
+ await item.update({ "system.quantity": qty })
+ }
+}
diff --git a/module/applications/sheets/regiment-sheet.mjs b/module/applications/sheets/regiment-sheet.mjs
index 3dbb63d..d0a0d0b 100644
--- a/module/applications/sheets/regiment-sheet.mjs
+++ b/module/applications/sheets/regiment-sheet.mjs
@@ -1,80 +1,266 @@
-import OathHammerItemSheet from "./base-item-sheet.mjs"
+import OathHammerActorSheet from "./base-actor-sheet.mjs"
+import { rollNPCSkill, rollNPCArmor, rollNPCAttackDamage } from "../../rolls.mjs"
import { SYSTEM } from "../../config/system.mjs"
-export default class OathHammerRegimentSheet extends OathHammerItemSheet {
+export default class OathHammerRegimentSheet extends OathHammerActorSheet {
/** @override */
static DEFAULT_OPTIONS = {
classes: ["regiment"],
- position: { width: 560, height: "auto" },
+ position: { width: 680, height: 620 },
window: { contentClasses: ["regiment-content"] },
actions: {
- addSkill: OathHammerRegimentSheet.#onAddSkill,
- removeSkill: OathHammerRegimentSheet.#onRemoveSkill,
- addAttack: OathHammerRegimentSheet.#onAddAttack,
- removeAttack:OathHammerRegimentSheet.#onRemoveAttack,
- addTrait: OathHammerRegimentSheet.#onAddTrait,
- removeTrait: OathHammerRegimentSheet.#onRemoveTrait,
+ adjustGrit: OathHammerRegimentSheet.#onAdjustGrit,
+ rollArmor: OathHammerRegimentSheet.#onRollArmor,
+ rollSkillNPC: OathHammerRegimentSheet.#onRollSkillNPC,
+ createNpcAttack: OathHammerRegimentSheet.#onCreateNpcAttack,
+ rollNpcAttack: OathHammerRegimentSheet.#onRollNpcAttack,
+ createSkill: OathHammerRegimentSheet.#onCreateSkill,
+ createTrait: OathHammerRegimentSheet.#onCreateTrait,
+ openLeader: OathHammerRegimentSheet.#onOpenLeader,
+ clearLeader: OathHammerRegimentSheet.#onClearLeader,
},
}
/** @override */
static PARTS = {
- main: { template: "systems/fvtt-oath-hammer/templates/item/regiment-sheet.hbs" },
+ main: { template: "systems/fvtt-oath-hammer/templates/actor/regiment-sheet.hbs" },
+ tabs: { template: "templates/generic/tab-navigation.hbs" },
+ skills: { template: "systems/fvtt-oath-hammer/templates/actor/npc-skills.hbs" },
+ combat: { template: "systems/fvtt-oath-hammer/templates/actor/regiment-combat.hbs" },
+ traits: { template: "systems/fvtt-oath-hammer/templates/actor/npc-traits.hbs" },
+ notes: { template: "systems/fvtt-oath-hammer/templates/actor/npc-notes.hbs" },
+ }
+
+ tabGroups = { sheet: "skills" }
+
+ #getTabs() {
+ const tabs = {
+ skills: { id: "skills", group: "sheet", icon: "fa-solid fa-dice-d6", label: "OATHHAMMER.Tab.Skills" },
+ combat: { id: "combat", group: "sheet", icon: "fa-solid fa-swords", label: "OATHHAMMER.Tab.Combat" },
+ traits: { id: "traits", group: "sheet", icon: "fa-solid fa-star", label: "OATHHAMMER.Tab.Traits" },
+ notes: { id: "notes", group: "sheet", icon: "fa-solid fa-book", label: "OATHHAMMER.Tab.Notes" },
+ }
+ 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()
+ const armorColor = this.document.system.armorDice?.colorDiceType ?? "white"
+ context.armorDiceEmoji = armorColor === "black" ? "⬛" : armorColor === "red" ? "🔴" : "⬜"
context.colorChoices = Object.fromEntries(
Object.entries(SYSTEM.DICE_COLOR_TYPES).map(([k, v]) => [k, game.i18n.localize(v)])
)
- context.dicePoolChoices = Object.fromEntries(
- Array.from({ length: 21 }, (_, i) => [i, String(i)])
- )
- context.apChoices = Object.fromEntries(
- Array.from({ length: 7 }, (_, i) => [i, String(i)])
- )
+ // Resolve leader actor
+ const leaderUuid = this.document.system.leaderUuid
+ if (leaderUuid) {
+ const leader = await fromUuid(leaderUuid)
+ context.leader = leader ? { id: leader.id, uuid: leader.uuid, name: leader.name, img: leader.img } : null
+ } else {
+ context.leader = null
+ }
return context
}
- // ── Array helpers ────────────────────────────────────────────────────────────
-
- static async #onAddSkill() {
- const skills = foundry.utils.deepClone(this.document.system.skills ?? [])
- skills.push({ name: "", value: 2, colorDiceType: "white" })
- await this.document.update({ "system.skills": skills })
+ /** @override */
+ async _preparePartContext(partId, context) {
+ const doc = this.document
+ switch (partId) {
+ case "main":
+ break
+ case "skills":
+ context.tab = context.tabs.skills
+ context.skills = doc.itemTypes.skillnpc ?? []
+ break
+ case "combat":
+ context.tab = context.tabs.combat
+ context.npcAttacks = (doc.itemTypes.npcattack ?? []).map(a => ({
+ id: a.id, uuid: a.uuid, img: a.img, name: a.name, system: a.system,
+ _descTooltip: a.system.description?.replace(/<[^>]+>/g, "").slice(0, 300) ?? ""
+ }))
+ break
+ case "traits":
+ context.tab = context.tabs.traits
+ context.traits = (doc.itemTypes.trait ?? []).map(t => ({
+ id: t.id, uuid: t.uuid, img: t.img, name: t.name, system: t.system,
+ _descTooltip: t.system.description?.replace(/<[^>]+>/g, "").slice(0, 300) ?? ""
+ }))
+ break
+ case "notes":
+ context.tab = context.tabs.notes
+ context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
+ doc.system.description ?? "", { async: true }
+ )
+ context.enrichedNotes = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
+ doc.system.notes ?? "", { async: true }
+ )
+ break
+ }
+ return context
}
- static async #onRemoveSkill(event, target) {
- const idx = parseInt(target.dataset.idx, 10)
- const skills = foundry.utils.deepClone(this.document.system.skills ?? [])
- skills.splice(idx, 1)
- await this.document.update({ "system.skills": skills })
+ /** @override */
+ async _onDrop(event) {
+ if (!this.isEditable || !this.isEditMode) return
+ const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event)
+
+ // Actor drop → set as unit leader (must be token-linked)
+ if (data.type === "Actor") {
+ const actor = await fromUuid(data.uuid)
+ if (!actor) return
+ if (!actor.prototypeToken?.actorLink) {
+ ui.notifications.warn(game.i18n.localize("OATHHAMMER.Warning.LeaderNotLinked"))
+ return
+ }
+ return this.document.update({ "system.leaderUuid": actor.uuid })
+ }
+
+ if (data.type !== "Item") return
+ const item = await fromUuid(data.uuid)
+ if (!item) return
+ const ALLOWED = new Set(["skillnpc", "npcattack", "trait"])
+ if (!ALLOWED.has(item.type)) return
+ return this._onDropItem(item)
}
- static async #onAddAttack() {
- const attacks = foundry.utils.deepClone(this.document.system.attacks ?? [])
- attacks.push({ name: "", damageDice: 6, colorDiceType: "white", ap: 0, special: "" })
- await this.document.update({ "system.attacks": attacks })
+ // ── Actions ────────────────────────────────────────────────────────────────
+
+ static async #onAdjustGrit(event, target) {
+ const delta = parseInt(target.dataset.delta, 10)
+ const current = this.document.system.grit?.value ?? 0
+ const max = this.document.system.grit?.max ?? current
+ await this.document.update({ "system.grit.value": Math.max(0, Math.min(max, current + delta)) })
}
- static async #onRemoveAttack(event, target) {
- const idx = parseInt(target.dataset.idx, 10)
- const attacks = foundry.utils.deepClone(this.document.system.attacks ?? [])
- attacks.splice(idx, 1)
- await this.document.update({ "system.attacks": attacks })
+ static async #onRollArmor() {
+ const doc = this.document
+ const armorDice = doc.system.armorDice
+ if (!armorDice?.value) return ui.notifications.info("No armor dice to roll.")
+ const bonusOptions = Array.from({ length: 13 }, (_, i) => {
+ const v = i - 6
+ return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
+ })
+ const content = await foundry.applications.handlebars.renderTemplate(
+ "systems/fvtt-oath-hammer/templates/npc-skill-dialog.hbs",
+ {
+ skillName: game.i18n.localize("OATHHAMMER.Label.ArmorDice"),
+ skillImg: doc.img, basePool: armorDice.value, bonusOptions,
+ colorChoices: { white: "⬜ White (4+)", red: "🔴 Red (3+)", black: "⬛ Black (2+)" },
+ selectedColor: armorDice.colorDiceType,
+ rollModes: foundry.utils.duplicate(CONFIG.Dice.rollModes),
+ visibility: game.settings.get("core", "rollMode")
+ }
+ )
+ const result = await foundry.applications.api.DialogV2.prompt({
+ window: { title: `${doc.name} — ${game.i18n.localize("OATHHAMMER.Roll.ArmorRoll")}`, resizable: true },
+ classes: ["fvtt-oath-hammer"], position: { width: 420 }, content,
+ ok: { label: game.i18n.localize("OATHHAMMER.Dialog.Roll"), icon: "fa-solid fa-dice-d6" },
+ })
+ if (!result) return
+ const form = new DOMParser().parseFromString(result, "text/html")
+ const getValue = n => form.querySelector(`[name="${n}"]`)?.value
+ await rollNPCArmor(doc, {
+ bonus: parseInt(getValue("bonus")) || 0,
+ colorOverride: getValue("colorOverride") || null,
+ visibility: getValue("visibility"),
+ })
}
- static async #onAddTrait() {
- const traits = foundry.utils.deepClone(this.document.system.traits ?? [])
- traits.push({ name: "", description: "" })
- await this.document.update({ "system.traits": traits })
+ static async #onRollSkillNPC(event, target) {
+ const skill = this.document.items.get(target.dataset.itemId)
+ if (!skill) return
+ const bonusOptions = Array.from({ length: 13 }, (_, i) => {
+ const v = i - 6
+ return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
+ })
+ const content = await foundry.applications.handlebars.renderTemplate(
+ "systems/fvtt-oath-hammer/templates/npc-skill-dialog.hbs",
+ {
+ skillName: skill.name, skillImg: skill.img, basePool: skill.system.dicePool, bonusOptions,
+ colorChoices: { white: "⬜ White (4+)", red: "🔴 Red (3+)", black: "⬛ Black (2+)" },
+ selectedColor: skill.system.colorDiceType,
+ rollModes: foundry.utils.duplicate(CONFIG.Dice.rollModes),
+ visibility: game.settings.get("core", "rollMode")
+ }
+ )
+ const result = await foundry.applications.api.DialogV2.prompt({
+ window: { title: `${skill.name} — ${game.i18n.localize("OATHHAMMER.Tab.Skills")}`, resizable: true },
+ classes: ["fvtt-oath-hammer"], position: { width: 420 }, content,
+ ok: { label: game.i18n.localize("OATHHAMMER.Dialog.Roll"), icon: "fa-solid fa-dice-d6" },
+ })
+ if (!result) return
+ const form = new DOMParser().parseFromString(result, "text/html")
+ const getValue = n => form.querySelector(`[name="${n}"]`)?.value
+ await rollNPCSkill(this.document, skill, {
+ bonus: parseInt(getValue("bonus")) || 0,
+ colorOverride: getValue("colorOverride") || null,
+ visibility: getValue("visibility"),
+ })
}
- static async #onRemoveTrait(event, target) {
- const idx = parseInt(target.dataset.idx, 10)
- const traits = foundry.utils.deepClone(this.document.system.traits ?? [])
- traits.splice(idx, 1)
- await this.document.update({ "system.traits": traits })
+ static #onCreateNpcAttack() {
+ this.document.createEmbeddedDocuments("Item", [{
+ name: game.i18n.localize("OATHHAMMER.NewItem.NpcAttack"), type: "npcattack"
+ }])
+ }
+
+ static async #onRollNpcAttack(event, target) {
+ const attack = this.document.items.get(target.dataset.itemId)
+ if (!attack) return
+ const bonusOptions = Array.from({ length: 13 }, (_, i) => {
+ const v = i - 6
+ return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 }
+ })
+ const content = await foundry.applications.handlebars.renderTemplate(
+ "systems/fvtt-oath-hammer/templates/npc-skill-dialog.hbs",
+ {
+ skillName: attack.name, skillImg: attack.img, basePool: attack.system.damageDice, bonusOptions,
+ colorChoices: { white: "⬜ White (4+)", red: "🔴 Red (3+)", black: "⬛ Black (2+)" },
+ selectedColor: attack.system.colorDiceType,
+ rollModes: foundry.utils.duplicate(CONFIG.Dice.rollModes),
+ visibility: game.settings.get("core", "rollMode")
+ }
+ )
+ const result = await foundry.applications.api.DialogV2.prompt({
+ window: { title: `${attack.name} — ${game.i18n.localize("OATHHAMMER.Dialog.Damage")}`, resizable: true },
+ classes: ["fvtt-oath-hammer"], position: { width: 420 }, content,
+ ok: { label: game.i18n.localize("OATHHAMMER.Dialog.Roll"), icon: "fa-solid fa-dice-d6" },
+ })
+ if (!result) return
+ const form = new DOMParser().parseFromString(result, "text/html")
+ const getValue = n => form.querySelector(`[name="${n}"]`)?.value
+ await rollNPCAttackDamage(this.document, attack, {
+ bonus: parseInt(getValue("bonus")) || 0,
+ colorOverride: getValue("colorOverride") || null,
+ visibility: getValue("visibility"),
+ })
+ }
+
+ static #onCreateSkill() {
+ this.document.createEmbeddedDocuments("Item", [{
+ name: game.i18n.localize("OATHHAMMER.NewItem.SkillNPC"), type: "skillnpc"
+ }])
+ }
+
+ static #onCreateTrait() {
+ this.document.createEmbeddedDocuments("Item", [{
+ name: game.i18n.localize("OATHHAMMER.NewItem.Trait"), type: "trait"
+ }])
+ }
+
+ static async #onOpenLeader() {
+ const leaderUuid = this.document.system.leaderUuid
+ if (!leaderUuid) return
+ const leader = await fromUuid(leaderUuid)
+ if (leader) leader.sheet.render(true)
+ }
+
+ static async #onClearLeader() {
+ await this.document.update({ "system.leaderUuid": null })
}
}
diff --git a/module/applications/sheets/settlement-sheet.mjs b/module/applications/sheets/settlement-sheet.mjs
index 9763a35..72a6985 100644
--- a/module/applications/sheets/settlement-sheet.mjs
+++ b/module/applications/sheets/settlement-sheet.mjs
@@ -1,6 +1,6 @@
import OathHammerActorSheet from "./base-actor-sheet.mjs"
-const ALLOWED_ITEM_TYPES = new Set(["building", "equipment", "weapon", "armor", "regiment"])
+const ALLOWED_ITEM_TYPES = new Set(["building", "equipment", "weapon", "armor"])
export default class OathHammerSettlementSheet extends OathHammerActorSheet {
/** @override */
@@ -17,8 +17,9 @@ export default class OathHammerSettlementSheet extends OathHammerActorSheet {
adjustCurrency: OathHammerSettlementSheet.#onAdjustCurrency,
adjustQty: OathHammerSettlementSheet.#onAdjustQty,
toggleConstructed: OathHammerSettlementSheet.#onToggleConstructed,
- createRegiment: OathHammerSettlementSheet.#onCreateRegiment,
collectTaxes: OathHammerSettlementSheet.#onCollectTaxes,
+ openRegiment: OathHammerSettlementSheet.#onOpenRegiment,
+ removeRegiment: OathHammerSettlementSheet.#onRemoveRegiment,
},
}
@@ -102,7 +103,9 @@ export default class OathHammerSettlementSheet extends OathHammerActorSheet {
}
case "garrison":
context.tab = context.tabs.garrison
- context.regiments = doc.itemTypes.regiment ?? []
+ context.regiments = (doc.system.garrisonRefs ?? [])
+ .map(id => game.actors?.get(id))
+ .filter(Boolean)
break
}
return context
@@ -112,6 +115,17 @@ export default class OathHammerSettlementSheet extends OathHammerActorSheet {
async _onDrop(event) {
if (!this.isEditable || !this.isEditMode) return
const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event)
+
+ // Regiment actors dropped onto garrison tab
+ if (data.type === "Actor") {
+ const actor = await fromUuid(data.uuid)
+ if (!actor || actor.type !== "regiment") return
+ const refs = foundry.utils.deepClone(this.document.system.garrisonRefs ?? [])
+ if (refs.includes(actor.id)) return // already linked
+ refs.push(actor.id)
+ return this.document.update({ "system.garrisonRefs": refs })
+ }
+
if (data.type !== "Item") return
const item = await fromUuid(data.uuid)
if (!item || !ALLOWED_ITEM_TYPES.has(item.type)) return
@@ -144,11 +158,15 @@ export default class OathHammerSettlementSheet extends OathHammerActorSheet {
await item.update({ "system.constructed": !item.system.constructed })
}
- static async #onCreateRegiment() {
- await this.document.createEmbeddedDocuments("Item", [{
- name: game.i18n.localize("OATHHAMMER.NewItem.Regiment"),
- type: "regiment",
- }])
+ static async #onOpenRegiment(event, target) {
+ const actor = game.actors?.get(target.dataset.actorId)
+ if (actor) actor.sheet.render(true)
+ }
+
+ static async #onRemoveRegiment(event, target) {
+ const actorId = target.dataset.actorId
+ const refs = (this.document.system.garrisonRefs ?? []).filter(id => id !== actorId)
+ await this.document.update({ "system.garrisonRefs": refs })
}
static async #onCollectTaxes() {
diff --git a/module/models/_module.mjs b/module/models/_module.mjs
index 734ac17..a2a3b33 100644
--- a/module/models/_module.mjs
+++ b/module/models/_module.mjs
@@ -15,3 +15,5 @@ export { default as OathHammerSettlement } from "./settlement.mjs"
export { default as OathHammerSkillNPC } from "./skillnpc.mjs"
export { default as OathHammerNpcAttack } from "./npcattack.mjs"
export { default as OathHammerRegiment } from "./regiment.mjs"
+export { default as OathHammerParty } from "./party.mjs"
+export { default as OathHammerArmy } from "./army.mjs"
diff --git a/module/models/army.mjs b/module/models/army.mjs
new file mode 100644
index 0000000..bdbc40f
--- /dev/null
+++ b/module/models/army.mjs
@@ -0,0 +1,13 @@
+export default class OathHammerArmy extends foundry.abstract.TypeDataModel {
+ static defineSchema() {
+ const { fields } = foundry.data
+ const schema = {}
+
+ schema.regimentRefs = new fields.ArrayField(new fields.StringField({ required: true, blank: false }))
+ schema.leaderUuid = new fields.StringField({ required: false, nullable: true, initial: null })
+ schema.location = new fields.StringField({ required: false, nullable: true, initial: "" })
+ schema.notes = new fields.HTMLField({ required: false, nullable: true, initial: "" })
+
+ return schema
+ }
+}
diff --git a/module/models/party.mjs b/module/models/party.mjs
new file mode 100644
index 0000000..0a686fd
--- /dev/null
+++ b/module/models/party.mjs
@@ -0,0 +1,24 @@
+export default class OathHammerParty extends foundry.abstract.TypeDataModel {
+ static defineSchema() {
+ const { fields } = foundry.data
+ const requiredInteger = { required: true, nullable: false, integer: true }
+ const schema = {}
+
+ schema.notes = new fields.HTMLField({ required: false, nullable: true, initial: "" })
+
+ // Ordered list of character actor IDs — position = marching order
+ schema.memberRefs = new fields.ArrayField(
+ new fields.StringField({ required: true, nullable: false, blank: false })
+ )
+
+ schema.treasury = new fields.SchemaField({
+ gp: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }),
+ sp: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }),
+ cp: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 }),
+ })
+
+ return schema
+ }
+
+ static LOCALIZATION_PREFIXES = ["OATHHAMMER.Party"]
+}
diff --git a/module/models/regiment.mjs b/module/models/regiment.mjs
index 0ea998e..af707f5 100644
--- a/module/models/regiment.mjs
+++ b/module/models/regiment.mjs
@@ -7,46 +7,33 @@ export default class OathHammerRegiment extends foundry.abstract.TypeDataModel {
const schema = {}
schema.description = new fields.HTMLField({ required: false, nullable: true, initial: "" })
- schema.notes = new fields.StringField({ required: false, nullable: true, initial: "" })
+ schema.notes = new fields.HTMLField({ required: false, nullable: true, initial: "" })
schema.grit = new fields.SchemaField({
- max: new fields.NumberField({ ...requiredInteger, initial: 20, min: 0, max: 200 }),
+ value: new fields.NumberField({ ...requiredInteger, initial: 20, min: 0 }),
+ max: new fields.NumberField({ ...requiredInteger, initial: 20, min: 0 }),
})
schema.armorDice = new fields.SchemaField({
- value: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0, max: 20 }),
+ value: new fields.NumberField({ ...requiredInteger, initial: 1, min: 0, max: 20 }),
colorDiceType: new fields.StringField({ required: true, nullable: false, initial: "white", choices: SYSTEM.DICE_COLOR_TYPES }),
})
- schema.movement = new fields.NumberField({ ...requiredInteger, initial: 60, min: 0, max: 500 })
+ schema.movement = new fields.NumberField({ ...requiredInteger, initial: 60, min: 0, max: 500 })
+ schema.supplyCost = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 })
+ schema.recruitmentCost = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 })
- // Embedded skill rows: [{name, value, colorDiceType}]
- schema.skills = new fields.ArrayField(new fields.SchemaField({
- name: new fields.StringField({ required: true, nullable: false, initial: "" }),
- value: new fields.NumberField({ ...requiredInteger, initial: 2, min: 0, max: 6 }),
- colorDiceType: new fields.StringField({ required: true, nullable: false, initial: "white", choices: SYSTEM.DICE_COLOR_TYPES }),
- }))
-
- // Embedded attack rows: [{name, damageDice, colorDiceType, ap, special}]
- schema.attacks = new fields.ArrayField(new fields.SchemaField({
- name: new fields.StringField({ required: true, nullable: false, initial: "" }),
- damageDice: new fields.NumberField({ ...requiredInteger, initial: 6, min: 0, max: 20 }),
- colorDiceType: new fields.StringField({ required: true, nullable: false, initial: "white", choices: SYSTEM.DICE_COLOR_TYPES }),
- ap: new fields.NumberField({ ...requiredInteger, initial: 0, min: 0, max: 6 }),
- special: new fields.StringField({ required: false, nullable: true, initial: "" }),
- }))
-
- // Embedded trait rows: [{name, description}]
- schema.traits = new fields.ArrayField(new fields.SchemaField({
- name: new fields.StringField({ required: true, nullable: false, initial: "" }),
- description: new fields.StringField({ required: false, nullable: true, initial: "" }),
- }))
+ schema.leaderUuid = new fields.StringField({ required: false, nullable: true, initial: null })
return schema
}
static LOCALIZATION_PREFIXES = ["OATHHAMMER.Regiment"]
+ get threshold() {
+ return { white: 4, red: 3, black: 2 }[this.armorDice.colorDiceType] ?? 4
+ }
+
get colorEmoji() {
return { white: "⬜", red: "🔴", black: "⬛" }[this.armorDice.colorDiceType] ?? "⬜"
}
diff --git a/module/models/settlement.mjs b/module/models/settlement.mjs
index d7c65dc..0792602 100644
--- a/module/models/settlement.mjs
+++ b/module/models/settlement.mjs
@@ -33,6 +33,11 @@ export default class OathHammerSettlement extends foundry.abstract.TypeDataModel
schema.isCapital = new fields.BooleanField({ required: true, initial: false })
schema.taxNotes = new fields.StringField({ required: true, nullable: false, initial: "" })
+ // Linked regiment actor IDs
+ schema.garrisonRefs = new fields.ArrayField(
+ new fields.StringField({ required: true, nullable: false, blank: false })
+ )
+
return schema
}
diff --git a/oath-hammer.mjs b/oath-hammer.mjs
index 56adf03..6a3e948 100644
--- a/oath-hammer.mjs
+++ b/oath-hammer.mjs
@@ -23,8 +23,11 @@ Hooks.once("init", function () {
CONFIG.Combat.documentClass = OathHammerCombat
CONFIG.Actor.dataModels = {
character: models.OathHammerCharacter,
- npc: models.OathHammerNPC,
- settlement: models.OathHammerSettlement
+ npc: models.OathHammerNPC,
+ settlement: models.OathHammerSettlement,
+ regiment: models.OathHammerRegiment,
+ party: models.OathHammerParty,
+ army: models.OathHammerArmy,
}
CONFIG.Item.documentClass = documents.OathHammerItem
@@ -42,7 +45,6 @@ Hooks.once("init", function () {
building: models.OathHammerBuilding,
skillnpc: models.OathHammerSkillNPC,
npcattack: models.OathHammerNpcAttack,
- regiment: models.OathHammerRegiment,
}
foundry.documents.collections.Actors.unregisterSheet("core", foundry.appv1.sheets.ActorSheet)
@@ -61,6 +63,21 @@ Hooks.once("init", function () {
makeDefault: true,
label: "OATHHAMMER.Sheet.Settlement"
})
+ foundry.documents.collections.Actors.registerSheet("fvtt-oath-hammer", applications.OathHammerRegimentSheet, {
+ types: ["regiment"],
+ makeDefault: true,
+ label: "OATHHAMMER.Sheet.Regiment"
+ })
+ foundry.documents.collections.Actors.registerSheet("fvtt-oath-hammer", applications.OathHammerPartySheet, {
+ types: ["party"],
+ makeDefault: true,
+ label: "OATHHAMMER.Sheet.Party"
+ })
+ foundry.documents.collections.Actors.registerSheet("fvtt-oath-hammer", applications.OathHammerArmySheet, {
+ types: ["army"],
+ makeDefault: true,
+ label: "OATHHAMMER.Sheet.Army"
+ })
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" })
@@ -76,7 +93,6 @@ Hooks.once("init", function () {
foundry.documents.collections.Items.registerSheet("fvtt-oath-hammer", applications.OathHammerBuildingSheet, { types: ["building"], makeDefault: true, label: "OATHHAMMER.Sheet.Building" })
foundry.documents.collections.Items.registerSheet("fvtt-oath-hammer", applications.OathHammerSkillNPCSheet, { types: ["skillnpc"], makeDefault: true, label: "OATHHAMMER.Sheet.SkillNPC" })
foundry.documents.collections.Items.registerSheet("fvtt-oath-hammer", applications.OathHammerNpcAttackSheet, { types: ["npcattack"], makeDefault: true, label: "OATHHAMMER.Sheet.NpcAttack" })
- foundry.documents.collections.Items.registerSheet("fvtt-oath-hammer", applications.OathHammerRegimentSheet, { types: ["regiment"], makeDefault: true, label: "OATHHAMMER.Sheet.Regiment" })
CONFIG.statusEffects = STATUS_EFFECTS
diff --git a/system.json b/system.json
index aec6905..69d64b2 100644
--- a/system.json
+++ b/system.json
@@ -42,6 +42,22 @@
"description",
"notes"
]
+ },
+ "regiment": {
+ "htmlFields": [
+ "description",
+ "notes"
+ ]
+ },
+ "party": {
+ "htmlFields": [
+ "notes"
+ ]
+ },
+ "army": {
+ "htmlFields": [
+ "notes"
+ ]
}
},
"Item": {
@@ -107,8 +123,7 @@
]
},
"skillnpc": {},
- "npcattack": {},
- "regiment": {}
+ "npcattack": {}
}
},
"grid": {
diff --git a/templates/actor/army-notes.hbs b/templates/actor/army-notes.hbs
new file mode 100644
index 0000000..e6d0938
--- /dev/null
+++ b/templates/actor/army-notes.hbs
@@ -0,0 +1,6 @@
+ {{localize "OATHHAMMER.Label.DropRegimentHint"}}
{{spell.name}}
{{spell.system.difficultyValue}}
- {{localize spell.system.tradition}}
+ {{spell.traditionLabel}}
{{#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}}
@@ -74,7 +74,7 @@
{{localize "OATHHAMMER.Label.DropLootHint"}}
+ +{{localize "OATHHAMMER.Label.DropMemberHint"}}
+ +{{localize "OATHHAMMER.Settlement.GarrisonHint"}}
+ diff --git a/templates/item/regiment-sheet.hbs b/templates/item/regiment-sheet.hbs deleted file mode 100644 index c513472..0000000 --- a/templates/item/regiment-sheet.hbs +++ /dev/null @@ -1,122 +0,0 @@ -