Files
FrankandClaude Opus 5.5 7a927e21a3 Characters: seed the current party sheets, add skill expertise
The party sheets in assets/characters/ changed: Darrah_Oakwanderer.pdf is
gone, replaced by Darragh Fermwyr (same halfling ranger, renamed and
updated), Owayn Fermwyr (stout halfling fighter, BJ) and Dunnchadh
(ghostwise halfling druid, Circle of the Blighted, Rick).

- app:character:seed now carries those three sheets, read from the PDFs'
  form fields. Darragh's portrait is renamed to match his new name.
- New skill expertise field (double proficiency bonus), since Darragh's
  Deft Explorer gives him +7 Stealth and the sheet had nowhere to show
  it. Expertise implies proficiency; the sheet marks it with two dots,
  the edit form gets a checkbox list next to skill proficiencies.
- Migration adds skill_expertise, backfilling existing rows with [] so
  the NOT NULL JSON column works on tables that already have characters.

Tests: expertise math on the entity, and a catalog test that every seed
sheet hydrates into a Character that passes validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:50:24 +02:00

115 lines
5.5 KiB
Twig

{#
# Read-only character sheet. Table-shaped where the paper sheet itself is
# (ability scores, skills, attacks); the prose helpers (_line, _section, mod)
# are borrowed from the enemy stat block, another 5e sheet.
# {% import 'characters/_sheet.html.twig' as cs %}
# {{ cs.block(character) }}
#}
{% macro block(c) %}
{% import 'enemy/_statblock.html.twig' as sb %}
<dl class="char-meta">
<div><dt>Race</dt><dd>{{ c.race ?: '—' }}</dd></div>
<div><dt>Class &amp; level</dt><dd>{{ c.characterClass ? c.characterClass ~ ' ' ~ c.level : '—' }}</dd></div>
<div><dt>Background</dt><dd>{{ c.background ?: '—' }}</dd></div>
<div><dt>Alignment</dt><dd>{{ c.alignment ?: '—' }}</dd></div>
<div><dt>Player</dt><dd>{{ c.playerName ?: '—' }}</dd></div>
<div><dt>Experience</dt><dd>{{ c.experiencePoints is not null ? c.experiencePoints ~ ' XP' : '—' }}</dd></div>
</dl>
<dl class="char-meta">
<div><dt>Armor Class</dt><dd>{{ c.armorClass ?? '—' }}</dd></div>
<div><dt>Initiative</dt><dd>{{ c.initiative is not null ? sb.mod(c.initiative) : '—' }}</dd></div>
<div><dt>Speed</dt><dd>{{ c.speed ?: '—' }}</dd></div>
<div><dt>Hit Points</dt>
<dd>
{%- if c.currentHitPoints is not null or c.hitPointMaximum is not null -%}
{{ c.currentHitPoints ?? '?' }}{{ c.hitPointMaximum ? ' / ' ~ c.hitPointMaximum }}{{ c.temporaryHitPoints ? ' (+' ~ c.temporaryHitPoints ~ ' temp)' }}
{%- else -%}—{%- endif -%}
</dd>
</div>
<div><dt>Hit Dice</dt><dd>{{ c.hitDice ?: '—' }}</dd></div>
<div><dt>Proficiency Bonus</dt><dd>{{ sb.mod(c.proficiencyBonus) }}</dd></div>
<div><dt>Passive Perception</dt><dd>{{ c.passivePerception }}</dd></div>
<div><dt>Inspiration</dt><dd>{{ c.inspiration ? 'Yes' : '—' }}</dd></div>
</dl>
<div class="row g-3">
<div class="col-md-6">
<div class="table-responsive">
<table class="table table-sm char-ability-table">
<thead><tr><th>Ability</th><th>Score</th><th>Mod</th><th>Save</th></tr></thead>
<tbody>
{% for ability in constant('App\\Entity\\Character::ABILITIES') %}
{% set score = c.abilityScore(ability) %}
<tr class="{{ c.isProficientInSave(ability) ? 'is-proficient' }}">
<td>{{ ability }}</td>
<td>{{ score }}</td>
<td>{{ sb.mod(c.abilityModifier(score)) }}</td>
<td>{{ sb.mod(c.saveModifier(ability)) }}{{ c.isProficientInSave(ability) ? ' ●' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-md-6">
<div class="table-responsive">
<table class="table table-sm char-skill-table">
<thead><tr><th>Skill</th><th>Abl.</th><th>Mod</th></tr></thead>
<tbody>
{% for skill, ability in constant('App\\Entity\\Character::SKILLS') %}
{% set proficient = c.isProficientInSkill(skill) or c.hasExpertiseInSkill(skill) %}
<tr class="{{ proficient ? 'is-proficient' }}">
<td>{{ skill }}{{ c.hasExpertiseInSkill(skill) ? ' ●●' : (proficient ? ' ●') }}</td>
<td>{{ ability|slice(0, 3) }}</td>
<td>{{ sb.mod(c.skillModifier(skill)) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% if c.attacks is not empty %}
<h3 class="h6 border-bottom pb-1 mt-3">Attacks &amp; Spellcasting</h3>
<div class="table-responsive">
<table class="table table-sm char-attack-table">
<thead><tr><th>Name</th><th>Atk Bonus</th><th>Damage/Type</th><th>Range</th></tr></thead>
<tbody>
{% for atk in c.attacks %}
<tr>
<td>{{ atk.name }}</td>
<td>{{ atk.bonus }}</td>
<td>{{ atk.damage }}</td>
<td>{{ atk.range }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{{ sb._section('Spellcasting', c.spellcasting) }}
{{ sb._section('Features & Traits', c.featuresTraits) }}
{{ sb._section('Other Proficiencies & Languages', c.otherProficienciesLanguages) }}
{{ sb._section('Equipment', c.equipment) }}
{% if c.personalityTraits or c.ideals or c.bonds or c.flaws %}
<h3 class="h6 border-bottom pb-1 mt-3">Personality</h3>
<div class="row row-cols-1 row-cols-md-2 g-2">
<div>{{ sb._line('Traits', c.personalityTraits) }}</div>
<div>{{ sb._line('Ideals', c.ideals) }}</div>
<div>{{ sb._line('Bonds', c.bonds) }}</div>
<div>{{ sb._line('Flaws', c.flaws) }}</div>
</div>
{% endif %}
{{ sb._section('Appearance', c.appearance) }}
{{ sb._section('Backstory', c.backstory) }}
{{ sb._section('Allies & Organizations', c.alliesOrganizations) }}
{{ sb._section('Treasure', c.treasure) }}
{% endmacro %}