refine ranged combat

This commit is contained in:
2026-05-16 21:35:35 +02:00
parent 866c0a9f2d
commit 97ba4bf478
7 changed files with 183 additions and 91 deletions
+38 -28
View File
@@ -7,64 +7,74 @@ export function effectiveAttr(char: Character, key: AttributeKey): number {
return a.startwert + a.mod; return a.startwert + a.mod;
} }
/** DSA 4.1: AT-Basis = (MU + GE + KK) / 5, kaufmännisch gerundet */ /** AT-Basis = (MU + GE + KK) / 5, kaufmännisch gerundet */
export function atBasis(char: Character): number { export function atBasis(char: Character): number {
const v = (effectiveAttr(char, 'MU') + effectiveAttr(char, 'GE') + effectiveAttr(char, 'KK')) / 5; const mu = effectiveAttr(char, 'MU');
return Math.round(v); const ge = effectiveAttr(char, 'GE');
const kk = effectiveAttr(char, 'KK');
return Math.round((mu + ge + kk) / 5);
} }
/** PA-Basis = (IN + GE + KK) / 5 */ /** PA-Basis = (IN + GE + KK) / 5, kaufmännisch gerundet */
export function paBasis(char: Character): number { export function paBasis(char: Character): number {
const v = (effectiveAttr(char, 'IN') + effectiveAttr(char, 'GE') + effectiveAttr(char, 'KK')) / 5; const inn = effectiveAttr(char, 'IN');
return Math.round(v); const ge = effectiveAttr(char, 'GE');
const kk = effectiveAttr(char, 'KK');
return Math.round((inn + ge + kk) / 5);
} }
/** FK-Basis = (IN + FF + KK) / 4 */ /** FK-Basis = (IN + FF + KK) / 5, kaufmännisch gerundet */
export function fkBasis(char: Character): number { export function fkBasis(char: Character): number {
const v = (effectiveAttr(char, 'IN') + effectiveAttr(char, 'FF') + effectiveAttr(char, 'KK')) / 4; const inn = effectiveAttr(char, 'IN');
return Math.round(v); const ff = effectiveAttr(char, 'FF');
const kk = effectiveAttr(char, 'KK');
return Math.round((inn + ff + kk) / 5);
} }
/** INI-Basis = (MU + MU + IN + GE) / 4 */ /** INI-Basis = (MU + MU + IN + GE) / 5, kaufmännisch gerundet */
export function iniBasis(char: Character): number { export function iniBasis(char: Character): number {
const v = const mu = effectiveAttr(char, 'MU');
(effectiveAttr(char, 'MU') + const inn = effectiveAttr(char, 'IN');
effectiveAttr(char, 'MU') + const ge = effectiveAttr(char, 'GE');
effectiveAttr(char, 'IN') + return Math.round((mu + mu + inn + ge) / 5);
effectiveAttr(char, 'GE')) /
4;
return Math.round(v);
} }
/** MR-Basis = (MU + KL + KO) / 5 + MR-Mod aus Energien + Rassen-MR */ /** MR-Basis = (MU + KL + KO) / 5 + MR-Mod + Rassen-MR, kaufmännisch gerundet */
export function mrBasis(char: Character): number { export function mrBasis(char: Character): number {
const race = getRaceDef(char.meta.rasse); const mu = effectiveAttr(char, 'MU');
const v = const kl = effectiveAttr(char, 'KL');
(effectiveAttr(char, 'MU') + effectiveAttr(char, 'KL') + effectiveAttr(char, 'KO')) / 5 + const ko = effectiveAttr(char, 'KO');
char.energien.mr.mod + const race = getRaceDef(char.meta.rasse)?.mr_mod ?? 0;
(race?.mr_mod ?? 0); return Math.round((mu + kl + ko) / 5 + char.energien.mr.mod + race);
return Math.round(v);
} }
/** LeP-Maximum: (2*KO + KK) / 2 abrunden + Rassenbonus + LeP-Mod */ /** LeP-Maximum: (2*KO + KK) / 2 kaufmännisch gerundet + Rassenbonus + LeP-Mod */
export function lepMax(char: Character): number { export function lepMax(char: Character): number {
const ko = effectiveAttr(char, 'KO'); const ko = effectiveAttr(char, 'KO');
const kk = effectiveAttr(char, 'KK'); const kk = effectiveAttr(char, 'KK');
const base = Math.floor((2 * ko + kk) / 2); const base = Math.round((2 * ko + kk) / 2);
const race = getRaceDef(char.meta.rasse)?.lep_mod ?? 0; const race = getRaceDef(char.meta.rasse)?.lep_mod ?? 0;
return base + race + char.energien.lep.mod; return base + race + char.energien.lep.mod;
} }
/** Asp-Maximum: (MU + IN + CH) / 2 kaufmännisch gerundet + Rassenbonus + Asp-Mod */
export function aspMax(char: Character): number { export function aspMax(char: Character): number {
if (!char.energien.asp) return 0; if (!char.energien.asp) return 0;
const mu = effectiveAttr(char, 'MU');
const inn = effectiveAttr(char, 'IN');
const ch = effectiveAttr(char, 'CH');
const base = Math.round((mu + inn + ch) / 2);
const race = getRaceDef(char.meta.rasse)?.asp_mod ?? 0; const race = getRaceDef(char.meta.rasse)?.asp_mod ?? 0;
const base = effectiveAttr(char, 'IN') + effectiveAttr(char, 'IN') + effectiveAttr(char, 'CH');
return Math.max(0, base + race + char.energien.asp.mod); return Math.max(0, base + race + char.energien.asp.mod);
} }
/** Aup-Maximum: (MU + KO + GE) / 2 kaufmännisch gerundet + Rassenbonus + Aup-Mod */
export function aupMax(char: Character): number { export function aupMax(char: Character): number {
const mu = effectiveAttr(char, 'MU');
const ko = effectiveAttr(char, 'KO');
const ge = effectiveAttr(char, 'GE');
const base = Math.round((mu + ko + ge) / 2);
const race = getRaceDef(char.meta.rasse)?.aup_mod ?? 0; const race = getRaceDef(char.meta.rasse)?.aup_mod ?? 0;
const base = effectiveAttr(char, 'MU') + effectiveAttr(char, 'MU') + effectiveAttr(char, 'IN');
return Math.max(0, base + race + char.energien.aup.mod); return Math.max(0, base + race + char.energien.aup.mod);
} }
+34 -4
View File
@@ -1,5 +1,8 @@
import type { Character } from '$lib/schema/character'; import type { Character } from '$lib/schema/character';
import { effectiveAttr, fkBasis } from '$lib/engine/derived'; import { effectiveAttr, fkBasis } from '$lib/engine/derived';
import { getAbility } from '$lib/rules/abilities';
import { getAdvantage } from '$lib/rules/advantages';
import { getDisadvantage } from '$lib/rules/disadvantages';
import type { RangedWeaponId } from '$lib/rules/weapons-ranged'; import type { RangedWeaponId } from '$lib/rules/weapons-ranged';
import { getRangedWeapon } from '$lib/rules/weapons-ranged'; import { getRangedWeapon } from '$lib/rules/weapons-ranged';
import type { Expertise } from '$lib/rules/expertise'; import type { Expertise } from '$lib/rules/expertise';
@@ -22,8 +25,6 @@ import {
type VisibilityId type VisibilityId
} from '$lib/rules/modifiers-ranged'; } from '$lib/rules/modifiers-ranged';
export type { Expertise } from '$lib/rules/expertise';
export type RangedSelections = { export type RangedSelections = {
range: RangeId; range: RangeId;
targetSize: TargetSizeId; targetSize: TargetSizeId;
@@ -96,17 +97,24 @@ function effectiveTaWForReload(
return { effectiveTaW: rawTaW }; return { effectiveTaW: rawTaW };
} }
export function expertiseFromCharacter(char: Character): Expertise {
if (char.abilities.some((a) => a.defId === 'marksman')) return 'master';
if (char.abilities.some((a) => a.defId === 'sniper')) return 'expert';
return 'none';
}
export function computeRangedTarget( export function computeRangedTarget(
char: Character, char: Character,
weaponId: RangedWeaponId, weaponId: RangedWeaponId,
selections: RangedSelections, selections: RangedSelections
expertise: Expertise
): RangedResult { ): RangedResult {
const weapon = getRangedWeapon(weaponId); const weapon = getRangedWeapon(weaponId);
if (!weapon) { if (!weapon) {
throw new Error(`Unknown weapon: ${weaponId}`); throw new Error(`Unknown weapon: ${weaponId}`);
} }
const expertise = expertiseFromCharacter(char);
const fkBase = fkBasis(char); const fkBase = fkBasis(char);
const kt = char.kampftalente.find((k) => k.id === weapon.skill); const kt = char.kampftalente.find((k) => k.id === weapon.skill);
const rawTaW = kt?.taw ?? 0; const rawTaW = kt?.taw ?? 0;
@@ -125,6 +133,28 @@ export function computeRangedTarget(
if (value !== 0 || note) modifiers.push({ id, label, value, note }); if (value !== 0 || note) modifiers.push({ id, label, value, note });
}; };
for (const entry of char.advantages) {
if (!entry.defId) continue;
const def = getAdvantage(entry.defId);
if (!def || def.fk_mod === 0) continue;
push(`advantage:${def.id}`, `Vorteil: ${def.name}`, -def.fk_mod);
}
for (const entry of char.disadvantages) {
if (!entry.defId) continue;
const def = getDisadvantage(entry.defId);
if (!def || def.fk_mod === 0) continue;
push(`disadvantage:${def.id}`, `Nachteil: ${def.name}`, -def.fk_mod);
}
for (const entry of char.abilities) {
if (!entry.defId) continue;
const def = getAbility(entry.defId);
if (!def || def.fk_mod === 0) continue;
if (def.weapon_type !== undefined && def.weapon_type !== weapon.id) continue;
push(`ability:${def.id}`, `SF: ${def.name}`, -def.fk_mod);
}
let strengthNote: string | undefined; let strengthNote: string | undefined;
if (weapon.strengthRequirement !== undefined && weapon.strengthModifier !== undefined) { if (weapon.strengthRequirement !== undefined && weapon.strengthModifier !== undefined) {
const kk = effectiveAttr(char, 'KK'); const kk = effectiveAttr(char, 'KK');
+16 -6
View File
@@ -1,3 +1,5 @@
import type { RangedWeaponId } from '$lib/rules/weapons-ranged';
/** Abilities (Sonderfertigkeiten, extensible); modifiers for melee and ranged combat */ /** Abilities (Sonderfertigkeiten, extensible); modifiers for melee and ranged combat */
export type AbilityDef = { export type AbilityDef = {
id: string; id: string;
@@ -5,6 +7,8 @@ export type AbilityDef = {
at_mod: number; at_mod: number;
pa_mod: number; pa_mod: number;
fk_mod: number; fk_mod: number;
/** Nur für Talentspezialisierung: FK-Mod gilt nur für diese Fernkampfwaffe */
weapon_type?: RangedWeaponId;
}; };
export const ABILITIES: AbilityDef[] = [ export const ABILITIES: AbilityDef[] = [
@@ -55,42 +59,48 @@ export const ABILITIES: AbilityDef[] = [
name: 'Talentspezialisierung Bogen (Elfenbogen)', name: 'Talentspezialisierung Bogen (Elfenbogen)',
at_mod: 0, at_mod: 0,
pa_mod: 0, pa_mod: 0,
fk_mod: 2 fk_mod: 2,
weapon_type: 'elven_bow'
}, },
{ {
id: 'specialize_composite_bow', id: 'specialize_composite_bow',
name: 'Talentspezialisierung Bogen (Kompositbogen)', name: 'Talentspezialisierung Bogen (Kompositbogen)',
at_mod: 0, at_mod: 0,
pa_mod: 0, pa_mod: 0,
fk_mod: 2 fk_mod: 2,
weapon_type: 'composite_bow'
}, },
{ {
id: 'specialize_warbow', id: 'specialize_warbow',
name: 'Talentspezialisierung Bogen (Kriegsbogen)', name: 'Talentspezialisierung Bogen (Kriegsbogen)',
at_mod: 0, at_mod: 0,
pa_mod: 0, pa_mod: 0,
fk_mod: 2 fk_mod: 2,
weapon_type: 'warbow'
}, },
{ {
id: 'specialize_shortbow', id: 'specialize_shortbow',
name: 'Talentspezialisierung Bogen (Kurzbogen)', name: 'Talentspezialisierung Bogen (Kurzbogen)',
at_mod: 0, at_mod: 0,
pa_mod: 0, pa_mod: 0,
fk_mod: 2 fk_mod: 2,
weapon_type: 'shortbow'
}, },
{ {
id: 'specialize_longbow', id: 'specialize_longbow',
name: 'Talentspezialisierung Bogen (Langbogen)', name: 'Talentspezialisierung Bogen (Langbogen)',
at_mod: 0, at_mod: 0,
pa_mod: 0, pa_mod: 0,
fk_mod: 2 fk_mod: 2,
weapon_type: 'longbow'
}, },
{ {
id: 'specialize_orc_bow', id: 'specialize_orc_bow',
name: 'Talentspezialisierung Bogen (Ork. Reiterbogen)', name: 'Talentspezialisierung Bogen (Ork. Reiterbogen)',
at_mod: 0, at_mod: 0,
pa_mod: 0, pa_mod: 0,
fk_mod: 2 fk_mod: 2,
weapon_type: 'orc_bow'
} }
]; ];
+19 -13
View File
@@ -303,28 +303,34 @@ export const RELOAD_STATES: ModifierRow[] = [
{ id: 'fast_reload_bow', name: 'Schnellladen', modifier: -1, skill: 'archery' } { id: 'fast_reload_bow', name: 'Schnellladen', modifier: -1, skill: 'archery' }
]; ];
function resolveModifierRaw(
row: ModifierRow,
expertise: Expertise
): number | string {
return expertise === 'master' && row.modifier_master !== undefined
? row.modifier_master
: expertise === 'expert' && row.modifier_expert !== undefined
? row.modifier_expert
: row.modifier;
}
/** Anzeige-Label für Dropdown-Optionen (berücksichtigt Scharfschütze/Meisterschütze). */
export function formatModifierLabel(row: ModifierRow, expertise: Expertise): string {
const m = resolveModifierRaw(row, expertise);
return `${row.name} (${m})`;
}
export function resolveNumericModifier( export function resolveNumericModifier(
row: ModifierRow | undefined, row: ModifierRow | undefined,
expertise: Expertise expertise: Expertise
): number | null { ): number | null {
if (!row) return null; if (!row) return null;
const raw = const raw = resolveModifierRaw(row, expertise);
expertise === 'master' && row.modifier_master !== undefined
? row.modifier_master
: expertise === 'expert' && row.modifier_expert !== undefined
? row.modifier_expert
: row.modifier;
if (typeof raw === 'number') return raw; if (typeof raw === 'number') return raw;
return null; return null;
} }
export function isStringModifier(row: ModifierRow | undefined, expertise: Expertise): boolean { export function isStringModifier(row: ModifierRow | undefined, expertise: Expertise): boolean {
if (!row) return false; if (!row) return false;
const raw = return typeof resolveModifierRaw(row, expertise) === 'string';
expertise === 'master' && row.modifier_master !== undefined
? row.modifier_master
: expertise === 'expert' && row.modifier_expert !== undefined
? row.modifier_expert
: row.modifier;
return typeof raw === 'string';
} }
@@ -3,7 +3,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { Character } from '$lib/schema/character'; import type { Character } from '$lib/schema/character';
import { getCharacter } from '$lib/storage/repo'; import { getCharacter } from '$lib/storage/repo';
import { computeRangedTarget, type Expertise } from '$lib/engine/ranged'; import { computeRangedTarget, expertiseFromCharacter } from '$lib/engine/ranged';
import { getRangedWeapon, RANGED_WEAPONS, type RangedWeaponId } from '$lib/rules/weapons-ranged'; import { getRangedWeapon, RANGED_WEAPONS, type RangedWeaponId } from '$lib/rules/weapons-ranged';
import { import {
RANGES, RANGES,
@@ -20,12 +20,12 @@
type RangeId, type RangeId,
type ReloadStateId, type ReloadStateId,
type TargetSizeId, type TargetSizeId,
type VisibilityId type VisibilityId,
formatModifierLabel
} from '$lib/rules/modifiers-ranged'; } from '$lib/rules/modifiers-ranged';
function optionLabel(row: ModifierRow): string { function optionLabel(row: ModifierRow): string {
const m = row.modifier; return formatModifierLabel(row, expertise);
return `${row.name} (${m})`;
} }
let char: Character | undefined; let char: Character | undefined;
@@ -33,7 +33,6 @@
const id = $page.params.id ?? ''; const id = $page.params.id ?? '';
let weaponId: RangedWeaponId = 'shortbow'; let weaponId: RangedWeaponId = 'shortbow';
let expertise: Expertise = 'none';
let range: RangeId = 'near'; let range: RangeId = 'near';
let targetSize: TargetSizeId = 'medium'; let targetSize: TargetSizeId = 'medium';
let visibility: VisibilityId = 'clear'; let visibility: VisibilityId = 'clear';
@@ -67,10 +66,7 @@
char && char &&
(() => { (() => {
try { try {
return computeRangedTarget( return computeRangedTarget(char, weaponId, {
char,
weaponId,
{
range, range,
targetSize, targetSize,
visibility, visibility,
@@ -80,14 +76,14 @@
extras: [...extras], extras: [...extras],
aimTurns, aimTurns,
targetBuild targetBuild
}, });
expertise
);
} catch { } catch {
return null; return null;
} }
})(); })();
$: expertise = char ? expertiseFromCharacter(char) : 'none';
$: hitZoneOptions = HIT_ZONES.filter((z) => z.build === targetBuild); $: hitZoneOptions = HIT_ZONES.filter((z) => z.build === targetBuild);
$: weaponForExamples = getRangedWeapon(weaponId); $: weaponForExamples = getRangedWeapon(weaponId);
@@ -120,15 +116,6 @@
{/if} {/if}
</section> </section>
<section class="card stack">
<h2>Schützen-SF</h2>
<select bind:value={expertise}>
<option value="none">keine</option>
<option value="expert">Scharfschütze (expert)</option>
<option value="master">Meisterschütze (master)</option>
</select>
</section>
<section class="card stack"> <section class="card stack">
<h2>Ziel</h2> <h2>Ziel</h2>
<div class="field"> <div class="field">
@@ -216,7 +203,7 @@
<select bind:value={hitZone}> <select bind:value={hitZone}>
<option value="">— keine —</option> <option value="">— keine —</option>
{#each hitZoneOptions as z} {#each hitZoneOptions as z}
<option value={z.id}>{z.name}</option> <option value={z.id}>{optionLabel(z)}</option>
{/each} {/each}
</select> </select>
</section> </section>
+23 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { atBasis, fkBasis, lepMax, paBasis, computeDerived } from '$lib/engine/derived'; import { aspMax, atBasis, aupMax, fkBasis, lepMax, paBasis, computeDerived } from '$lib/engine/derived';
import { newCharacter } from '$lib/characters/default'; import { newCharacter } from '$lib/characters/default';
describe('derived', () => { describe('derived', () => {
@@ -7,7 +7,7 @@ describe('derived', () => {
const c = newCharacter({ name: 'Test', id: '00000000-0000-4000-8000-000000000001' }); const c = newCharacter({ name: 'Test', id: '00000000-0000-4000-8000-000000000001' });
expect(atBasis(c)).toBe(Math.round((11 + 11 + 11) / 5)); expect(atBasis(c)).toBe(Math.round((11 + 11 + 11) / 5));
expect(paBasis(c)).toBe(Math.round((11 + 11 + 11) / 5)); expect(paBasis(c)).toBe(Math.round((11 + 11 + 11) / 5));
expect(fkBasis(c)).toBe(Math.round((11 + 11 + 11) / 4)); expect(fkBasis(c)).toBe(Math.round((11 + 11 + 11) / 5));
}); });
it('includes race LeP bonus for human', () => { it('includes race LeP bonus for human', () => {
@@ -17,10 +17,30 @@ describe('derived', () => {
const kk = 13; const kk = 13;
c.eigenschaften.KO = { startwert: ko, mod: 0 }; c.eigenschaften.KO = { startwert: ko, mod: 0 };
c.eigenschaften.KK = { startwert: kk, mod: 0 }; c.eigenschaften.KK = { startwert: kk, mod: 0 };
const base = Math.floor((2 * ko + kk) / 2); const base = Math.round((2 * ko + kk) / 2);
expect(lepMax(c)).toBe(base + 10 + c.energien.lep.mod); expect(lepMax(c)).toBe(base + 10 + c.energien.lep.mod);
}); });
it('computes aspMax as round((MU+IN+CH)/2) plus race and mod', () => {
const c = newCharacter({ id: '00000000-0000-4000-8000-000000000004' });
c.energien.asp = { mod: 0 };
c.eigenschaften.MU = { startwert: 12, mod: 0 };
c.eigenschaften.IN = { startwert: 14, mod: 0 };
c.eigenschaften.CH = { startwert: 10, mod: 0 };
const base = Math.round((12 + 14 + 10) / 2);
expect(aspMax(c)).toBe(base + c.energien.asp.mod);
});
it('computes aupMax as round((MU+KO+GE)/2) plus race and mod', () => {
const c = newCharacter({ id: '00000000-0000-4000-8000-000000000005' });
c.meta.rasse = 'mensch01';
c.eigenschaften.MU = { startwert: 13, mod: 0 };
c.eigenschaften.KO = { startwert: 12, mod: 0 };
c.eigenschaften.GE = { startwert: 11, mod: 0 };
const base = Math.round((13 + 12 + 11) / 2);
expect(aupMax(c)).toBe(base + 10 + c.energien.aup.mod);
});
it('computeDerived returns all keys', () => { it('computeDerived returns all keys', () => {
const c = newCharacter({ id: '00000000-0000-4000-8000-000000000003' }); const c = newCharacter({ id: '00000000-0000-4000-8000-000000000003' });
const d = computeDerived(c); const d = computeDerived(c);
+35 -6
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { computeRangedTarget } from '$lib/engine/ranged'; import { computeRangedTarget } from '$lib/engine/ranged';
import { fkBasis } from '$lib/engine/derived';
import { newCharacter } from '$lib/characters/default'; import { newCharacter } from '$lib/characters/default';
import type { ExtraModifierId } from '$lib/rules/modifiers-ranged'; import type { ExtraModifierId } from '$lib/rules/modifiers-ranged';
@@ -26,8 +27,8 @@ const baseSel = {
describe('ranged', () => { describe('ranged', () => {
it('computes FK target with zero modifiers', () => { it('computes FK target with zero modifiers', () => {
const c = charWithBogen(4); const c = charWithBogen(4);
const fkBase = Math.round((11 + 11 + 11) / 4); const fkBase = fkBasis(c);
const r = computeRangedTarget(c, 'shortbow', { ...baseSel, reloadState: undefined }, 'none'); const r = computeRangedTarget(c, 'shortbow', { ...baseSel, reloadState: undefined });
expect(r.fkBase).toBe(fkBase); expect(r.fkBase).toBe(fkBase);
expect(r.baseTarget).toBe(fkBase + 4); expect(r.baseTarget).toBe(fkBase + 4);
expect(r.finalTarget).toBe(fkBase + 4); expect(r.finalTarget).toBe(fkBase + 4);
@@ -38,8 +39,7 @@ describe('ranged', () => {
const r = computeRangedTarget( const r = computeRangedTarget(
c, c,
'shortbow', 'shortbow',
{ ...baseSel, range: 'medium', reloadState: 'regular_shot' }, { ...baseSel, range: 'medium', reloadState: 'regular_shot' }
'none'
); );
// +4 Entfernung, +1 normaler Schuss, +0 Zielgröße „groß“ // +4 Entfernung, +1 normaler Schuss, +0 Zielgröße „groß“
expect(r.totalModifier).toBe(5); expect(r.totalModifier).toBe(5);
@@ -51,10 +51,39 @@ describe('ranged', () => {
const r = computeRangedTarget( const r = computeRangedTarget(
c, c,
'shortbow', 'shortbow',
{ ...baseSel, reloadState: 'aimed_shot' }, { ...baseSel, reloadState: 'aimed_shot' }
'none'
); );
expect(r.effectiveTaW).toBe(5); expect(r.effectiveTaW).toBe(5);
expect(r.baseTarget).toBe(r.fkBase + 5); expect(r.baseTarget).toBe(r.fkBase + 5);
}); });
it('applies matching weapon specialization (+2 FK)', () => {
const c = charWithBogen(0);
c.abilities = [{ id: 'sf1', defId: 'specialize_shortbow' }];
const base = computeRangedTarget(c, 'shortbow', baseSel);
const plain = computeRangedTarget(charWithBogen(0), 'shortbow', baseSel);
expect(base.finalTarget).toBe(plain.finalTarget + 2);
});
it('ignores specialization for a different weapon', () => {
const c = charWithBogen(0);
c.abilities = [{ id: 'sf1', defId: 'specialize_longbow' }];
const r = computeRangedTarget(c, 'shortbow', baseSel);
const plain = computeRangedTarget(charWithBogen(0), 'shortbow', baseSel);
expect(r.finalTarget).toBe(plain.finalTarget);
});
it('derives expert from Scharfschütze ability (TaW/2 -2 for aimed shot)', () => {
const c = charWithBogen(10);
c.abilities = [{ id: 'sf1', defId: 'sniper' }];
const r = computeRangedTarget(c, 'shortbow', { ...baseSel, reloadState: 'aimed_shot' });
expect(r.effectiveTaW).toBe(3);
});
it('derives master from Meisterschütze ability (full TaW for aimed shot)', () => {
const c = charWithBogen(10);
c.abilities = [{ id: 'sf1', defId: 'marksman' }];
const r = computeRangedTarget(c, 'shortbow', { ...baseSel, reloadState: 'aimed_shot' });
expect(r.effectiveTaW).toBe(10);
});
}); });