Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions SOURCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ FBS `main` on 2026-10-07.
| Army sizes, section limits, retinues | armies | `ArmyListBuilder/ArmyBuilder.cs`, `Model/ArmyList/ArmyList.cs` `IsListValid`, `Model/ArmyList/ArmyListSection.cs` |
| Option costs per model / per unit | armies | `Model/ArmyList/ArmyListRegiment.cs` `PointsCost`, `ArmyListEntry.cs` `PropertyCostUnit` |

## Known differences between the game and its own text
## Game text the site corrects

The glossary uses the game's property descriptions. These ones say something the code doesn't do, so
`src/data/overrides.json` replaces them on the site:
`src/data/overrides.json` replaces game descriptions that were out of date. The game was fixed to the
same wording in Perwahl/FBS#115, and Web's range there was changed from 8 to 12 to match its text. Once
that is merged, the next export carries the new text, and these overrides can be removed:

- **Regeneration**: the game text says "recovers all missing wounds at end of turn". The code clears partial
wounds at the start of the Strategic phase, and lost models stay lost.
- **Web**: the game text says range 12. The spell asset has range 8.
- **Frenzy**: the game text doesn't mention that a Frenzied unit can't flee from a charge.
- **Reanimated / Demonic**: same. Neither can flee from a charge.
- **Ambusher**: the zone isn't the whole of your half; it stops about 2" short of the centre line.
- **Regeneration**: "Heals all missing wounds at end of turn."
- **Frenzy**, **Reanimated**, **Demonic**: add "Can't flee from charges." Reanimated and Demonic lose D3
wounds instead of taking a break test.
- **Ambusher**: the zone stops 2" short of the centre line.
- **Halberd**: missing full stop.
16 changes: 11 additions & 5 deletions src/components/UnitCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const cantrips = spells?.options?.filter((o) => property(o.id)?.cantrip) ?? [];
const majors = spells?.options?.filter((o) => !property(o.id)?.cantrip) ?? [];
const sizes = ARMY_SIZES;
const isRegiment = !unit.commander;
// Monsters and war machines fielded as one model: no per-model pricing or unit size to show.
const singleModel = Object.values(unit.models).length > 0 && Object.values(unit.models).every((m) => m.max === 1);
const perModel = isRegiment && !unit.isMount && !singleModel;
const sizeText = (id: string) => {
const m = unit.models[id];
if (!m) return '';
Expand All @@ -39,7 +42,7 @@ const sizeText = (id: string) => {
{type?.name ?? unit.modelType}{type?.movement ? ` · Move ${inches(type.movement)}` : ''}{unit.solo ? ' · Fights alone' : ''}
</p>
</div>
<p class="unit-points"><span>{unit.points}</span> pts{isRegiment && !unit.isMount && ' / model'}</p>
<p class="unit-points"><span>{unit.points}</span> pts{perModel && ' / model'}</p>
</header>

<StatLine stats={unit.stats} compact />
Expand All @@ -50,7 +53,7 @@ const sizeText = (id: string) => {
{s.options?.map((o, i) => (
<>
{i > 0 && <span class="or">or</span>}
<PropertyChip id={o.id} cost={o.cost} kind={property(o.id)?.type} regiment={isRegiment && !unit.isMount} />
<PropertyChip id={o.id} cost={o.cost} kind={property(o.id)?.type} regiment={perModel} />
</>
))}
</li>
Expand All @@ -61,13 +64,16 @@ const sizeText = (id: string) => {
{optionals.length > 0 && (
<li>
<span class="prop-hint">Options</span>
{optionals.flatMap((s) => s.options ?? []).map((o) => <PropertyChip id={o.id} cost={o.cost} kind={property(o.id)?.type} regiment={isRegiment && !unit.isMount} />)}
{optionals.flatMap((s) => s.options ?? []).map((o) => <PropertyChip id={o.id} cost={o.cost} kind={property(o.id)?.type} regiment={perModel} />)}
</li>
)}
</ul>

{spells && (
<p class="unit-line"><span class="unit-line-label">Spells</span> Choose 1 cantrip: {cantrips.map((o, i) => <>{i > 0 && ', '}<PropertyChip id={o.id} kind="spell" /></>)}. And 1 spell: {majors.map((o, i) => <>{i > 0 && ', '}<PropertyChip id={o.id} kind="spell" /></>)}.</p>
<p class="unit-line"><span class="unit-line-label">Spells</span>
{cantrips.length > 0 && <>Choose 1 cantrip: {cantrips.map((o, i) => <>{i > 0 && ', '}<PropertyChip id={o.id} kind="spell" /></>)}. </>}
{majors.length > 0 && <>Choose 1 spell: {majors.map((o, i) => <>{i > 0 && ', '}<PropertyChip id={o.id} kind="spell" /></>)}.</>}
</p>
)}

{extras.length > 0 && (
Expand All @@ -81,7 +87,7 @@ const sizeText = (id: string) => {
)}

<footer class="unit-foot">
{isRegiment && !unit.isMount && (
{perModel && (
<span class="foot-item">Unit size {sizes.map((s) => <span class="by-size" data-s={s.id}>{sizeText(s.id)}</span>)}</span>
)}
{sizes.some((s) => unit.maxPerArmy[s.id] !== undefined) && (
Expand Down
2 changes: 1 addition & 1 deletion src/components/diagrams/ChargeReach.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const L = cx - w / 2;
const R = cx + w / 2;
const reach = 8;
const k = reach * Math.SQRT1_2;
const sight = `M ${L} ${fy} L ${L - k} ${fy - k} A ${reach} ${reach} 0 0 1 ${L} ${fy - reach} L ${R} ${fy - reach} A ${reach} ${reach} 0 0 1 ${R + k} ${fy - k} Z`;
const sight = `M ${L} ${fy} L ${L - k} ${fy - k} A ${reach} ${reach} 0 0 1 ${L} ${fy - reach} L ${R} ${fy - reach} A ${reach} ${reach} 0 0 1 ${R + k} ${fy - k} L ${R} ${fy} Z`;
---
<Diagram width={36} height={23} title="Charge reach: a unit can charge enemies within its Move inside a 90 degree arc from its front edge, unless sight is blocked." caption="Charge reach of an Infantry unit with Move 8&quot;. Sight fans out 45° from each front corner and stops at the first unit or sight-blocking terrain.">
<path class="dg-sight" d={sight} />
Expand Down
2 changes: 1 addition & 1 deletion src/components/diagrams/RangedAttack.astro
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const cx = 20, fy = 25.2;
const L = cx - w / 2, R = cx + w / 2;
const fan = (rr: number) => {
const k = rr * Math.SQRT1_2;
return `M ${L} ${fy} L ${L - k} ${fy - k} A ${rr} ${rr} 0 0 1 ${L} ${fy - rr} L ${R} ${fy - rr} A ${rr} ${rr} 0 0 1 ${R + k} ${fy - k} Z`;
return `M ${L} ${fy} L ${L - k} ${fy - k} A ${rr} ${rr} 0 0 1 ${L} ${fy - rr} L ${R} ${fy - rr} A ${rr} ${rr} 0 0 1 ${R + k} ${fy - k} L ${R} ${fy} Z`;
};
---
<Diagram width={40} height={29.6} title="Ranged attack: targets within half range are hit normally; targets beyond half range suffer -1 to hit; a target in a forest suffers a further -1 for cover." caption="Handguns, range 24&quot;. Beyond 12&quot; is long range (−1). The unit in the forest is in cover too (−1 more). Only the first two ranks shoot.">
Expand Down
13 changes: 6 additions & 7 deletions src/data/overrides.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
{
"Regeneration": "At the start of each Strategic phase, heals any wounds on partly wounded models. Models already removed don't come back.",
"Web": "Reduce an enemy unit's Move by 4. Range 8.",
"Ambusher": "Deploys in the ambush zone: anywhere on your half of the battlefield, except a strip about 2\" deep either side of the centre line.",
"Frenzy": "Re-roll missed attack rolls in the first round of combat. Can't flee from a charge.",
"Reanimated": "-1 Move. Never flees: instead of taking a break test it loses D3 wounds. Can't flee from a charge.",
"Demonic": "Never flees: instead of taking a break test it loses D3 wounds. Can't flee from a charge.",
"Halberd": "Charge Bonus: +1 Power. Enemy damage saves against this weapon in melee are never better than 4+."
"Regeneration": "Heals all missing wounds at end of turn.",
"Frenzy": "Re-roll missed Attack Rolls in the first round of combat. Can't flee from charges.",
"Reanimated": "-1 Movement. Never flees: loses D3 wounds instead of taking a break test. Can't flee from charges.",
"Demonic": "Never flees: loses D3 wounds instead of taking a break test. Can't flee from charges.",
"Ambusher": "Deploys anywhere on your half of the battlefield, up to 2\" from the centre line.",
"Halberd": "Charge Bonus: +1 Power. Damage Saves against this weapon are never better than 4+."
}
35 changes: 27 additions & 8 deletions src/pages/reference/glossary.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,36 @@ const GROUPS: { type: string; title: string; intro: string }[] = [
{ type: 'rangedAttack', title: 'Ranged weapons', intro: 'Bows, guns, war machines and breath weapons.' },
{ type: 'property', title: 'Unit properties', intro: 'Armour, special rules and traits.' },
{ type: 'gift', title: 'Gifts', intro: 'Blessings granted by dark powers.' },
{ type: 'commanderAbility', title: 'Commander abilities', intro: 'Abilities a commander activates, usually a limited number of times per battle.' },
{ type: 'spell', title: 'Spells', intro: 'Cast by spellcasters as their ability. Cantrips are the lesser spells.' },
{ type: 'commanderAbility', title: 'Commander abilities', intro: 'Abilities a commander activates, usually once per battle. A few can be used in every activation, and a few are always active.' },
{ type: 'spell', title: 'Spells', intro: 'Cast by spellcasters as their ability. Each spell has a set number of charges per battle, usually 1, 2 or 3. Cantrips are the lesser spells.' },
];

type MagicRef = { id: string; cost?: number };
const magicRefs = magicItemsJson as MagicRef[];
const magicIds = new Set(magicRefs.map((m) => m.id));
const anchor = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const entries = Object.entries(PROPERTIES).filter(([id]) => !magicIds.has(id));
// Anchors from names, numbered when a name repeats (Flamer and Javelins come in several versions).
const anchorsUsed = new Map<string, number>();
const anchor = (name: string) => {
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const n = (anchorsUsed.get(base) ?? 0) + 1;
anchorsUsed.set(base, n);
return n === 1 ? base : `${base}-${n}`;
};

// Separate properties that read exactly the same (a commander's and a unit's Brace of Pistols) are
// one entry here; the ids are kept so every faction using either is listed.
type Entry = [ids: string[], p: Property];
const merged = new Map<string, Entry>();
for (const [id, p] of Object.entries(PROPERTIES)) {
if (magicIds.has(id)) continue;
const key = `${p.type}|${p.name}|${p.description}`;
const existing = merged.get(key);
if (existing) existing[0].push(id);
else merged.set(key, [[id], p]);
}
const entries: Entry[] = [...merged.values()];
const byType = (type: string) =>
entries.filter(([, p]) => p.type === type).sort(([, a], [, b]) => a.name.localeCompare(b.name));
entries.filter(([, p]) => p.type === type).sort(([, a], [, b]) => a.name.localeCompare(b.name) || a.description.localeCompare(b.description));
const known = new Set(GROUPS.map((g) => g.type));
const other = entries.filter(([, p]) => !known.has(p.type) && !p.type.startsWith('magic'));

Expand All @@ -38,7 +57,7 @@ for (const f of FACTIONS) {
}
}
}
const factionsUsing = (id: string) => [...(usedBy.get(id) ?? [])].sort().join(' · ');
const factionsUsing = (ids: string[]) => [...new Set(ids.flatMap((id) => [...(usedBy.get(id) ?? [])]))].sort().join(' · ');

const magic = magicRefs
.map((m) => ({ ...m, p: PROPERTIES[m.id] as Property | undefined }))
Expand Down Expand Up @@ -76,12 +95,12 @@ const magicGroups = [
<h2>{g.title}</h2>
<p class="glossary-intro">{g.intro}</p>
<dl class="glossary-list">
{list.map(([id, p]) => (
{list.map(([ids, p]) => (
<div class="glossary-item" id={anchor(p.name)} data-text={`${p.name} ${p.description}`.toLowerCase()}>
<dt>{p.name}{p.cantrip && <span class="tag">Cantrip</span>}</dt>
<dd>
{p.description}
{usedBy.has(id) && <span class="used-by">{factionsUsing(id)}</span>}
{factionsUsing(ids) && <span class="used-by">{factionsUsing(ids)}</span>}
</dd>
</div>
))}
Expand Down
6 changes: 4 additions & 2 deletions src/pages/rules/strategic-phase.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,11 @@ Every other weapon is listed in the [Glossary](../../reference/glossary/).

## Abilities and spells

Many units and most commanders have **activated abilities**: spells, war cries, battle drugs and stranger things. Using one takes the unit's ability for the activation, just like shooting.
Many units and most commanders have **activated abilities**: spells, war cries, curses and stranger things. Using one takes the unit's ability for the activation, just like shooting.

- **No casting roll.** An ability always works. Its limit is how many times it can be used: most can be used **once or twice per battle**, and the game shows the uses left.
- **No casting roll.** Abilities and spells always work. What limits them is how many times they can be used, and the game shows the uses left.
- **Commander abilities** can usually be used **once per battle**. A few, such as Shield Wall, can be used in every activation, and a few are always active and never need activating.
- **Spells** have **charges**: a set number of casts per battle, usually **1, 2 or 3**.
- **Targets** depend on the ability. It might affect an enemy in line of sight, a friend within range, everything within range, or just the user.
- **Duration**: most effects last until the start of the next Strategic phase. Some last the whole battle, or until the target's next combat.
- **In combat**: units in combat can only use abilities marked as usable in combat, and most abilities can't target units in combat.
Expand Down
2 changes: 1 addition & 1 deletion src/pages/rules/units.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,6 @@ A **charge bonus** applies only in the first round of combat after the unit char
<dt>Ambusher</dt><dd>Deploys in the ambush zone, much further forward than your normal deployment zone.</dd>
<dt>Fearless</dt><dd>Ignores the penalty for losing a combat on Discipline tests.</dd>
<dt>Frenzy</dt><dd>Re-rolls missed attack rolls in the first round of combat. Can't flee from a charge.</dd>
<dt>Regeneration</dt><dd>At the start of each Strategic phase, heals any wounds on partly wounded models. Models already removed don't come back.</dd>
<dt>Regeneration</dt><dd>Heals all missing wounds at the end of the turn.</dd>
<dt>Poisoned Attacks</dt><dd>Enemies re-roll damage saves of 6.</dd>
</dl>
6 changes: 4 additions & 2 deletions src/styles/armies.css
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
.unit-card {
scroll-margin-top: 8rem;
position: relative;
display: grid; grid-template-columns: 9.5rem minmax(0, 1fr);
display: grid; grid-template-columns: 11rem minmax(0, 1fr);
border-radius: var(--radius); border: 1px solid var(--line); overflow: hidden;
background:
linear-gradient(180deg, rgb(255 255 255 / 0.02), transparent 40%),
Expand All @@ -109,8 +109,10 @@
linear-gradient(180deg, #1c2833, #121a22);
border-right: 1px solid var(--line);
display: flex; align-items: flex-end; justify-content: center;
padding: 0.75rem 0.6rem 0;
}
.unit-art img { width: 100%; height: 100%; max-height: 15rem; object-fit: cover; object-position: top center; }
/* Portraits range from tall and narrow (crossbowmen) to wide (rat swarms, dragons): show all of it. */
.unit-art img { width: 100%; height: auto; max-height: 15rem; object-fit: contain; object-position: center bottom; }
.unit-main { padding: 0.85rem 1rem 0.75rem; min-width: 0; display: grid; gap: 0.6rem; align-content: start; }
.unit-head { display: grid; grid-template-columns: auto 1fr auto; gap: 0.65rem; align-items: center; }
.type-icon {
Expand Down
Loading