Compare commits

..

5 Commits

Author SHA1 Message Date
fgraepel 260b7b0529 update readme 2026-05-16 21:51:04 +02:00
fgraepel 97ba4bf478 refine ranged combat 2026-05-16 21:35:35 +02:00
fgraepel 866c0a9f2d add agents.md 2026-05-16 18:59:34 +02:00
fgraepel a281592541 refine abilities and stuff 2026-05-16 18:58:05 +02:00
fgraepel b0a4bd554e rework races 2026-05-16 18:35:55 +02:00
15 changed files with 795 additions and 157 deletions
+118
View File
@@ -0,0 +1,118 @@
# AGENTS.md
Guidance for AI agents working in this repo. Read this before making changes.
## Project at a Glance
- **What it is:** An offline-first PWA helper for the German tabletop RPG _Das Schwarze Auge 4.1_ (DSA 4.1). Covers character sheet, melee/ranged combat, spells, and import/export.
- **Stack:** SvelteKit 2 (SPA via `adapter-static`) + Svelte 5 + TypeScript (strict) + Vite 6.
- **Persistence:** IndexedDB via Dexie (`src/lib/storage/db.ts`); meta key/value table tracks the active character.
- **Validation:** Zod schemas in `src/lib/schema/`.
- **PWA:** `@vite-pwa/sveltekit` with autoUpdate; manifest in `vite.config.ts`.
- **Language:** UI, copy, identifiers, and most code comments are **German**. Preserve German wording (`Eigenschaften`, `Talente`, `Sonderfertigkeiten`, `Vorteile`, `Nachteile`, attribute keys like `MU/KL/IN/CH/FF/GE/KO/KK`, …) don't translate domain terms.
## Commands
| Task | Command |
| ----------------- | ---------------- |
| Install deps | `npm install` |
| Dev server | `npm run dev` |
| Production build | `npm run build` |
| Preview build | `npm run preview`|
| Type / svelte check | `npm run check` |
| Lint | `npm run lint` |
| Format | `npm run format` |
| Run tests (CI) | `npm test` |
| Watch tests | `npm run test:watch` |
| Regenerate icons | `npm run icons` |
After substantive edits, run `npm run check` and `npm test` before declaring done.
## Repository Layout
```
src/
app.css, app.html, app.d.ts
lib/
characters/ # Character defaults / factories (newCharacter)
components/ # Shared Svelte components (e.g. DiceRoller)
engine/ # Pure derivation: AT/PA/FK basis, LeP, melee, ranged, probe, talent-check, spell
import/ # Helden-Software importer (placeholder)
rules/ # DSA 4.1 rule tables: attributes, races, talents, weapons, SFs, Vor-/Nachteile
schema/ # Zod schemas + schema version / migration
storage/ # Dexie DB, repo (CRUD), io (JSON/YAML export/import)
routes/
+layout.{svelte,ts}, +page.svelte
characters/[id]/{sheet,combat/melee,combat/ranged,spells}/+page.svelte
settings/+page.svelte
tests/
setup.ts # vitest setup (uses fake-indexeddb)
engine/ # Unit tests for derivation, ranged, io
scripts/
write-placeholder-icons.mjs # runs on postinstall + `npm run icons`
static/
favicon.svg, icons/
```
Use SvelteKit aliases: `$lib/...` (resolves to `src/lib`) and `$app/...` for kit internals. Don't introduce relative `../../lib` imports.
## Conventions
### Formatting & Linting
- Prettier: **tabs**, single quotes, no trailing commas, `printWidth: 100`. `*.svelte` uses the Svelte parser via `prettier-plugin-svelte`.
- ESLint flat config (`eslint.config.js`) extends `@eslint/js`, `typescript-eslint`, and `eslint-plugin-svelte`. `build/`, `.svelte-kit/`, and `node_modules/` are ignored.
- Run `npm run format` and `npm run lint` before finishing a change.
### TypeScript
- `strict: true` in `tsconfig.json` (extends `.svelte-kit/tsconfig.json`). `allowJs` and `checkJs` are on stray `.js` files are type-checked too.
- Prefer `import type { ... }` for type-only imports.
- No `any`. Use Zod inference (`z.infer<typeof schema>`) for domain types; the canonical `Character` type lives in `src/lib/schema/character.ts`.
### Svelte 5
- Components are Svelte 5. Use the new runes where appropriate (`$state`, `$derived`, `$effect`, `$props`); don't reintroduce legacy `export let` style for new code.
- `svelte.config.js` filters the `a11y_label_has_associated_control` warning and the ESLint config relaxes it because fields use visual labels. Keep that pattern don't add `for`/`id` plumbing just to silence warnings.
### Domain Rules (read carefully before touching `engine/` or `rules/`)
- Derived values follow DSA 4.1 formulas in `src/lib/engine/derived.ts`. They are pure functions of `Character`; don't mutate the input.
- Attribute access goes through `effectiveAttr(char, key)` (= `startwert + mod`). Don't read raw values directly when computing derived stats.
- Race modifiers come from `getRaceDef(char.meta.rasse)` (`src/lib/rules/races.ts`). New races must provide `lep_mod`, `asp_mod`, `aup_mod`, `mr_mod` so the engine keeps working.
- Sonderfertigkeiten / Vor- / Nachteile each have a typed table in `src/lib/rules/*.ts` and a getter (`getSonderfertigkeit`, …). Add new entries to the table; don't hard-code IDs in engine code.
### Schema & Migrations
- `CURRENT_SCHEMA_VERSION` lives in `src/lib/schema/version.ts`. Every persisted `Character` carries `schemaVersion` (a `z.literal`).
- When you change the character schema in a breaking way:
1. Bump `CURRENT_SCHEMA_VERSION`.
2. Update `migrateCharacter` in `src/lib/schema/version.ts` to migrate old payloads forward.
3. Update / add tests in `tests/engine/io.test.ts` (or a new test) covering the migration path.
- Importers (`src/lib/storage/io.ts`, `src/lib/import/helden.ts`) must run input through `migrateCharacter` before `parseCharacter`.
### Storage
- All DB access goes through `src/lib/storage/repo.ts`. Don't `import { db }` from components use `listCharacters`, `getCharacter`, `saveCharacter`, `deleteCharacter`, and the active-character helpers.
- The Dexie schema is in `src/lib/storage/db.ts`. Adding a new store or index requires bumping the Dexie `version(N)` and providing an upgrade function.
## Testing
- Test runner: Vitest (`npm test` / `npm run test:watch`). Config lives in `vite.config.ts` (`test.include` + `tests/setup.ts`).
- `tests/setup.ts` wires up `fake-indexeddb` so Dexie works in Node. Don't import real browser globals in tests.
- Add tests next to existing ones under `tests/engine/` for any new derivation, rules logic, or schema migration. Keep them deterministic (no real time/random unless seeded).
- When changing `engine/derived.ts`, re-check `tests/engine/derived.test.ts` because it pins the exact DSA 4.1 rounding (`Math.round` for AT/PA/FK/INI/MR, `Math.floor` for LeP base).
## Build & PWA Notes
- SPA mode: `adapter-static` with `fallback: 'index.html'` and `strict: false`. Don't introduce server-only endpoints (`+server.ts`, `+page.server.ts`) the app must build as a static bundle.
- Workbox precaches `client/**/*.{js,css,ico,png,svg,webp,woff,woff2}`. If you add new asset types that should be cached offline, extend `workbox.globPatterns` in `vite.config.ts`.
- `postinstall` runs `scripts/write-placeholder-icons.mjs` to generate placeholder PNGs. Real production icons should replace `static/icons/icon-192.png`, `icon-512.png`, and `maskable-512.png`.
## Things to Avoid
- Don't add server-rendered routes, server hooks, or anything that breaks `adapter-static`.
- Don't bypass Zod every external payload (import, future API) must go through `parseCharacter` (after migration).
- Don't hardcode rule data inside components; put it in `src/lib/rules/` so it can be reused and tested.
- Don't switch indentation, quote style, or trailing-comma policy Prettier owns formatting.
- Don't translate German domain vocabulary or rename attribute keys; existing data and tests depend on them.
+158 -15
View File
@@ -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).
+4 -4
View File
@@ -24,7 +24,7 @@ export function newCharacter(partial?: { name?: string; id?: string }): Characte
schemaVersion: CURRENT_SCHEMA_VERSION, schemaVersion: CURRENT_SCHEMA_VERSION,
meta: { meta: {
name, name,
rasse: 'Mensch', rasse: 'mensch01',
kultur: '', kultur: '',
profession: '' profession: ''
}, },
@@ -34,9 +34,9 @@ export function newCharacter(partial?: { name?: string; id?: string }): Characte
aup: { mod: 0 }, aup: { mod: 0 },
mr: { mod: 0 } mr: { mod: 0 }
}, },
vorteile: [], advantages: [],
nachteile: [], disadvantages: [],
sonderfertigkeiten: [], abilities: [],
talente: [], talente: [],
kampftalente: [], kampftalente: [],
inventar: [], inventar: [],
+43 -30
View File
@@ -1,68 +1,81 @@
import type { AttributeKey } from '$lib/rules/attributes'; import type { AttributeKey } from '$lib/rules/attributes';
import type { Character } from '$lib/schema/character'; import type { Character } from '$lib/schema/character';
import { RACE_ASP_MOD, RACE_LEP_MOD } from '$lib/rules/races'; import { getRaceDef } from '$lib/rules/races';
export function effectiveAttr(char: Character, key: AttributeKey): number { export function effectiveAttr(char: Character, key: AttributeKey): number {
const a = char.eigenschaften[key]; const a = char.eigenschaften[key];
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 */ /** 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 v = const mu = effectiveAttr(char, 'MU');
(effectiveAttr(char, 'MU') + effectiveAttr(char, 'KL') + effectiveAttr(char, 'KO')) / 5 + const kl = effectiveAttr(char, 'KL');
char.energien.mr.mod; const ko = effectiveAttr(char, 'KO');
return Math.round(v); 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 { 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 = RACE_LEP_MOD[char.meta.rasse] ?? 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 race = RACE_ASP_MOD[char.meta.rasse] ?? 0; const mu = effectiveAttr(char, 'MU');
const base = effectiveAttr(char, 'IN') + effectiveAttr(char, 'IN') + effectiveAttr(char, 'CH'); 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;
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 base = effectiveAttr(char, 'MU') + effectiveAttr(char, 'MU') + effectiveAttr(char, 'IN'); const mu = effectiveAttr(char, 'MU');
return Math.max(0, base + char.energien.aup.mod); 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;
return Math.max(0, base + race + char.energien.aup.mod);
} }
export type DerivedSheet = { export type DerivedSheet = {
+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');
+109
View File
@@ -0,0 +1,109 @@
import type { RangedWeaponId } from '$lib/rules/weapons-ranged';
/** Abilities (Sonderfertigkeiten, extensible); modifiers for melee and ranged combat */
export type AbilityDef = {
id: string;
name: string;
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[] = [
{
id: 'evade_1',
name: 'Ausweichen I',
at_mod: 0,
pa_mod: 0,
fk_mod: 0
},
{
id: 'evade_2',
name: 'Ausweichen II',
at_mod: 0,
pa_mod: 0,
fk_mod: 0
},
{
id: 'evade_3',
name: 'Ausweichen III',
at_mod: 0,
pa_mod: 0,
fk_mod: 0
},
{
id: 'sniper',
name: 'Scharfschütze',
at_mod: 0,
pa_mod: 0,
fk_mod: 0
},
{
id: 'marksman',
name: 'Meisterschütze',
at_mod: 0,
pa_mod: 0,
fk_mod: 0
},
{
id: 'fast_reload',
name: 'Schnellladen',
at_mod: 0,
pa_mod: 0,
fk_mod: 0
},
{
id: 'specialize_elven_bow',
name: 'Talentspezialisierung Bogen (Elfenbogen)',
at_mod: 0,
pa_mod: 0,
fk_mod: 2,
weapon_type: 'elven_bow'
},
{
id: 'specialize_composite_bow',
name: 'Talentspezialisierung Bogen (Kompositbogen)',
at_mod: 0,
pa_mod: 0,
fk_mod: 2,
weapon_type: 'composite_bow'
},
{
id: 'specialize_warbow',
name: 'Talentspezialisierung Bogen (Kriegsbogen)',
at_mod: 0,
pa_mod: 0,
fk_mod: 2,
weapon_type: 'warbow'
},
{
id: 'specialize_shortbow',
name: 'Talentspezialisierung Bogen (Kurzbogen)',
at_mod: 0,
pa_mod: 0,
fk_mod: 2,
weapon_type: 'shortbow'
},
{
id: 'specialize_longbow',
name: 'Talentspezialisierung Bogen (Langbogen)',
at_mod: 0,
pa_mod: 0,
fk_mod: 2,
weapon_type: 'longbow'
},
{
id: 'specialize_orc_bow',
name: 'Talentspezialisierung Bogen (Ork. Reiterbogen)',
at_mod: 0,
pa_mod: 0,
fk_mod: 2,
weapon_type: 'orc_bow'
}
];
export function getAbility(id: string): AbilityDef | undefined {
return ABILITIES.find((s) => s.id === id);
}
+45
View File
@@ -0,0 +1,45 @@
/** Advantage definitions (extensible); modifiers for melee and ranged combat */
export type AdvantageDef = {
id: string;
name: string;
has_levels: boolean;
/** z. B. [1, 2, 3], wenn has_levels sonst leer */
levels: number[];
at_mod: number;
pa_mod: number;
fk_mod: number;
};
export const ADVANTAGES: AdvantageDef[] = [
{
id: 'entfernungssinn',
name: 'Entfernungssinn',
has_levels: false,
levels: [],
at_mod: 0,
pa_mod: 0,
fk_mod: 2
},
{
id: 'innerer_kompass',
name: 'Innerer Kompass',
has_levels: false,
levels: [],
at_mod: 0,
pa_mod: 0,
fk_mod: 0
},
{
id: 'kampfreflexe',
name: 'Kampfreflexe',
has_levels: true,
levels: [1, 2, 3],
at_mod: 1,
pa_mod: 0,
fk_mod: 0
}
];
export function getAdvantage(id: string): AdvantageDef | undefined {
return ADVANTAGES.find((v) => v.id === id);
}
+26
View File
@@ -0,0 +1,26 @@
/** Disadvantage definitions (extensible); modifiers for melee and ranged combat */
export type DisadvantageDef = {
id: string;
name: string;
has_levels: boolean;
levels: number[];
at_mod: number;
pa_mod: number;
fk_mod: number;
};
export const DISADVANTAGES: DisadvantageDef[] = [
{
id: 'einaeuig',
name: 'Einäugig',
has_levels: false,
levels: [],
at_mod: 0,
pa_mod: 0,
fk_mod: -2
}
];
export function getDisadvantage(id: string): DisadvantageDef | undefined {
return DISADVANTAGES.find((n) => n.id === id);
}
+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';
} }
+44 -24
View File
@@ -1,26 +1,46 @@
/** LeP-Modifikator je Rasse (typische Werte DSA 4.1, vereinfacht) */ /** Rassen mit LeP-/AuP-/AsP-/MR-Modifikatoren (typische Werte DSA 4.1, vereinfacht) */
export const RACE_LEP_MOD: Record<string, number> = { export type RaceDef = {
Mensch: 5, id: string;
Elf: 2, name: string;
Halbelf: 4, lep_mod: number;
Zwerg: 8, aup_mod: number;
Halbling: 0, asp_mod: number;
Gnom: -4, mr_mod: number;
Halbork: 6,
Ork: 8,
Goblin: -5,
'': 0
}; };
export const RACE_ASP_MOD: Record<string, number> = { export const RACES: RaceDef[] = [
Mensch: 0, { id: 'mensch01', name: 'Mittelländer', lep_mod: 10, aup_mod: 10, asp_mod: 0, mr_mod: -4 },
Elf: 12, { id: 'mensch02', name: 'Tulamide', lep_mod: 10, aup_mod: 10, asp_mod: 0, mr_mod: -4 },
Halbelf: 6, { id: 'mensch03', name: 'Thorwaler', lep_mod: 11, aup_mod: 10, asp_mod: 0, mr_mod: -5 },
Zwerg: 0, { id: 'mensch04', name: 'Nivese', lep_mod: 9, aup_mod: 12, asp_mod: 0, mr_mod: -5 },
Halbling: 0, { id: 'mensch05', name: 'Norbarde', lep_mod: 11, aup_mod: 10, asp_mod: 0, mr_mod: -4 },
Gnom: 0, { id: 'mensch06', name: 'Trollzacker', lep_mod: 11, aup_mod: 18, asp_mod: 0, mr_mod: -5 },
Halbork: 0, { id: 'mensch07', name: 'Rochshaz', lep_mod: 12, aup_mod: 20, asp_mod: 0, mr_mod: -5 },
Ork: 0, { id: 'mensch08', name: 'Waldmensch', lep_mod: 8, aup_mod: 12, asp_mod: 0, mr_mod: -6 },
Goblin: 0, { id: 'mensch09', name: 'Tocamuyac', lep_mod: 8, aup_mod: 12, asp_mod: 0, mr_mod: -6 },
'': 0 { id: 'mensch10', name: 'Utulu', lep_mod: 11, aup_mod: 12, asp_mod: 0, mr_mod: -6 },
}; { id: 'plainelf', name: 'Auelf', lep_mod: 6, aup_mod: 12, asp_mod: 12, mr_mod: -2 },
{ id: 'woodelf', name: 'Waldelf', lep_mod: 6, aup_mod: 10, asp_mod: 12, mr_mod: -2 },
{ id: 'firnelf', name: 'Firnelf', lep_mod: 7, aup_mod: 15, asp_mod: 12, mr_mod: -1 },
{ id: 'halbelf', name: 'Halbelf', lep_mod: 8, aup_mod: 10, asp_mod: -6, mr_mod: -4 },
{ id: 'zwerg01', name: 'Zwerg', lep_mod: 11, aup_mod: 15, asp_mod: 0, mr_mod: -4 },
{ id: 'zwerg02', name: 'Brilliantzwerg', lep_mod: 10, aup_mod: 18, asp_mod: 0, mr_mod: -4 },
{ id: 'zwerg03', name: 'Ambosszwerg', lep_mod: 12, aup_mod: 18, asp_mod: 0, mr_mod: -4 },
{ id: 'ork01', name: 'Ork', lep_mod: 12, aup_mod: 18, asp_mod: 0, mr_mod: -7 },
{ id: 'ork02', name: 'Orkfrau', lep_mod: 10, aup_mod: 15, asp_mod: 0, mr_mod: -7 },
{ id: 'halbork', name: 'Halbork', lep_mod: 11, aup_mod: 15, asp_mod: 0, mr_mod: -6 },
{ id: 'goblin', name: 'Goblin', lep_mod: 4, aup_mod: 12, asp_mod: 0, mr_mod: -5 },
{ id: 'achaz01', name: 'Achaz', lep_mod: 8, aup_mod: 7, asp_mod: 0, mr_mod: -2 },
{ id: 'achaz02', name: 'Orkland-Achaz', lep_mod: 15, aup_mod: 7, asp_mod: 0, mr_mod: -2 },
{ id: 'achaz03', name: 'Maraskan-Achaz', lep_mod: 15, aup_mod: 7, asp_mod: 0, mr_mod: -2 }
];
export function getRaceDef(rasse: string): RaceDef | undefined {
if (!rasse) return undefined;
const byId = RACES.find((r) => r.id === rasse);
if (byId) return byId;
const lower = rasse.toLowerCase();
const byIdLower = RACES.find((r) => r.id === lower);
if (byIdLower) return byIdLower;
return RACES.find((r) => r.name === rasse);
}
+9 -5
View File
@@ -14,13 +14,17 @@ const energySchema = z.object({
const traitSchema = z.object({ const traitSchema = z.object({
id: z.string(), id: z.string(),
name: z.string(), /** Referenz auf Eintrag in rules (advantage/disadvantage) */
defId: z.string().default(''),
name: z.string().optional(),
stufe: z.number().int().optional(),
note: z.string().optional() note: z.string().optional()
}); });
const sfSchema = z.object({ const sfSchema = z.object({
id: z.string(), id: z.string(),
name: z.string(), defId: z.string().default(''),
name: z.string().optional(),
note: z.string().optional() note: z.string().optional()
}); });
@@ -108,9 +112,9 @@ export const characterSchema = z.object({
aup: energySchema, aup: energySchema,
mr: energySchema mr: energySchema
}), }),
vorteile: z.array(traitSchema).default([]), advantages: z.array(traitSchema).default([]),
nachteile: z.array(traitSchema).default([]), disadvantages: z.array(traitSchema).default([]),
sonderfertigkeiten: z.array(sfSchema).default([]), abilities: z.array(sfSchema).default([]),
talente: z.array(talentEntrySchema).default([]), talente: z.array(talentEntrySchema).default([]),
kampftalente: z.array(combatTalentSchema).default([]), kampftalente: z.array(combatTalentSchema).default([]),
zauber: z.array(spellEntrySchema).optional(), zauber: z.array(spellEntrySchema).optional(),
@@ -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>
+108 -20
View File
@@ -4,7 +4,11 @@
import type { Character } from '$lib/schema/character'; import type { Character } from '$lib/schema/character';
import { ATTRIBUTE_KEYS, ATTRIBUTE_LABELS } from '$lib/rules/attributes'; import { ATTRIBUTE_KEYS, ATTRIBUTE_LABELS } from '$lib/rules/attributes';
import { getCharacter, saveCharacter } from '$lib/storage/repo'; import { getCharacter, saveCharacter } from '$lib/storage/repo';
import { RACES } from '$lib/rules/races';
import { DISADVANTAGES, getDisadvantage } from '$lib/rules/disadvantages';
import { ABILITIES } from '$lib/rules/abilities';
import { TALENTS } from '$lib/rules/talents'; import { TALENTS } from '$lib/rules/talents';
import { ADVANTAGES, getAdvantage } from '$lib/rules/advantages';
import { computeDerived } from '$lib/engine/derived'; import { computeDerived } from '$lib/engine/derived';
import { atBasis, paBasis } from '$lib/engine/derived'; import { atBasis, paBasis } from '$lib/engine/derived';
@@ -54,21 +58,59 @@
char = char; char = char;
} }
function addVorteil() { function addAdvantage() {
if (!char) return; if (!char) return;
char.vorteile = [...char.vorteile, { id: crypto.randomUUID(), name: '' }]; const first = ADVANTAGES[0];
char.advantages = [
...char.advantages,
{
id: crypto.randomUUID(),
defId: first?.id ?? '',
stufe: first?.has_levels && first.levels[0] !== undefined ? first.levels[0] : undefined
}
];
char = char; char = char;
} }
function addNachteil() { function addDisadvantage() {
if (!char) return; if (!char) return;
char.nachteile = [...char.nachteile, { id: crypto.randomUUID(), name: '' }]; const first = DISADVANTAGES[0];
char.disadvantages = [
...char.disadvantages,
{
id: crypto.randomUUID(),
defId: first?.id ?? '',
stufe: first?.has_levels && first.levels[0] !== undefined ? first.levels[0] : undefined
}
];
char = char; char = char;
} }
function addSF() { function addAbility() {
if (!char) return; if (!char) return;
char.sonderfertigkeiten = [...char.sonderfertigkeiten, { id: crypto.randomUUID(), name: '' }]; const first = ABILITIES[0];
char.abilities = [
...char.abilities,
{ id: crypto.randomUUID(), defId: first?.id ?? '' }
];
char = char;
}
function onAdvantageDefChange(v: Character['advantages'][number], defId: string) {
if (!char) return;
v.defId = defId;
const d = getAdvantage(defId);
if (d?.has_levels && d.levels.length) v.stufe = d.levels[0];
else v.stufe = undefined;
char = char;
}
function onDisadvantageDefChange(v: Character['disadvantages'][number], defId: string) {
if (!char) return;
v.defId = defId;
const d = getDisadvantage(defId);
if (d?.has_levels && d.levels.length) v.stufe = d.levels[0];
else v.stufe = undefined;
char = char; char = char;
} }
@@ -116,7 +158,12 @@
</div> </div>
<div class="field"> <div class="field">
<label for="rasse">Rasse</label> <label for="rasse">Rasse</label>
<input id="rasse" bind:value={char.meta.rasse} /> <select id="rasse" bind:value={char.meta.rasse}>
<option value=""></option>
{#each RACES as r}
<option value={r.id}>{r.name}</option>
{/each}
</select>
</div> </div>
<div class="field"> <div class="field">
<label for="kultur">Kultur</label> <label for="kultur">Kultur</label>
@@ -226,7 +273,7 @@
{#each char.talente as tal, i} {#each char.talente as tal, i}
<div class="inline"> <div class="inline">
<select bind:value={tal.id}> <select bind:value={tal.id}>
{#each TALENTS as t} {#each TALENTS.filter((t) => t.category !== 'kampf') as t}
<option value={t.id}>{t.name}</option> <option value={t.id}>{t.name}</option>
{/each} {/each}
</select> </select>
@@ -246,56 +293,97 @@
<section class="card stack"> <section class="card stack">
<h2>Vorteile</h2> <h2>Vorteile</h2>
{#each char.vorteile as v, i} {#each char.advantages as v, i}
{@const vd = getAdvantage(v.defId)}
<div class="inline"> <div class="inline">
<input placeholder="Name" bind:value={v.name} /> <select
value={v.defId}
on:change={(e) =>
onAdvantageDefChange(v, (e.currentTarget as HTMLSelectElement).value)}
>
<option value=""></option>
{#each ADVANTAGES as d}
<option value={d.id}>{d.name}</option>
{/each}
</select>
{#if vd?.has_levels && vd.levels.length}
<label class="sr" for="advantage-stufe-{v.id}">Stufe</label>
<select id="advantage-stufe-{v.id}" class="w-tiny" bind:value={v.stufe}>
{#each vd.levels as lvl}
<option value={lvl}>{lvl}</option>
{/each}
</select>
{/if}
<button <button
type="button" type="button"
class="btn danger" class="btn danger"
on:click={() => { on:click={() => {
char!.vorteile = char!.vorteile.filter((_, j) => j !== i); char!.advantages = char!.advantages.filter((_, j) => j !== i);
char = char; char = char;
}}>✕</button }}>✕</button
> >
</div> </div>
{/each} {/each}
<button type="button" class="btn" on:click={addVorteil}>Vorteil</button> <button type="button" class="btn" on:click={addAdvantage}>Vorteil</button>
</section> </section>
<section class="card stack"> <section class="card stack">
<h2>Nachteile</h2> <h2>Nachteile</h2>
{#each char.nachteile as v, i} {#each char.disadvantages as v, i}
{@const nd = getDisadvantage(v.defId)}
<div class="inline"> <div class="inline">
<input placeholder="Name" bind:value={v.name} /> <select
value={v.defId}
on:change={(e) =>
onDisadvantageDefChange(v, (e.currentTarget as HTMLSelectElement).value)}
>
<option value=""></option>
{#each DISADVANTAGES as d}
<option value={d.id}>{d.name}</option>
{/each}
</select>
{#if nd?.has_levels && nd.levels.length}
<label class="sr" for="disadvantage-stufe-{v.id}">Stufe</label>
<select id="disadvantage-stufe-{v.id}" class="w-tiny" bind:value={v.stufe}>
{#each nd.levels as lvl}
<option value={lvl}>{lvl}</option>
{/each}
</select>
{/if}
<button <button
type="button" type="button"
class="btn danger" class="btn danger"
on:click={() => { on:click={() => {
char!.nachteile = char!.nachteile.filter((_, j) => j !== i); char!.disadvantages = char!.disadvantages.filter((_, j) => j !== i);
char = char; char = char;
}}>✕</button }}>✕</button
> >
</div> </div>
{/each} {/each}
<button type="button" class="btn" on:click={addNachteil}>Nachteil</button> <button type="button" class="btn" on:click={addDisadvantage}>Nachteil</button>
</section> </section>
<section class="card stack"> <section class="card stack">
<h2>Sonderfertigkeiten</h2> <h2>Sonderfertigkeiten</h2>
{#each char.sonderfertigkeiten as s, i} {#each char.abilities as s, i}
<div class="inline"> <div class="inline">
<input placeholder="Name" bind:value={s.name} /> <select bind:value={s.defId}>
<option value=""></option>
{#each ABILITIES as d}
<option value={d.id}>{d.name}</option>
{/each}
</select>
<button <button
type="button" type="button"
class="btn danger" class="btn danger"
on:click={() => { on:click={() => {
char!.sonderfertigkeiten = char!.sonderfertigkeiten.filter((_, j) => j !== i); char!.abilities = char!.abilities.filter((_, j) => j !== i);
char = char; char = char;
}}>✕</button }}>✕</button
> >
</div> </div>
{/each} {/each}
<button type="button" class="btn" on:click={addSF}>SF</button> <button type="button" class="btn" on:click={addAbility}>SF</button>
</section> </section>
<section class="card stack"> <section class="card stack">
+25 -5
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,18 +7,38 @@ 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', () => {
const c = newCharacter({ name: 'H', id: '00000000-0000-4000-8000-000000000002' }); const c = newCharacter({ name: 'H', id: '00000000-0000-4000-8000-000000000002' });
c.meta.rasse = 'Mensch'; c.meta.rasse = 'Mittelländer';
const ko = 12; const ko = 12;
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 + 5 + 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', () => {
+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);
});
}); });