Add trained toggle to attack skills (Melee, Brawl, Ranged, Finesse)

Attack skills now have trained/untrained status like regular skills:
- Untrained: difficulty = 20 - stat
- Trained: difficulty = 20 - (stat × 2)

Includes UI toggle button, data model fields, and matching styling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-12-18 10:33:35 -06:00
parent fe91764f3b
commit 0eb258d2c1
6 changed files with 142 additions and 35 deletions

View File

@ -61,14 +61,23 @@ Each skill has `trained`, `difficulty`, and `critThreshold` properties.
- `system.skills.melee.critThreshold` - Modify melee crit threshold
- `system.skills.sneak.critThreshold` - Modify sneak crit threshold
### Attack Crit Thresholds
### Attack Skills
| Key | Type | Range | Description |
| -------------------------------------- | ------ | ----- | ----------------------------- |
| `system.attacks.melee.critThreshold` | Number | 1-20 | Melee attack crit threshold |
| `system.attacks.brawl.critThreshold` | Number | 1-20 | Brawl attack crit threshold |
| `system.attacks.ranged.critThreshold` | Number | 1-20 | Ranged attack crit threshold |
| `system.attacks.finesse.critThreshold` | Number | 1-20 | Finesse attack crit threshold |
Each attack skill has `trained`, `difficulty`, and `critThreshold` properties.
**Attack Skills:** `melee`, `brawl`, `ranged`, `finesse`
| Key Pattern | Type | Range | Description |
| --------------------------------------- | ------- | ---------- | --------------------------- |
| `system.attacks.<attack>.trained` | Boolean | true/false | Is attack skill trained? |
| `system.attacks.<attack>.difficulty` | Number | 1-20 | Roll target (derived) |
| `system.attacks.<attack>.critThreshold` | Number | 1-20 | Crit on this roll or higher |
**Examples:**
- `system.attacks.melee.trained` - Train Melee attack skill
- `system.attacks.ranged.trained` - Train Ranged attack skill
- `system.attacks.melee.critThreshold` - Modify melee crit threshold
### Saves

View File

@ -184,18 +184,26 @@ export default class CharacterData extends VagabondActorBase {
}),
}),
// Attack skills with crit thresholds
// Attack skills with trained status and crit thresholds
attacks: new fields.SchemaField({
melee: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
brawl: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
ranged: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
finesse: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
}),
@ -652,8 +660,8 @@ export default class CharacterData extends VagabondActorBase {
// Calculate Skill Difficulties (also clamps skill crit thresholds)
this._calculateSkillDifficulties();
// Clamp attack crit thresholds after Active Effects
this._clampAttackCritThresholds();
// Calculate Attack Difficulties (also clamps attack crit thresholds)
this._calculateAttackDifficulties();
}
/**
@ -683,13 +691,28 @@ export default class CharacterData extends VagabondActorBase {
}
/**
* Clamp attack crit thresholds after Active Effects are applied.
* Schema constraints don't apply to effect-modified data.
* Calculate difficulty values for all attack skills.
* Untrained: 20 - stat
* Trained: 20 - (stat × 2)
* Also clamps crit thresholds after Active Effects.
*
* @private
*/
_clampAttackCritThresholds() {
for (const attackData of Object.values(this.attacks)) {
_calculateAttackDifficulties() {
const attackStats = CONFIG.VAGABOND?.attackTypes || {};
for (const [attackId, attackData] of Object.entries(this.attacks)) {
const attackConfig = attackStats[attackId];
if (!attackConfig) continue;
const statKey = attackConfig.stat;
const statValue = this.stats[statKey]?.value || 0;
const trained = attackData.trained;
// Calculate difficulty: 20 - stat (untrained) or 20 - stat×2 (trained)
attackData.difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
// Clamp crit threshold after Active Effects (schema min doesn't apply to effect-modified data)
attackData.critThreshold = Math.max(1, Math.min(20, attackData.critThreshold));
}
}

View File

@ -66,6 +66,7 @@ export default class VagabondActorSheet extends HandlebarsApplicationMixin(Actor
changeTab: VagabondActorSheet.#onChangeTab,
modifyResource: VagabondActorSheet.#onModifyResource,
toggleTrained: VagabondActorSheet.#onToggleTrained,
toggleAttackTrained: VagabondActorSheet.#onToggleAttackTrained,
removeStatus: VagabondActorSheet.#onRemoveStatus,
},
// Drag-drop configuration - use Foundry's built-in system
@ -827,6 +828,21 @@ export default class VagabondActorSheet extends HandlebarsApplicationMixin(Actor
await this.actor.update({ [`system.skills.${skillId}.trained`]: !currentValue });
}
/**
* Handle attack skill trained toggle.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onToggleAttackTrained(event, target) {
event.preventDefault();
event.stopPropagation(); // Prevent triggering the row's rollAttack action
const attackId = target.dataset.attack;
if (!attackId) return;
const currentValue = this.actor.system.attacks[attackId]?.trained ?? false;
await this.actor.update({ [`system.attacks.${attackId}.trained`]: !currentValue });
}
/**
* Handle status removal action.
* @param {PointerEvent} event

View File

@ -219,29 +219,23 @@ export default class VagabondCharacterSheet extends VagabondActorSheet {
*/
_prepareAttackSkills() {
const system = this.actor.system;
const attackConfig = {
melee: { label: "VAGABOND.AttackMelee", stat: "might" },
brawl: { label: "VAGABOND.AttackBrawl", stat: "might" },
ranged: { label: "VAGABOND.AttackRanged", stat: "awareness" },
finesse: { label: "VAGABOND.AttackFinesse", stat: "dexterity" },
};
const attackConfig = CONFIG.VAGABOND?.attackTypes || {};
const attacks = {};
for (const [key, config] of Object.entries(attackConfig)) {
const statValue = system.stats[config.stat]?.value || 0;
// Attack difficulty is 20 - stat (always trained)
const difficulty = 20 - statValue * 2;
const attackData = system.attacks[key];
if (!attackData) continue;
attacks[key] = {
id: key,
label: config.label,
stat: config.stat,
statAbbr: this._getStatAbbr(config.stat),
difficulty,
critThreshold: system.attacks[key]?.critThreshold || 20,
hasCritBonus: (system.attacks[key]?.critThreshold || 20) < 20,
trained: attackData.trained,
difficulty: attackData.difficulty,
critThreshold: attackData.critThreshold || 20,
hasCritBonus: (attackData.critThreshold || 20) < 20,
};
}

View File

@ -733,38 +733,91 @@
display: flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-2 $spacing-3;
padding: $spacing-1 $spacing-2;
background-color: var(--color-bg-input);
border: 1px solid var(--color-border)-light;
border-radius: $radius-md;
cursor: pointer;
transition: all $transition-fast;
&:hover {
background-color: var(--color-bg-secondary);
border-color: var(--color-border);
}
&.trained {
background-color: rgba(45, 90, 39, 0.1);
border-color: rgba(45, 90, 39, 0.3);
.attack-trained-toggle {
color: var(--color-success);
border-color: var(--color-success);
}
}
.attack-trained-toggle {
@include flex-center;
width: 20px;
height: 20px;
padding: 0;
background: transparent;
border: 1px solid var(--color-border);
border-radius: $radius-sm;
color: var(--color-text-muted);
cursor: pointer;
transition: all $transition-fast;
&:hover {
border-color: var(--color-accent-primary);
color: var(--color-accent-primary);
}
i {
font-size: 10px;
}
}
.attack-name {
font-weight: $font-weight-semibold;
font-weight: $font-weight-medium;
color: var(--color-text-primary);
flex: 1;
}
.attack-stat {
font-size: $font-size-xs;
color: var(--color-text-secondary);
margin-right: auto;
}
.attack-difficulty {
font-family: $font-family-header;
font-weight: $font-weight-bold;
color: var(--color-text-primary);
min-width: 24px;
text-align: center;
}
.attack-crit {
font-size: $font-size-xs;
color: var(--color-warning);
i {
margin-right: 2px;
}
}
.attack-roll-btn {
@include flex-center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
transition: color $transition-fast;
&:hover {
color: var(--color-accent-primary);
}
}
}

View File

@ -76,9 +76,16 @@
<h2 class="section-header">{{localize "VAGABOND.Attacks"}}</h2>
<div class="attack-skills-grid">
{{#each attackSkills}}
<div class="attack-skill-row interactive-row" role="button" tabindex="0"
data-action="rollAttack" data-attack-skill="{{this.id}}"
aria-label="{{localize this.label}} {{localize 'VAGABOND.Attack'}}: {{localize 'VAGABOND.Difficulty'}} {{this.difficulty}}">
<div class="attack-skill-row {{#if this.trained}}trained{{/if}}" data-attack-skill="{{this.id}}">
<button type="button" class="attack-trained-toggle"
data-action="toggleAttackTrained" data-attack="{{this.id}}"
data-tooltip="{{localize 'VAGABOND.ToggleTrained'}}">
{{#if this.trained}}
<i class="fa-solid fa-check"></i>
{{else}}
<i class="fa-regular fa-circle"></i>
{{/if}}
</button>
<span class="attack-name">{{localize this.label}}</span>
<span class="attack-stat">({{this.statAbbr}})</span>
<span class="attack-difficulty">{{this.difficulty}}</span>
@ -87,6 +94,11 @@
<i class="fa-solid fa-star" aria-hidden="true"></i>{{this.critThreshold}}
</span>
{{/if}}
<button type="button" class="attack-roll-btn"
data-action="rollAttack" data-attack-skill="{{this.id}}"
data-tooltip="{{localize 'VAGABOND.RollAttack'}}">
<i class="fa-solid fa-dice-d20"></i>
</button>
</div>
{{/each}}
</div>