Compare commits
2 Commits
866c0a9f2d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 260b7b0529 | |||
| 97ba4bf478 |
@@ -1,31 +1,174 @@
|
|||||||
# DSA 4.1 Helfer (PWA)
|
# DSA 4.1 Helfer (PWA)
|
||||||
|
|
||||||
Offline-fähiger Spielerhelfer für **Das Schwarze Auge 4.1**: Bogen, Fernkampf-Zielzahlen, Nahkampf, Zauberliste, lokaler Speicher (IndexedDB), Export/Import als JSON oder YAML.
|
Offline-fähiger Spielerhelfer für **Das Schwarze Auge 4.1**. Verwaltet Helden lokal
|
||||||
|
im Browser (IndexedDB), berechnet abgeleitete Werte und unterstützt am Tisch beim
|
||||||
|
Fern- und Nahkampf sowie bei Zauberproben. Alles läuft als installierbare PWA
|
||||||
|
ohne Server – die Daten verlassen den Rechner nicht.
|
||||||
|
|
||||||
|
> Schwerpunkt liegt aktuell auf **Fernkampf mit dem Bogen**: die komplette
|
||||||
|
> Modifikatoren-Tabelle (Distanz, Sicht, Bewegung, Trefferzonen,
|
||||||
|
> Schussart/Nachladen, Wind, Steilschuss, Zielen, Scharf-/Meisterschütze) ist
|
||||||
|
> implementiert.
|
||||||
|
|
||||||
|
## Funktionen
|
||||||
|
|
||||||
|
### Heldenverwaltung
|
||||||
|
- Mehrere Helden parallel, lokal in IndexedDB gespeichert (`dsa-ranger`-DB via Dexie).
|
||||||
|
- Anlegen, Öffnen, Löschen direkt aus der Übersicht (`/`).
|
||||||
|
- **Export & Import** als JSON oder YAML (z. B. für Backup oder Austausch zwischen Geräten).
|
||||||
|
- Eingaben werden über [Zod](https://zod.dev) validiert; mitgelieferte
|
||||||
|
`migrateCharacter`-Funktion hält Altdateien beim Import auf dem aktuellen
|
||||||
|
Schema (`schemaVersion: 1`).
|
||||||
|
|
||||||
|
### Charakterbogen (`/characters/:id/sheet`)
|
||||||
|
- Stammdaten (Name, Rasse, Kultur, Profession).
|
||||||
|
- Eigenschaften MU/KL/IN/CH/FF/GE/KO/KK mit Startwert und Mod.
|
||||||
|
- Energien LeP/AsP/AuP/MR mit individuellem Mod-Feld; AsP optional aktivierbar.
|
||||||
|
- Talente und **Kampftalente** mit TaW sowie eigenen AT/PA-Werten; ein Helfer
|
||||||
|
setzt AT/PA aus Basis + TaW neu.
|
||||||
|
- **Vorteile / Nachteile / Sonderfertigkeiten** aus typisierten Regeltabellen
|
||||||
|
(mit Stufenwahl, wo vorgesehen).
|
||||||
|
- Inventarliste, Notizen (Freitext) und **Nahkampfwaffen-Instanzen** mit
|
||||||
|
Talentbindung und AT-/PA-Modifikator.
|
||||||
|
|
||||||
|
### Übersicht (`/characters/:id`)
|
||||||
|
- Zeigt automatisch berechnete Basiswerte: **AT-, PA-, FK-, INI- und MR-Basis**
|
||||||
|
sowie **LeP-, AsP- und AuP-Maximum**. Formeln nach DSA 4.1 inkl.
|
||||||
|
Rassen-Modifikatoren (Mittelländer, Auelf, Zwerg, Ork, Achaz, …).
|
||||||
|
|
||||||
|
### Fernkampf-Rechner (`/characters/:id/combat/ranged`)
|
||||||
|
- Aktuell sechs Bögen modelliert: Elfen-, Komposit-, Krieg-, Kurz-, Lang-,
|
||||||
|
Ork. Reiterbogen – inkl. Reichweiten, Bonusschaden je Distanz, Nachladezeit
|
||||||
|
und KK-Anforderung.
|
||||||
|
- Berechnet die Zielzahl für 1W20 aus **FK-Basis + (wirksamem) TaW** und allen
|
||||||
|
ausgewählten Erschwernissen:
|
||||||
|
- Entfernung (sehr nah … extrem weit),
|
||||||
|
- Zielgröße, Sichtverhältnisse, Bewegung des Ziels,
|
||||||
|
- Trefferzone (Zwei- oder Vierbeiner),
|
||||||
|
- Schussart / Nachladezustand (Pfeil auf Sehne, Bogen gespannt, gezielter Schuss, Schuss mit Ansage, Schnellschuss, Schnellladen),
|
||||||
|
- Zielen über mehrere Kampfrunden,
|
||||||
|
- Wind, Steilschuss nach oben/unten,
|
||||||
|
- aktive Vorteile/Nachteile/SF (z. B. *Entfernungssinn*, *Talentspezialisierung Bogen*),
|
||||||
|
- **Scharf-/Meisterschütze** modifiziert automatisch viele Werte (z. B. Trefferzonen, Schnellschuss, Schuss mit Ansage).
|
||||||
|
- Liefert pro Berechnung eine Aufschlüsselung aller Modifikatoren und einen
|
||||||
|
Schadenshinweis inkl. Entfernungs-Bonus.
|
||||||
|
|
||||||
|
### Nahkampf-Rechner (`/characters/:id/combat/melee`)
|
||||||
|
- Liest AT/PA aus den Kampftalenten und addiert die Waffen-Modifikatoren der
|
||||||
|
gewählten Nahkampfwaffe.
|
||||||
|
- Erschwernisse als kommaseparierte Liste plus eigene AT-/PA-Zusätze.
|
||||||
|
- Optional vereinfachte Ansagen-Verteilung (AT-Anteil auf PA).
|
||||||
|
- Manöver-Details (Wuchtschlag, Finte, Ausweichen …) bleiben bewusst generisch
|
||||||
|
– am Tisch beziffern und eintragen.
|
||||||
|
|
||||||
|
### Magie (`/characters/:id/spells`)
|
||||||
|
- Liste eigener Zauber (Name, Stufe, ZfP-Notiz, Notiz) wird im Charakter
|
||||||
|
gespeichert.
|
||||||
|
- Generische 3W20-Zielzahlen-Anzeige für eine wählbare Eigenschafts-Triade
|
||||||
|
(Default MU/KL/CH) + ZfW.
|
||||||
|
- Optionale Liturgien-Freitextliste.
|
||||||
|
|
||||||
|
### Einstellungen & Hilfen
|
||||||
|
- `/settings`: Status des Helden-Software-Imports (noch nicht implementiert)
|
||||||
|
und ein kleiner 1W20/3W20-Würfelhelfer.
|
||||||
|
- Online-/Offline-Anzeige in der Topbar, optionaler „Installieren“-Button im
|
||||||
|
Browser, Service-Worker-Update über `@vite-pwa/sveltekit`.
|
||||||
|
|
||||||
|
## Technik
|
||||||
|
|
||||||
|
- **SvelteKit 2** + **Svelte 5** als SPA (`@sveltejs/adapter-static`,
|
||||||
|
`fallback: 'index.html'`).
|
||||||
|
- **TypeScript** (strict).
|
||||||
|
- **Vite 6** + **`@vite-pwa/sveltekit`** (Service Worker, Web App Manifest).
|
||||||
|
- **Dexie** für IndexedDB (Charaktere und Meta-Tabelle für aktiven Helden).
|
||||||
|
- **Zod** für Schema-Definition & -Validierung.
|
||||||
|
- **js-yaml** für YAML-Export/-Import.
|
||||||
|
- **Vitest** + **fake-indexeddb** für Tests.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
routes/ # SvelteKit-Routen (SPA)
|
||||||
|
+layout.svelte # Topbar, PWA-Registrierung, Online-Status
|
||||||
|
+page.svelte # Heldenübersicht (Anlegen/Import/Export)
|
||||||
|
settings/+page.svelte # Einstellungen + Würfelhelfer
|
||||||
|
characters/[id]/
|
||||||
|
+page.svelte # Übersicht mit Basiswerten + Subnavigation
|
||||||
|
sheet/+page.svelte # Charakterbogen (Editor)
|
||||||
|
combat/ranged/+page.svelte
|
||||||
|
combat/melee/+page.svelte
|
||||||
|
spells/+page.svelte
|
||||||
|
lib/
|
||||||
|
characters/ # Default-Charakter-Factory
|
||||||
|
components/ # Shared Svelte-Komponenten (DiceRoller)
|
||||||
|
engine/ # Reine Berechnungen (derived, ranged, melee, spell, …)
|
||||||
|
rules/ # DSA-4.1-Regeltabellen (Rassen, Talente, Waffen, SF, …)
|
||||||
|
schema/ # Zod-Schemata + Schema-Versionierung
|
||||||
|
storage/ # Dexie-DB, Repository, JSON-/YAML-IO
|
||||||
|
import/ # Reservierter Helden-Software-Importer
|
||||||
|
tests/engine/ # Unit-Tests (Vitest)
|
||||||
|
static/ # Favicon, Manifest-Icons
|
||||||
|
scripts/ # Hilfsskripte (Placeholder-Icons)
|
||||||
|
```
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run icons # Platzhalter-PNGs (optional ersetzen)
|
npm run icons # Platzhalter-PNGs (für Produktion ersetzen)
|
||||||
npm run dev
|
npm run dev # Vite-Dev-Server
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tests & Build
|
## Tests, Lint & Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test # Vitest (einmalig)
|
||||||
npm run build
|
npm run test:watch # Vitest im Watch-Mode
|
||||||
npm run preview
|
npm run check # svelte-check + TypeScript
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm run format # Prettier
|
||||||
|
npm run build # Produktions-Build (statisch)
|
||||||
|
npm run preview # Build lokal servieren
|
||||||
```
|
```
|
||||||
|
|
||||||
## Technik
|
## Datenmodell (Kurz)
|
||||||
|
|
||||||
- SvelteKit (SPA, `adapter-static` + `fallback: 'index.html'`)
|
Der `Character` (siehe `src/lib/schema/character.ts`) enthält u. a.:
|
||||||
- TypeScript, Zod, Dexie, js-yaml
|
|
||||||
- `@vite-pwa/sveltekit` (Service Worker, Manifest)
|
|
||||||
|
|
||||||
## Hinweise
|
- `meta` (Name, Rasse, Kultur, Profession, optional Geschlecht/GP),
|
||||||
|
- `eigenschaften` (alle 8 Werte mit `startwert` + `mod`),
|
||||||
|
- `energien` (`lep`, `aup`, `mr`, optional `asp`, `kap`),
|
||||||
|
- `advantages`, `disadvantages`, `abilities` (jeweils mit `defId` auf den
|
||||||
|
Regeleintrag),
|
||||||
|
- `talente` / `kampftalente`,
|
||||||
|
- `zauber`, `liturgien` (optional),
|
||||||
|
- `waffen.fernkampf` + `waffen.nahkampf` als Instanzen,
|
||||||
|
- `inventar`, `notizen`,
|
||||||
|
- `schemaVersion` (aktuell `1`).
|
||||||
|
|
||||||
- Regeln/Fernkampf-Tabellen aus dem früheren `rules.js` liegen unter `src/lib/rules/`.
|
Persistenz läuft ausschließlich über `src/lib/storage/repo.ts` – Komponenten
|
||||||
- Helden-Software-Import: Platzhalter in `src/lib/import/helden.ts`.
|
greifen nicht direkt auf die Dexie-Datenbank zu.
|
||||||
- Icons: `npm run icons` erzeugt minimale PNGs; für Produktion eigene 192/512/maskable-Grafiken einsetzen.
|
|
||||||
|
## Stand & bekannte Einschränkungen
|
||||||
|
|
||||||
|
- **Helden-Software-Import** ist noch ein Platzhalter
|
||||||
|
(`src/lib/import/helden.ts`). JSON-/YAML-Import funktioniert.
|
||||||
|
- **Spell-Engine** ist bewusst minimal: 3W20-Zielzahlen für eine generische
|
||||||
|
Eigenschafts-Triade. Verteilungen, Modifikatoren, Reichweite/Dauer-Berechnung
|
||||||
|
noch nicht modelliert.
|
||||||
|
- **Nahkampf** liefert AT-/PA-Zielzahlen, kennt aber keine konkreten Manöver –
|
||||||
|
Wuchtschlag, Finte usw. als Zahlen eingeben.
|
||||||
|
- Regeltabellen sind erweiterbar, aber nicht vollständig (z. B. Vorteile,
|
||||||
|
SF, Nahkampfwaffen).
|
||||||
|
- Nur eine Schema-Version (`1`); zukünftige Migrationen über
|
||||||
|
`migrateCharacter` in `src/lib/schema/version.ts`.
|
||||||
|
- Symbole unter `static/icons/` sind Platzhalter (`npm run icons`). Für eine
|
||||||
|
Produktivversion eigene 192/512/maskable-PNGs hinterlegen.
|
||||||
|
|
||||||
|
## Hinweise für Mitwirkende
|
||||||
|
|
||||||
|
- Domain-Vokabular ist deutsch (`Eigenschaften`, `Talente`, `Sonderfertigkeiten`,
|
||||||
|
`Vorteile`, `Nachteile`, `MU/KL/IN/CH/FF/GE/KO/KK`) und sollte so bleiben –
|
||||||
|
bestehende Daten und Tests hängen daran.
|
||||||
|
- Architekturhinweise und Konventionen stehen in [`AGENTS.md`](./AGENTS.md).
|
||||||
|
|||||||
+38
-28
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,27 +66,24 @@
|
|||||||
char &&
|
char &&
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
return computeRangedTarget(
|
return computeRangedTarget(char, weaponId, {
|
||||||
char,
|
range,
|
||||||
weaponId,
|
targetSize,
|
||||||
{
|
visibility,
|
||||||
range,
|
movement,
|
||||||
targetSize,
|
hitZone: hitZone === '' ? undefined : hitZone,
|
||||||
visibility,
|
reloadState: reloadState || undefined,
|
||||||
movement,
|
extras: [...extras],
|
||||||
hitZone: hitZone === '' ? undefined : hitZone,
|
aimTurns,
|
||||||
reloadState: reloadState || undefined,
|
targetBuild
|
||||||
extras: [...extras],
|
});
|
||||||
aimTurns,
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user