diff --git a/src/lib/engine/derived.ts b/src/lib/engine/derived.ts index 0b67f5c..447c5ee 100644 --- a/src/lib/engine/derived.ts +++ b/src/lib/engine/derived.ts @@ -7,64 +7,74 @@ export function effectiveAttr(char: Character, key: AttributeKey): number { 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 { - const v = (effectiveAttr(char, 'MU') + effectiveAttr(char, 'GE') + effectiveAttr(char, 'KK')) / 5; - return Math.round(v); + const mu = effectiveAttr(char, 'MU'); + 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 { - const v = (effectiveAttr(char, 'IN') + effectiveAttr(char, 'GE') + effectiveAttr(char, 'KK')) / 5; - return Math.round(v); + const inn = effectiveAttr(char, 'IN'); + 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 { - const v = (effectiveAttr(char, 'IN') + effectiveAttr(char, 'FF') + effectiveAttr(char, 'KK')) / 4; - return Math.round(v); + const inn = effectiveAttr(char, 'IN'); + 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 { - const v = - (effectiveAttr(char, 'MU') + - effectiveAttr(char, 'MU') + - effectiveAttr(char, 'IN') + - effectiveAttr(char, 'GE')) / - 4; - return Math.round(v); + const mu = effectiveAttr(char, 'MU'); + const inn = effectiveAttr(char, 'IN'); + const ge = effectiveAttr(char, 'GE'); + return Math.round((mu + mu + inn + ge) / 5); } -/** 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 { - const race = getRaceDef(char.meta.rasse); - const v = - (effectiveAttr(char, 'MU') + effectiveAttr(char, 'KL') + effectiveAttr(char, 'KO')) / 5 + - char.energien.mr.mod + - (race?.mr_mod ?? 0); - return Math.round(v); + const mu = effectiveAttr(char, 'MU'); + const kl = effectiveAttr(char, 'KL'); + const ko = effectiveAttr(char, 'KO'); + const race = getRaceDef(char.meta.rasse)?.mr_mod ?? 0; + return Math.round((mu + kl + ko) / 5 + char.energien.mr.mod + race); } -/** 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 { const ko = effectiveAttr(char, 'KO'); 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; 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 { 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 base = effectiveAttr(char, 'IN') + effectiveAttr(char, 'IN') + effectiveAttr(char, 'CH'); 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 { + 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 base = effectiveAttr(char, 'MU') + effectiveAttr(char, 'MU') + effectiveAttr(char, 'IN'); return Math.max(0, base + race + char.energien.aup.mod); } diff --git a/src/lib/engine/ranged.ts b/src/lib/engine/ranged.ts index 2deeaf4..f522f6d 100644 --- a/src/lib/engine/ranged.ts +++ b/src/lib/engine/ranged.ts @@ -1,5 +1,8 @@ import type { Character } from '$lib/schema/character'; 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 { getRangedWeapon } from '$lib/rules/weapons-ranged'; import type { Expertise } from '$lib/rules/expertise'; @@ -22,8 +25,6 @@ import { type VisibilityId } from '$lib/rules/modifiers-ranged'; -export type { Expertise } from '$lib/rules/expertise'; - export type RangedSelections = { range: RangeId; targetSize: TargetSizeId; @@ -96,17 +97,24 @@ function effectiveTaWForReload( 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( char: Character, weaponId: RangedWeaponId, - selections: RangedSelections, - expertise: Expertise + selections: RangedSelections ): RangedResult { const weapon = getRangedWeapon(weaponId); if (!weapon) { throw new Error(`Unknown weapon: ${weaponId}`); } + const expertise = expertiseFromCharacter(char); + const fkBase = fkBasis(char); const kt = char.kampftalente.find((k) => k.id === weapon.skill); const rawTaW = kt?.taw ?? 0; @@ -125,6 +133,28 @@ export function computeRangedTarget( 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; if (weapon.strengthRequirement !== undefined && weapon.strengthModifier !== undefined) { const kk = effectiveAttr(char, 'KK'); diff --git a/src/lib/rules/abilities.ts b/src/lib/rules/abilities.ts index 61a58b4..30ce502 100644 --- a/src/lib/rules/abilities.ts +++ b/src/lib/rules/abilities.ts @@ -1,3 +1,5 @@ +import type { RangedWeaponId } from '$lib/rules/weapons-ranged'; + /** Abilities (Sonderfertigkeiten, extensible); modifiers for melee and ranged combat */ export type AbilityDef = { id: string; @@ -5,6 +7,8 @@ export type AbilityDef = { at_mod: number; pa_mod: number; fk_mod: number; + /** Nur für Talentspezialisierung: FK-Mod gilt nur für diese Fernkampfwaffe */ + weapon_type?: RangedWeaponId; }; export const ABILITIES: AbilityDef[] = [ @@ -55,42 +59,48 @@ export const ABILITIES: AbilityDef[] = [ name: 'Talentspezialisierung Bogen (Elfenbogen)', at_mod: 0, pa_mod: 0, - fk_mod: 2 + fk_mod: 2, + weapon_type: 'elven_bow' }, { id: 'specialize_composite_bow', name: 'Talentspezialisierung Bogen (Kompositbogen)', at_mod: 0, pa_mod: 0, - fk_mod: 2 + fk_mod: 2, + weapon_type: 'composite_bow' }, { id: 'specialize_warbow', name: 'Talentspezialisierung Bogen (Kriegsbogen)', at_mod: 0, pa_mod: 0, - fk_mod: 2 + fk_mod: 2, + weapon_type: 'warbow' }, { id: 'specialize_shortbow', name: 'Talentspezialisierung Bogen (Kurzbogen)', at_mod: 0, pa_mod: 0, - fk_mod: 2 + fk_mod: 2, + weapon_type: 'shortbow' }, { id: 'specialize_longbow', name: 'Talentspezialisierung Bogen (Langbogen)', at_mod: 0, pa_mod: 0, - fk_mod: 2 + fk_mod: 2, + weapon_type: 'longbow' }, { id: 'specialize_orc_bow', name: 'Talentspezialisierung Bogen (Ork. Reiterbogen)', at_mod: 0, pa_mod: 0, - fk_mod: 2 + fk_mod: 2, + weapon_type: 'orc_bow' } ]; diff --git a/src/lib/rules/modifiers-ranged.ts b/src/lib/rules/modifiers-ranged.ts index 84bdbbc..be2cacc 100644 --- a/src/lib/rules/modifiers-ranged.ts +++ b/src/lib/rules/modifiers-ranged.ts @@ -303,28 +303,34 @@ export const RELOAD_STATES: ModifierRow[] = [ { 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( row: ModifierRow | undefined, expertise: Expertise ): number | null { if (!row) return null; - const raw = - expertise === 'master' && row.modifier_master !== undefined - ? row.modifier_master - : expertise === 'expert' && row.modifier_expert !== undefined - ? row.modifier_expert - : row.modifier; + const raw = resolveModifierRaw(row, expertise); if (typeof raw === 'number') return raw; return null; } export function isStringModifier(row: ModifierRow | undefined, expertise: Expertise): boolean { if (!row) return false; - const raw = - expertise === 'master' && row.modifier_master !== undefined - ? row.modifier_master - : expertise === 'expert' && row.modifier_expert !== undefined - ? row.modifier_expert - : row.modifier; - return typeof raw === 'string'; + return typeof resolveModifierRaw(row, expertise) === 'string'; } diff --git a/src/routes/characters/[id]/combat/ranged/+page.svelte b/src/routes/characters/[id]/combat/ranged/+page.svelte index 54cc9ba..d90f33a 100644 --- a/src/routes/characters/[id]/combat/ranged/+page.svelte +++ b/src/routes/characters/[id]/combat/ranged/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores'; import type { Character } from '$lib/schema/character'; 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 { RANGES, @@ -20,12 +20,12 @@ type RangeId, type ReloadStateId, type TargetSizeId, - type VisibilityId + type VisibilityId, + formatModifierLabel } from '$lib/rules/modifiers-ranged'; function optionLabel(row: ModifierRow): string { - const m = row.modifier; - return `${row.name} (${m})`; + return formatModifierLabel(row, expertise); } let char: Character | undefined; @@ -33,7 +33,6 @@ const id = $page.params.id ?? ''; let weaponId: RangedWeaponId = 'shortbow'; - let expertise: Expertise = 'none'; let range: RangeId = 'near'; let targetSize: TargetSizeId = 'medium'; let visibility: VisibilityId = 'clear'; @@ -67,27 +66,24 @@ char && (() => { try { - return computeRangedTarget( - char, - weaponId, - { - range, - targetSize, - visibility, - movement, - hitZone: hitZone === '' ? undefined : hitZone, - reloadState: reloadState || undefined, - extras: [...extras], - aimTurns, - targetBuild - }, - expertise - ); + return computeRangedTarget(char, weaponId, { + range, + targetSize, + visibility, + movement, + hitZone: hitZone === '' ? undefined : hitZone, + reloadState: reloadState || undefined, + extras: [...extras], + aimTurns, + targetBuild + }); } catch { return null; } })(); + $: expertise = char ? expertiseFromCharacter(char) : 'none'; + $: hitZoneOptions = HIT_ZONES.filter((z) => z.build === targetBuild); $: weaponForExamples = getRangedWeapon(weaponId); @@ -120,15 +116,6 @@ {/if} -
-

Schützen-SF

- -
-

Ziel

@@ -216,7 +203,7 @@
diff --git a/tests/engine/derived.test.ts b/tests/engine/derived.test.ts index ab364f8..64ae6b5 100644 --- a/tests/engine/derived.test.ts +++ b/tests/engine/derived.test.ts @@ -1,5 +1,5 @@ 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'; describe('derived', () => { @@ -7,7 +7,7 @@ describe('derived', () => { const c = newCharacter({ name: 'Test', id: '00000000-0000-4000-8000-000000000001' }); expect(atBasis(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', () => { @@ -17,10 +17,30 @@ describe('derived', () => { const kk = 13; c.eigenschaften.KO = { startwert: ko, 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); }); + 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', () => { const c = newCharacter({ id: '00000000-0000-4000-8000-000000000003' }); const d = computeDerived(c); diff --git a/tests/engine/ranged.test.ts b/tests/engine/ranged.test.ts index d0149ff..81cf777 100644 --- a/tests/engine/ranged.test.ts +++ b/tests/engine/ranged.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { computeRangedTarget } from '$lib/engine/ranged'; +import { fkBasis } from '$lib/engine/derived'; import { newCharacter } from '$lib/characters/default'; import type { ExtraModifierId } from '$lib/rules/modifiers-ranged'; @@ -26,8 +27,8 @@ const baseSel = { describe('ranged', () => { it('computes FK target with zero modifiers', () => { const c = charWithBogen(4); - const fkBase = Math.round((11 + 11 + 11) / 4); - const r = computeRangedTarget(c, 'shortbow', { ...baseSel, reloadState: undefined }, 'none'); + const fkBase = fkBasis(c); + const r = computeRangedTarget(c, 'shortbow', { ...baseSel, reloadState: undefined }); expect(r.fkBase).toBe(fkBase); expect(r.baseTarget).toBe(fkBase + 4); expect(r.finalTarget).toBe(fkBase + 4); @@ -38,8 +39,7 @@ describe('ranged', () => { const r = computeRangedTarget( c, 'shortbow', - { ...baseSel, range: 'medium', reloadState: 'regular_shot' }, - 'none' + { ...baseSel, range: 'medium', reloadState: 'regular_shot' } ); // +4 Entfernung, +1 normaler Schuss, +0 Zielgröße „groß“ expect(r.totalModifier).toBe(5); @@ -51,10 +51,39 @@ describe('ranged', () => { const r = computeRangedTarget( c, 'shortbow', - { ...baseSel, reloadState: 'aimed_shot' }, - 'none' + { ...baseSel, reloadState: 'aimed_shot' } ); expect(r.effectiveTaW).toBe(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); + }); });