version 0.0.1

This commit is contained in:
2026-05-11 22:07:17 +02:00
parent fd160cc13b
commit 5869b87336
53 changed files with 11810 additions and 80 deletions
+10
View File
@@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.ts.timestamp-*
coverage
+8
View File
@@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+31
View File
@@ -0,0 +1,31 @@
# 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.
## Entwicklung
```bash
npm install
npm run icons # Platzhalter-PNGs (optional ersetzen)
npm run dev
```
## Tests & Build
```bash
npm test
npm run build
npm run preview
```
## Technik
- SvelteKit (SPA, `adapter-static` + `fallback: 'index.html'`)
- TypeScript, Zod, Dexie, js-yaml
- `@vite-pwa/sveltekit` (Service Worker, Manifest)
## Hinweise
- Regeln/Fernkampf-Tabellen aus dem früheren `rules.js` liegen unter `src/lib/rules/`.
- Helden-Software-Import: Platzhalter in `src/lib/import/helden.ts`.
- Icons: `npm run icons` erzeugt minimale PNGs; für Produktion eigene 192/512/maskable-Grafiken einsetzen.
+34
View File
@@ -0,0 +1,34 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
...svelte.configs['flat/recommended'],
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts'],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
extraFileExtensions: ['.svelte']
}
}
},
{
files: ['**/*.svelte'],
rules: {
// a11y label/controls: Felder nutzen visuelle Labels; Compiler-Warnungen sind hier akzeptiert
'svelte/valid-compile': ['error', { ignoreWarnings: true }]
}
},
{
ignores: ['build/', '.svelte-kit/', 'node_modules/']
}
);
+8557
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "dsa-ranger",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"postinstall": "node scripts/write-placeholder-icons.mjs",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"lint": "eslint .",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"icons": "node scripts/write-placeholder-icons.mjs"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.5",
"typescript-eslint": "^8.19.1",
"@vite-pwa/sveltekit": "^1.1.0",
"eslint": "^9.17.0",
"eslint-plugin-svelte": "^2.46.1",
"fake-indexeddb": "^6.0.0",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"svelte": "^5.16.0",
"svelte-check": "^4.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vitest": "^3.0.2"
},
"dependencies": {
"dexie": "^4.0.10",
"js-yaml": "^4.1.0",
"zod": "^3.24.1"
}
}
-80
View File
@@ -1,80 +0,0 @@
export const WEAPONS = [
{ id: 'elven_bow', name: 'Elfenbogen', skill: 'archery', damage: '1W6+5', bonus_damage: '3/2/1/1/0', ranges: '10/25/50/100/200', reload: 3 },
{ id: 'composite_bow', name: 'Kompositbogen', skill: 'archery', damage: '1W6+5', bonus_damage: '2/1/1/0/0', ranges: '10/20/35/50/80', reload: 3, strengh: 15, strengh_modifier: -2 },
{ id: 'warbow', name: 'Kriegsbogen', skill: 'archery', damage: '1W6+7', bonus_damage: '3/2/1/0/0', ranges: '10/20/40/80/150', reload: 4, strengh: 16, strengh_modifier: -2 },
{ id: 'shortbow', name: 'Kurzbogen', skill: 'archery', damage: '1W6+4', bonus_damage: '1/1/0/0/-1', ranges: '5/15/25/40/60', reload: 2 },
{ id: 'longbow', name: 'Langbogen', skill: 'archery', damage: '1W6+6', bonus_damage: '3/2/1/0/-1', ranges: '10/25/50/100/200', reload: 4, strengh: 15, strengh_modifier: -2 },
{ id: 'orc_bow', name: 'Ork. Reiterbogen', skill: 'archery', damage: '1W6+5', bonus_damage: '3/1/0/-1/-2', ranges: '5/15/30/60/100', reload: 3, strengh: 15, strengh_modifier: -2 }
];
export const RANGES = [
{ id: 'very_near', name: 'sehr nah', modifier: -2 },
{ id: 'near', name: 'nah', modifier: 0 },
{ id: 'medium', name: 'mittel', modifier: 4 },
{ id: 'far', name: 'weit', modifier: 8 },
{ id: 'very_far', name: 'extrem weit', modifier: 12 }
];
export const TARGET_SIZES = [
{ id: 'very_large', name: 'sehr groß', description: 'Scheunentor, Drache, Elefant, Riese', modifier: -2 },
{ id: 'large', name: 'groß', description: 'Pferd, Steppenrind, Oger, Troll', modifier: 0 },
{ id: 'medium', name: 'mittel', description: 'Mensch, Elf, Ork, Goblin, Zwerg', modifier: 2 },
{ id: 'small', name: 'klein', description: 'Wolf, Reh, Schaf', modifier: 4 },
{ id: 'very_small', name: 'sehr klein', description: 'Schlange, Fasan, Katze, Rabe', modifier: 6 },
{ id: 'tiny', name: 'winzig', description: 'Münze, Drachenauge, Maus, Kröte', modifier: 8 }
];
export const VISIBILITY = [
{ id: 'clear', name: 'klar', modifier: 0 },
{ id: 'haze', name: 'Dunst', modifier: 2 },
{ id: 'fog', name: 'Nebel', modifier: 4 },
{ id: 'dusk', name: 'Dämmerung', modifier: 2 },
{ id: 'moonlight', name: 'Mondlicht', modifier: 4 },
{ id: 'starlight', name: 'Sternenlicht', modifier: 6 },
{ id: 'darkness', name: 'Finsternis', modifier: 8 },
{ id: 'invisible', name: 'Unsichtbares Ziel', modifier: 8 }
];
export const TARGET_MOVEMENT = [
{ id: 'stationary', name: 'unbewegliches / fest montiertes Ziel', modifier: -4 },
{ id: 'standing', name: 'stillstehendes Ziel', modifier: -2 },
{ id: 'slow', name: 'leichte Bewegung des Ziels', modifier: 0 },
{ id: 'fast', name: 'schnelle Bewegung des Ziels', modifier: 2 },
{ id: 'very_fast', name: 'sehr schnelle Bewegung des Ziels / Ausweichbewegungen', modifier: 4 },
{ id: 'battle', name: 'Kampfgetümmel', modifier: 2, special: 'true' }
];
export const HIT_ZONES = [
{ id: 'head', name: 'Kopf', modifier: 10, modifier_expert: 7, modifier_master: 5, build: 'biped', random: '19-20' },
{ id: 'torso', name: 'Torso', modifier: 6, modifier_expert: 4, modifier_master: 3, build: 'biped', random: '15-18' },
{ id: 'arms', name: 'Arme', modifier: 10, modifier_expert: 7, modifier_master: 5, build: 'biped', random: '9-14' },
{ id: 'stomach', name: 'Bauch', modifier: 6, modifier_expert: 4, modifier_master: 3, build: 'biped', random: '7-8' },
{ id: 'legs', name: 'Beine', modifier: 8, modifier_expert: 5, modifier_master: 4, build: 'biped', random: '1-6' },
{ id: 'hand_foot', name: 'Hand/Fuß', modifier: 16, modifier_expert: 11, modifier_master: 8, build: 'biped' },
{ id: 'eye_heart', name: 'Auge/Herz', modifier: 20, modifier_expert: 13, modifier_master: 10, build: 'biped' },
{ id: 'qrump', name: 'Rumpf', modifier: 4, modifier_expert: 3, modifier_master: 2, build: 'quadruped', random: '1-8' },
{ id: 'qleg', name: 'Bein', modifier: 10, modifier_expert: 7, modifier_master: 5, build: 'quadruped', random: '9-16' },
{ id: 'weakspot', name: 'verwundbare Stelle', modifier: 12, modifier_expert: 8, modifier_master: 6, build: 'quadruped', random: '1-8' },
{ id: 'qhead', name: 'Kopf', modifier: 16, modifier_expert: 11, modifier_master: 8, build: 'quadruped', random: '17-19' },
{ id: 'qtail', name: 'Schwanz', modifier: 16, modifier_expert: 11, modifier_master: 8, build: 'quadruped', random: '20' },
{ id: 'qsensory_organs', name: 'Sinnesorgane', modifier: 16, modifier_expert: 11, modifier_master: 8, build: 'quadruped', random: '20' }
];
export const OTHER_MODIFIERS = [
{ id: 'steep_shot_down', name: 'Steilschuss nach unten', modifier: 2, skill: 'archery' },
{ id: 'steep_shot_up', name: 'Steilschuss nach oben', modifier: 4, skill: 'archery' },
{ id: 'gusty_crosswind', name: 'böiger Seitenwind', modifier: 4 },
{ id: 'strong_gusty_crosswind', name: 'starker, böiger Seitenwind', modifier: 8 },
{ id: 'fast_shot', name: 'Schnellschuss', modifier: 2, modifier_expert: 1, modifier_master: 0 },
{ id: 'aim', name: 'Zielen', modifier: -0.5, modifier_expert: -1, modifier_master: -1 }
];
export const RELOAD_STATES = [
{ id: 'arrow_nocked', name: 'Pfeil auf Sehne', modifier: -1 },
{ id: 'bow_drawn', name: 'Bogen bereits gespannt', modifier: -2 },
{ id: 'regular_shot', name: 'Normaler Schuss', modifier: 1 },
{ id: 'fast_shot', name: 'Normaler Schuss', modifier: 0 },
{ id: 'aimed_shot', name: 'Gezielter Schuss', modifier: '/2', modifier_expert: '/2 -2', modifier_master: 1 },
{ id: 'announced', name: 'Fernkampfangriff mit Ansage', modifier: '/2', modifier_expert: '/2 -2', modifier_master: 1 },
{ id: 'fast_reload_bow', name: 'Schnellladen', modifier: -1, skill: 'archery' }
];
+17
View File
@@ -0,0 +1,17 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, '..');
const iconsDir = path.join(root, 'static', 'icons');
const minimalPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAKmrrdAAAAABJRU5ErkJggg==',
'base64'
);
fs.mkdirSync(iconsDir, { recursive: true });
for (const name of ['icon-192.png', 'icon-512.png', 'maskable-512.png']) {
fs.writeFileSync(path.join(iconsDir, name), minimalPng);
}
console.log('Wrote placeholder PNG icons to static/icons/');
+160
View File
@@ -0,0 +1,160 @@
:root {
--bg: #12121a;
--surface: #1c1c28;
--surface-2: #252532;
--border: #3a3a4d;
--text: #e8e8ef;
--muted: #9a9ab0;
--accent: #6c9cff;
--accent-dim: #4a6fb8;
--success: #5ecf8a;
--warn: #e8c547;
--danger: #e86c6c;
--radius: 10px;
--font: system-ui, 'Segoe UI', Roboto, Ubuntu, Cantarell, sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.45;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1,
h2,
h3 {
margin: 0 0 0.5rem;
font-weight: 600;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.45rem 0.85rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
}
.btn:hover {
border-color: var(--accent-dim);
background: var(--surface);
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #0b1020;
font-weight: 600;
}
.btn.primary:hover {
filter: brightness(1.05);
}
.btn.danger {
border-color: #8a3d3d;
background: #3a2020;
color: #ffc9c9;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.field label {
font-size: 0.85rem;
color: var(--muted);
}
.field input,
.field select,
.field textarea {
padding: 0.45rem 0.55rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
@media (max-width: 720px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.muted {
color: var(--muted);
font-size: 0.9rem;
}
.big-number {
font-size: 2.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.pill {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--border);
font-size: 0.8rem;
color: var(--muted);
}
.stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#16213e" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
import { atBasis, paBasis } from '$lib/engine/derived';
import { CURRENT_SCHEMA_VERSION } from '$lib/schema/version';
import type { Character } from '$lib/schema/character';
function defaultAttributes(): Character['eigenschaften'] {
return {
MU: { startwert: 11, mod: 0 },
KL: { startwert: 11, mod: 0 },
IN: { startwert: 11, mod: 0 },
CH: { startwert: 11, mod: 0 },
FF: { startwert: 11, mod: 0 },
GE: { startwert: 11, mod: 0 },
KO: { startwert: 11, mod: 0 },
KK: { startwert: 11, mod: 0 }
};
}
export function newCharacter(partial?: { name?: string; id?: string }): Character {
const name = partial?.name?.trim() || 'Neuer Held';
const instanceId = crypto.randomUUID();
const ch: Character = {
id: partial?.id ?? crypto.randomUUID(),
schemaVersion: CURRENT_SCHEMA_VERSION,
meta: {
name,
rasse: 'Mensch',
kultur: '',
profession: ''
},
eigenschaften: defaultAttributes(),
energien: {
lep: { mod: 0 },
aup: { mod: 0 },
mr: { mod: 0 }
},
vorteile: [],
nachteile: [],
sonderfertigkeiten: [],
talente: [],
kampftalente: [],
inventar: [],
waffen: {
fernkampf: [
{
instanceId,
weaponDefId: 'shortbow'
}
],
nahkampf: []
}
};
const ab = atBasis(ch);
const pb = paBasis(ch);
ch.kampftalente = [
{ id: 'bogen', taw: 0, at: ab, pa: pb },
{ id: 'dolche', taw: 0, at: ab, pa: pb }
];
return ch;
}
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
let mode: '1w20' | '3w20' = '1w20';
let last: number[] = [];
function roll1() {
last = [1 + Math.floor(Math.random() * 20)];
}
function roll3() {
last = [1, 2, 3].map(() => 1 + Math.floor(Math.random() * 20));
}
function roll() {
if (mode === '1w20') roll1();
else roll3();
}
</script>
<div class="row">
<select bind:value={mode}>
<option value="1w20">1W20</option>
<option value="3w20">3W20</option>
</select>
<button type="button" class="btn primary" on:click={roll}>Würfeln</button>
</div>
{#if last.length}
<p class="big-number">{last.join(' · ')}</p>
{/if}
+90
View File
@@ -0,0 +1,90 @@
import type { AttributeKey } from '$lib/rules/attributes';
import type { Character } from '$lib/schema/character';
import { RACE_ASP_MOD, RACE_LEP_MOD } from '$lib/rules/races';
export function effectiveAttr(char: Character, key: AttributeKey): number {
const a = char.eigenschaften[key];
return a.startwert + a.mod;
}
/** DSA 4.1: 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);
}
/** PA-Basis = (IN + GE + KK) / 5 */
export function paBasis(char: Character): number {
const v = (effectiveAttr(char, 'IN') + effectiveAttr(char, 'GE') + effectiveAttr(char, 'KK')) / 5;
return Math.round(v);
}
/** FK-Basis = (IN + FF + KK) / 4 */
export function fkBasis(char: Character): number {
const v = (effectiveAttr(char, 'IN') + effectiveAttr(char, 'FF') + effectiveAttr(char, 'KK')) / 4;
return Math.round(v);
}
/** INI-Basis = (MU + MU + IN + GE) / 4 */
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);
}
/** MR-Basis = (MU + KL + KO) / 5 + MR-Mod aus Energien */
export function mrBasis(char: Character): number {
const v =
(effectiveAttr(char, 'MU') + effectiveAttr(char, 'KL') + effectiveAttr(char, 'KO')) / 5 +
char.energien.mr.mod;
return Math.round(v);
}
/** LeP-Maximum: (2*KO + KK) / 2 abrunden + 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 race = RACE_LEP_MOD[char.meta.rasse] ?? 0;
return base + race + char.energien.lep.mod;
}
export function aspMax(char: Character): number {
if (!char.energien.asp) return 0;
const race = RACE_ASP_MOD[char.meta.rasse] ?? 0;
const base = effectiveAttr(char, 'IN') + effectiveAttr(char, 'IN') + effectiveAttr(char, 'CH');
return Math.max(0, base + race + char.energien.asp.mod);
}
export function aupMax(char: Character): number {
const base = effectiveAttr(char, 'MU') + effectiveAttr(char, 'MU') + effectiveAttr(char, 'IN');
return Math.max(0, base + char.energien.aup.mod);
}
export type DerivedSheet = {
atBasis: number;
paBasis: number;
fkBasis: number;
iniBasis: number;
mrBasis: number;
lepMax: number;
aspMax: number;
aupMax: number;
};
export function computeDerived(char: Character): DerivedSheet {
return {
atBasis: atBasis(char),
paBasis: paBasis(char),
fkBasis: fkBasis(char),
iniBasis: iniBasis(char),
mrBasis: mrBasis(char),
lepMax: lepMax(char),
aspMax: aspMax(char),
aupMax: aupMax(char)
};
}
+71
View File
@@ -0,0 +1,71 @@
import type { Character } from '$lib/schema/character';
import { atBasis, paBasis } from '$lib/engine/derived';
export type MeleeSelections = {
/** Manöver / Situation als Liste von Modifikator-Werten (Erschwernis positiv) */
situationModifiers: number[];
/** Zusätzliche AT-Erschwernis (z.B. Wuchtschlag in Stufen) */
atExtra: number;
/** Zusätzliche PA-Erschwernis */
paExtra: number;
/** Ansage: AT-Teil wird auf die PA verteilt (vereinfacht: nur Anzeige, keine Auto-Berechnung) */
ansageAtAnteil: number;
};
export type MeleeResult = {
atBasis: number;
paBasis: number;
talentId: string;
taw: number;
at: number;
pa: number;
atTarget: number;
paTarget: number;
modifierSum: number;
notes: string[];
};
/**
* Nahkampf-Zielwerte: AT/PA aus Bogen + TaW + Waffen-Modifikatoren + gewählte Erschwernisse.
* Manöver-Details (Wuchtschlag/Finte) bleiben bewusst generisch Werte am Tisch eintragen.
*/
export function computeMeleeTargets(
char: Character,
talentId: string,
weaponAtMod: number,
weaponPaMod: number,
selections: MeleeSelections
): MeleeResult {
const kt = char.kampftalente.find((k) => k.id === talentId);
const taw = kt?.taw ?? 0;
const at = (kt?.at ?? atBasis(char) + taw) + weaponAtMod;
const pa = (kt?.pa ?? paBasis(char) + taw) + weaponPaMod;
const modifierSum =
selections.situationModifiers.reduce((a, b) => a + b, 0) +
selections.atExtra +
selections.paExtra;
const atTarget = at - modifierSum - selections.ansageAtAnteil;
const paTarget = pa - modifierSum + selections.ansageAtAnteil;
const notes: string[] = [];
if (selections.ansageAtAnteil > 0) {
notes.push(
`Ansage: ${selections.ansageAtAnteil} AT-Erschwernis gelten zusätzlich als PA-Erschwernis (hier vereinfacht symmetrisch verteilt).`
);
}
return {
atBasis: atBasis(char),
paBasis: paBasis(char),
talentId,
taw,
at,
pa,
atTarget,
paTarget,
modifierSum,
notes
};
}
+8
View File
@@ -0,0 +1,8 @@
import type { AttributeKey } from '$lib/rules/attributes';
import type { Character } from '$lib/schema/character';
import { effectiveAttr } from '$lib/engine/derived';
/** Einfache 1W20-Eigenschaftsprobe: Zielwert = effektiver Attributswert */
export function attributeProbeTarget(char: Character, key: AttributeKey): number {
return effectiveAttr(char, key);
}
+217
View File
@@ -0,0 +1,217 @@
import type { Character } from '$lib/schema/character';
import { effectiveAttr, fkBasis } from '$lib/engine/derived';
import type { RangedWeaponId } from '$lib/rules/weapons-ranged';
import { getRangedWeapon } from '$lib/rules/weapons-ranged';
import type { Expertise } from '$lib/rules/expertise';
import {
HIT_ZONES,
OTHER_MODIFIERS,
RANGES,
RELOAD_STATES,
TARGET_MOVEMENT,
TARGET_SIZES,
VISIBILITY,
isStringModifier,
resolveNumericModifier,
type ExtraModifierId,
type HitZoneId,
type MovementId,
type RangeId,
type ReloadStateId,
type TargetSizeId,
type VisibilityId
} from '$lib/rules/modifiers-ranged';
export type { Expertise } from '$lib/rules/expertise';
export type RangedSelections = {
range: RangeId;
targetSize: TargetSizeId;
visibility: VisibilityId;
movement: MovementId;
hitZone?: HitZoneId;
reloadState?: ReloadStateId;
extras: ExtraModifierId[];
aimTurns: number;
targetBuild: 'biped' | 'quadruped';
};
export type ModifierLine = { id: string; label: string; value: number; note?: string };
export type RangedResult = {
fkBase: number;
weaponTaW: number;
effectiveTaW: number;
baseTarget: number;
modifiers: ModifierLine[];
totalModifier: number;
finalTarget: number;
damageSummary: string;
strengthNote?: string;
reloadNote?: string;
};
function rowById<T extends { id: string }>(rows: T[], id: string): T | undefined {
return rows.find((r) => r.id === id);
}
function parseBonusDamage(bonusDamage: string, rangeIndex: number): number {
const parts = bonusDamage.split('/').map((p) => parseInt(p.trim(), 10));
const v = parts[rangeIndex];
return Number.isFinite(v) ? v : 0;
}
function rangeIndex(id: RangeId): number {
const order: RangeId[] = ['very_near', 'near', 'medium', 'far', 'very_far'];
return order.indexOf(id);
}
function effectiveTaWForReload(
rawTaW: number,
reloadId: ReloadStateId | undefined,
expertise: Expertise
): { effectiveTaW: number; reloadNote?: string } {
if (!reloadId) return { effectiveTaW: rawTaW };
const row = rowById(RELOAD_STATES, reloadId);
if (!row) return { effectiveTaW: rawTaW };
if (reloadId === 'aimed_shot' || reloadId === 'announced') {
if (expertise === 'master' && typeof row.modifier_master === 'number') {
return {
effectiveTaW: rawTaW,
reloadNote: `${row.name}: voller TaW, zusätzliche Erschwernis +${row.modifier_master}`
};
}
if (expertise === 'expert' && row.modifier_expert !== undefined) {
const s = String(row.modifier_expert);
const m = s.match(/^\/2\s*([+-]\d+)?/);
if (m) {
const extra = m[1] ? parseInt(m[1], 10) : 0;
return {
effectiveTaW: Math.floor(rawTaW / 2) + extra,
reloadNote: `${row.name}: TaW/2${extra ? ` ${extra >= 0 ? '+' : ''}${extra}` : ''}`
};
}
}
return {
effectiveTaW: Math.floor(rawTaW / 2),
reloadNote: `${row.name}: TaW/2 (abgerundet)`
};
}
return { effectiveTaW: rawTaW };
}
export function computeRangedTarget(
char: Character,
weaponId: RangedWeaponId,
selections: RangedSelections,
expertise: Expertise
): RangedResult {
const weapon = getRangedWeapon(weaponId);
if (!weapon) {
throw new Error(`Unknown weapon: ${weaponId}`);
}
const fkBase = fkBasis(char);
const kt = char.kampftalente.find((k) => k.id === weapon.skill);
const rawTaW = kt?.taw ?? 0;
const { effectiveTaW, reloadNote } = effectiveTaWForReload(
rawTaW,
selections.reloadState,
expertise
);
const baseTarget = fkBase + effectiveTaW;
const modifiers: ModifierLine[] = [];
const push = (id: string, label: string, value: number, note?: string) => {
if (value !== 0 || note) modifiers.push({ id, label, value, note });
};
let strengthNote: string | undefined;
if (weapon.strengthRequirement !== undefined && weapon.strengthModifier !== undefined) {
const kk = effectiveAttr(char, 'KK');
if (kk < weapon.strengthRequirement) {
const pen = -weapon.strengthModifier;
push('strength', `KK zu niedrig (<${weapon.strengthRequirement})`, pen);
strengthNote = `Erschwernis +${pen} (Regel: Bogen zu stark)`;
}
}
const rangeRow = rowById(RANGES, selections.range);
const sizeRow = rowById(TARGET_SIZES, selections.targetSize);
const visRow = rowById(VISIBILITY, selections.visibility);
const movRow = rowById(TARGET_MOVEMENT, selections.movement);
for (const [row, label] of [
[rangeRow, 'Entfernung'],
[sizeRow, 'Zielgröße'],
[visRow, 'Sicht'],
[movRow, 'Bewegung']
] as const) {
const v = resolveNumericModifier(row, 'none');
if (row && v !== null) push(row.id, `${label}: ${row.name}`, v);
}
if (selections.hitZone) {
const hz = rowById(HIT_ZONES, selections.hitZone);
if (hz && hz.build === selections.targetBuild) {
const v = resolveNumericModifier(hz, expertise);
if (v !== null) push(hz.id, `Trefferzone: ${hz.name}`, v);
}
}
if (selections.reloadState) {
const rRow = rowById(RELOAD_STATES, selections.reloadState);
if (rRow) {
const isAimedOrAnnounced = selections.reloadState === 'aimed_shot' || selections.reloadState === 'announced';
if (isAimedOrAnnounced && expertise === 'master' && typeof rRow.modifier_master === 'number') {
push(rRow.id, `Schussart: ${rRow.name}`, rRow.modifier_master);
} else if (!isAimedOrAnnounced || !isStringModifier(rRow, expertise)) {
const v = resolveNumericModifier(rRow, expertise);
if (v !== null && !(isAimedOrAnnounced && typeof rRow.modifier === 'string')) {
push(rRow.id, `Schussart: ${rRow.name}`, v);
}
}
}
}
for (const ex of selections.extras) {
const row = rowById(OTHER_MODIFIERS, ex);
if (!row) continue;
if (row.skill === 'archery' && weapon.skill !== 'bogen') continue;
const v = resolveNumericModifier(row, expertise);
if (v !== null) push(row.id, row.name, v);
}
if (selections.aimTurns > 0) {
const aimRow = rowById(OTHER_MODIFIERS, 'aim');
const per = resolveNumericModifier(aimRow ?? { id: 'aim', name: 'Zielen', modifier: -0.5 }, expertise);
const v = (typeof per === 'number' ? per : -0.5) * selections.aimTurns;
push('aim', `Zielen (${selections.aimTurns} KR)`, v);
}
const totalModifier = modifiers.reduce((s, m) => s + m.value, 0);
const finalTarget = baseTarget - totalModifier;
const ri = rangeIndex(selections.range);
const bonus = parseBonusDamage(weapon.bonusDamage, ri);
const damageSummary =
bonus === 0 ? `${weapon.damage}` : `${weapon.damage} + ${bonus} (Entfernungs-Bonus)`;
return {
fkBase,
weaponTaW: rawTaW,
effectiveTaW,
baseTarget,
modifiers,
totalModifier,
finalTarget,
damageSummary,
strengthNote,
reloadNote
};
}
+29
View File
@@ -0,0 +1,29 @@
import type { Character } from '$lib/schema/character';
import type { AttributeKey } from '$lib/rules/attributes';
import { effectiveAttr } from '$lib/engine/derived';
export type SpellCastContext = {
zfpTarget?: number;
};
/**
* DSA 4.1 Zauberprobe: 3W20 gegen (ZfW + jeweilige Eigenschaft).
* ZfP*-Verteilung erfolgt am Tisch.
*/
export function spellProbeTargets(
char: Character,
probeAttrs: readonly [AttributeKey, AttributeKey, AttributeKey],
zfw: number
): { targets: [number, number, number]; attrs: AttributeKey[] } {
const attrs = [...probeAttrs] as AttributeKey[];
const targets: [number, number, number] = [
effectiveAttr(char, probeAttrs[0]) + zfw,
effectiveAttr(char, probeAttrs[1]) + zfw,
effectiveAttr(char, probeAttrs[2]) + zfw
];
return { targets, attrs };
}
export function listSpells(char: Character) {
return char.zauber ?? [];
}
+34
View File
@@ -0,0 +1,34 @@
import type { Character } from '$lib/schema/character';
import { getTalent } from '$lib/rules/talents';
import { effectiveAttr } from '$lib/engine/derived';
import type { AttributeKey } from '$lib/rules/attributes';
export type TalentProbeTargets = {
talentId: string;
taw: number;
/** Zielwerte für die drei W20 (in Reihenfolge der Talentprobe) */
targets: [number, number, number];
/** TaW + jeweilige Eigenschaft (Hilfsanzeige) */
details: { attr: AttributeKey; attrValue: number; sum: number }[];
};
/**
* 3W20-Talentprobe: jede einzelne Probe gegen (TaW + effektive Eigenschaft).
* TaP*-Berechnung erfolgt am Tisch; hier nur die Zielzahlen.
*/
export function talentProbeTargets(char: Character, talentId: string, taw: number): TalentProbeTargets {
const def = getTalent(talentId);
const attrs: AttributeKey[] = def?.probe
? ([...def.probe] as AttributeKey[])
: (['MU', 'IN', 'GE'] as const);
const details = attrs.map((attr) => {
const attrValue = effectiveAttr(char, attr);
return { attr, attrValue, sum: taw + attrValue };
});
const targets: [number, number, number] = [
details[0]?.sum ?? taw,
details[1]?.sum ?? taw,
details[2]?.sum ?? taw
];
return { talentId, taw, targets, details };
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Reserviert für zukünftigen Import aus Helden-Software (.helden / XML).
* Schema der Charakterdaten ist in `schema/character.ts` darauf vorbereitet.
*/
export function parseHeldenXml(xml: string): never {
void xml;
throw new Error('Helden-Software-Import ist noch nicht implementiert.');
}
export function isHeldenImportSupported(): boolean {
return false;
}
+14
View File
@@ -0,0 +1,14 @@
export const ATTRIBUTE_KEYS = ['MU', 'KL', 'IN', 'CH', 'FF', 'GE', 'KO', 'KK'] as const;
export type AttributeKey = (typeof ATTRIBUTE_KEYS)[number];
export const ATTRIBUTE_LABELS: Record<AttributeKey, string> = {
MU: 'Mut',
KL: 'Klugheit',
IN: 'Intuition',
CH: 'Charisma',
FF: 'Fingerfertigkeit',
GE: 'Gewandtheit',
KO: 'Konstitution',
KK: 'Körperkraft'
};
+2
View File
@@ -0,0 +1,2 @@
/** Scharfschütze / Meisterschütze (oder vergleichbare SF) für Fernkampf-Modifikatoren */
export type Expertise = 'none' | 'expert' | 'master';
+330
View File
@@ -0,0 +1,330 @@
import type { Expertise } from '$lib/rules/expertise';
export type RangeId = 'very_near' | 'near' | 'medium' | 'far' | 'very_far';
export type TargetSizeId =
| 'very_large'
| 'large'
| 'medium'
| 'small'
| 'very_small'
| 'tiny';
export type VisibilityId =
| 'clear'
| 'haze'
| 'fog'
| 'dusk'
| 'moonlight'
| 'starlight'
| 'darkness'
| 'invisible';
export type MovementId = 'stationary' | 'standing' | 'slow' | 'fast' | 'very_fast' | 'battle';
export type HitZoneId =
| 'head'
| 'torso'
| 'arms'
| 'stomach'
| 'legs'
| 'hand_foot'
| 'eye_heart'
| 'qrump'
| 'qleg'
| 'weakspot'
| 'qhead'
| 'qtail'
| 'qsensory_organs';
export type ExtraModifierId =
| 'steep_shot_down'
| 'steep_shot_up'
| 'gusty_crosswind'
| 'strong_gusty_crosswind'
| 'fast_shot'
| 'aim';
export type ReloadStateId =
| 'arrow_nocked'
| 'bow_drawn'
| 'regular_shot'
| 'fast_shot_bow'
| 'aimed_shot'
| 'announced'
| 'fast_reload_bow';
export type ModifierRow = {
id: string;
name: string;
modifier: number | string;
modifier_expert?: number | string;
modifier_master?: number | string;
description?: string;
skill?: string;
special?: string;
build?: 'biped' | 'quadruped';
random?: string;
};
export const RANGES: ModifierRow[] = [
{ id: 'very_near', name: 'sehr nah', modifier: -2 },
{ id: 'near', name: 'nah', modifier: 0 },
{ id: 'medium', name: 'mittel', modifier: 4 },
{ id: 'far', name: 'weit', modifier: 8 },
{ id: 'very_far', name: 'extrem weit', modifier: 12 }
];
export const TARGET_SIZES: ModifierRow[] = [
{
id: 'very_large',
name: 'sehr groß',
description: 'Scheunentor, Drache, Elefant, Riese',
modifier: -2
},
{
id: 'large',
name: 'groß',
description: 'Pferd, Steppenrind, Oger, Troll',
modifier: 0
},
{
id: 'medium',
name: 'mittel',
description: 'Mensch, Elf, Ork, Goblin, Zwerg',
modifier: 2
},
{
id: 'small',
name: 'klein',
description: 'Wolf, Reh, Schaf',
modifier: 4
},
{
id: 'very_small',
name: 'sehr klein',
description: 'Schlange, Fasan, Katze, Rabe',
modifier: 6
},
{
id: 'tiny',
name: 'winzig',
description: 'Münze, Drachenauge, Maus, Kröte',
modifier: 8
}
];
export const VISIBILITY: ModifierRow[] = [
{ id: 'clear', name: 'klar', modifier: 0 },
{ id: 'haze', name: 'Dunst', modifier: 2 },
{ id: 'fog', name: 'Nebel', modifier: 4 },
{ id: 'dusk', name: 'Dämmerung', modifier: 2 },
{ id: 'moonlight', name: 'Mondlicht', modifier: 4 },
{ id: 'starlight', name: 'Sternenlicht', modifier: 6 },
{ id: 'darkness', name: 'Finsternis', modifier: 8 },
{ id: 'invisible', name: 'Unsichtbares Ziel', modifier: 8 }
];
export const TARGET_MOVEMENT: ModifierRow[] = [
{ id: 'stationary', name: 'unbewegliches / fest montiertes Ziel', modifier: -4 },
{ id: 'standing', name: 'stillstehendes Ziel', modifier: -2 },
{ id: 'slow', name: 'leichte Bewegung des Ziels', modifier: 0 },
{ id: 'fast', name: 'schnelle Bewegung des Ziels', modifier: 2 },
{
id: 'very_fast',
name: 'sehr schnelle Bewegung des Ziels / Ausweichbewegungen',
modifier: 4
},
{ id: 'battle', name: 'Kampfgetümmel', modifier: 2, special: 'true' }
];
export const HIT_ZONES: ModifierRow[] = [
{
id: 'head',
name: 'Kopf',
modifier: 10,
modifier_expert: 7,
modifier_master: 5,
build: 'biped',
random: '19-20'
},
{
id: 'torso',
name: 'Torso',
modifier: 6,
modifier_expert: 4,
modifier_master: 3,
build: 'biped',
random: '15-18'
},
{
id: 'arms',
name: 'Arme',
modifier: 10,
modifier_expert: 7,
modifier_master: 5,
build: 'biped',
random: '9-14'
},
{
id: 'stomach',
name: 'Bauch',
modifier: 6,
modifier_expert: 4,
modifier_master: 3,
build: 'biped',
random: '7-8'
},
{
id: 'legs',
name: 'Beine',
modifier: 8,
modifier_expert: 5,
modifier_master: 4,
build: 'biped',
random: '1-6'
},
{
id: 'hand_foot',
name: 'Hand/Fuß',
modifier: 16,
modifier_expert: 11,
modifier_master: 8,
build: 'biped'
},
{
id: 'eye_heart',
name: 'Auge/Herz',
modifier: 20,
modifier_expert: 13,
modifier_master: 10,
build: 'biped'
},
{
id: 'qrump',
name: 'Rumpf',
modifier: 4,
modifier_expert: 3,
modifier_master: 2,
build: 'quadruped',
random: '1-8'
},
{
id: 'qleg',
name: 'Bein',
modifier: 10,
modifier_expert: 7,
modifier_master: 5,
build: 'quadruped',
random: '9-16'
},
{
id: 'weakspot',
name: 'verwundbare Stelle',
modifier: 12,
modifier_expert: 8,
modifier_master: 6,
build: 'quadruped',
random: '1-8'
},
{
id: 'qhead',
name: 'Kopf',
modifier: 16,
modifier_expert: 11,
modifier_master: 8,
build: 'quadruped',
random: '17-19'
},
{
id: 'qtail',
name: 'Schwanz',
modifier: 16,
modifier_expert: 11,
modifier_master: 8,
build: 'quadruped',
random: '20'
},
{
id: 'qsensory_organs',
name: 'Sinnesorgane',
modifier: 16,
modifier_expert: 11,
modifier_master: 8,
build: 'quadruped',
random: '20'
}
];
export const OTHER_MODIFIERS: ModifierRow[] = [
{
id: 'steep_shot_down',
name: 'Steilschuss nach unten',
modifier: 2,
skill: 'archery'
},
{
id: 'steep_shot_up',
name: 'Steilschuss nach oben',
modifier: 4,
skill: 'archery'
},
{ id: 'gusty_crosswind', name: 'böiger Seitenwind', modifier: 4 },
{ id: 'strong_gusty_crosswind', name: 'starker, böiger Seitenwind', modifier: 8 },
{
id: 'fast_shot',
name: 'Schnellschuss',
modifier: 2,
modifier_expert: 1,
modifier_master: 0
},
{
id: 'aim',
name: 'Zielen',
modifier: -0.5,
modifier_expert: -1,
modifier_master: -1
}
];
export const RELOAD_STATES: ModifierRow[] = [
{ id: 'arrow_nocked', name: 'Pfeil auf Sehne', modifier: -1 },
{ id: 'bow_drawn', name: 'Bogen bereits gespannt', modifier: -2 },
{ id: 'regular_shot', name: 'Normaler Schuss', modifier: 1 },
{ id: 'fast_shot_bow', name: 'Normaler Schuss (schnell)', modifier: 0 },
{
id: 'aimed_shot',
name: 'Gezielter Schuss',
modifier: '/2',
modifier_expert: '/2 -2',
modifier_master: 1
},
{
id: 'announced',
name: 'Fernkampfangriff mit Ansage',
modifier: '/2',
modifier_expert: '/2 -2',
modifier_master: 1
},
{ id: 'fast_reload_bow', name: 'Schnellladen', modifier: -1, skill: 'archery' }
];
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;
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';
}
+26
View File
@@ -0,0 +1,26 @@
/** LeP-Modifikator je Rasse (typische Werte DSA 4.1, vereinfacht) */
export const RACE_LEP_MOD: Record<string, number> = {
Mensch: 5,
Elf: 2,
Halbelf: 4,
Zwerg: 8,
Halbling: 0,
Gnom: -4,
Halbork: 6,
Ork: 8,
Goblin: -5,
'': 0
};
export const RACE_ASP_MOD: Record<string, number> = {
Mensch: 0,
Elf: 12,
Halbelf: 6,
Zwerg: 0,
Halbling: 0,
Gnom: 0,
Halbork: 0,
Ork: 0,
Goblin: 0,
'': 0
};
+94
View File
@@ -0,0 +1,94 @@
import type { RangedSkillId } from '$lib/rules/weapons-ranged';
export type TalentCategory = 'allgemein' | 'kampf' | 'magie' | 'sonstig';
export type TalentDef = {
id: string;
name: string;
category: TalentCategory;
probe?: readonly [string, string, string];
/** Kampftalente mit Fernkampf-Untertyp */
rangedSkill?: RangedSkillId;
};
/** Auszug Talentliste DSA 4.1 erweiterbar */
export const TALENTS: TalentDef[] = [
{ id: 'akrobatik', name: 'Akrobatik', category: 'allgemein', probe: ['GE', 'GE', 'KK'] },
{ id: 'alchimie', name: 'Alchimie', category: 'allgemein', probe: ['MU', 'KL', 'FF'] },
{ id: 'bogen', name: 'Bogen', category: 'kampf', probe: ['IN', 'FF', 'KK'], rangedSkill: 'bogen' },
{
id: 'armbrust',
name: 'Armbrust',
category: 'kampf',
probe: ['IN', 'FF', 'KK'],
rangedSkill: 'armbrust'
},
{
id: 'wurfwaffe',
name: 'Wurfwaffen',
category: 'kampf',
probe: ['IN', 'FF', 'KK'],
rangedSkill: 'wurfwaffe'
},
{
id: 'schleuder',
name: 'Schleuder',
category: 'kampf',
probe: ['IN', 'FF', 'KK'],
rangedSkill: 'schleuder'
},
{ id: 'dolche', name: 'Dolche', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'fechtwaffen', name: 'Fechtwaffen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'hiebwaffen', name: 'Hiebwaffen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'kettenwaffen', name: 'Kettenwaffen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'lanzenreiterei', name: 'Lanzenreiterei', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'parierwaffen', name: 'Parierwaffen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'raufen', name: 'Raufen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'schilde', name: 'Schilde', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'schwerter', name: 'Schwerter', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'speere', name: 'Speere', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'stangenwaffen', name: 'Stangenwaffen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'zweihandhiebwaffen', name: 'Zweihand-Hiebwaffen', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'zweihandschwerter', name: 'Zweihand-Schwerter', category: 'kampf', probe: ['MU', 'GE', 'KK'] },
{ id: 'athletik', name: 'Athletik', category: 'allgemein', probe: ['GE', 'KO', 'KK'] },
{ id: 'menschenkenntnis', name: 'Menschenkenntnis', category: 'allgemein', probe: ['MU', 'IN', 'CH'] },
{ id: 'uberreden', name: 'Überreden', category: 'allgemein', probe: ['MU', 'IN', 'CH'] },
{ id: 'uberzeugen', name: 'Überzeugen', category: 'allgemein', probe: ['MU', 'IN', 'CH'] },
{ id: 'betoeren', name: 'Betören', category: 'allgemein', probe: ['CH', 'CH', 'KK'] },
{ id: 'gassenwissen', name: 'Gassenwissen', category: 'allgemein', probe: ['KL', 'IN', 'CH'] },
{ id: 'handel', name: 'Handel', category: 'allgemein', probe: ['KL', 'IN', 'CH'] },
{ id: 'heimlichkeit', name: 'Heimlichkeit', category: 'allgemein', probe: ['MU', 'IN', 'GE'] },
{ id: 'tierkunde', name: 'Tierkunde', category: 'allgemein', probe: ['MU', 'IN', 'CH'] },
{ id: 'pflanzenkunde', name: 'Pflanzenkunde', category: 'allgemein', probe: ['KL', 'IN', 'FF'] },
{ id: 'wildnisleben', name: 'Wildnisleben', category: 'allgemein', probe: ['MU', 'IN', 'GE'] },
{ id: 'fischen', name: 'Fischen & Angeln', category: 'allgemein', probe: ['KL', 'FF', 'KK'] },
{ id: 'tanzen', name: 'Tanzen', category: 'allgemein', probe: ['KL', 'CH', 'GE'] },
{ id: 'musizieren', name: 'Musizieren', category: 'allgemein', probe: ['CH', 'FF', 'KK'] },
{ id: 'schloesser_knacken', name: 'Schlösser knacken', category: 'allgemein', probe: ['IN', 'FF', 'KK'] },
{ id: 'fallenstellen', name: 'Fallen stellen', category: 'allgemein', probe: ['KL', 'FF', 'KK'] },
{ id: 'seilkunst', name: 'Seilkunst', category: 'allgemein', probe: ['KL', 'FF', 'KK'] },
{ id: 'schneidern', name: 'Schneidern', category: 'allgemein', probe: ['KL', 'FF', 'KK'] },
{ id: 'schmieden', name: 'Schmieden', category: 'allgemein', probe: ['FF', 'KO', 'KK'] },
{ id: 'steinbearbeitung', name: 'Steinbearbeitung', category: 'allgemein', probe: ['FF', 'KO', 'KK'] },
{ id: 'holzbearbeitung', name: 'Holzbearbeitung', category: 'allgemein', probe: ['KL', 'FF', 'KK'] },
{ id: 'lederarbeiten', name: 'Lederarbeiten', category: 'allgemein', probe: ['KL', 'FF', 'KK'] },
{ id: 'kochen', name: 'Kochen', category: 'allgemein', probe: ['KL', 'IN', 'FF'] },
{ id: 'lesen_schreiben', name: 'Lesen & Schreiben', category: 'allgemein', probe: ['KL', 'KL', 'IN'] },
{ id: 'sprachen', name: 'Sprachen kennen', category: 'allgemein', probe: ['KL', 'KL', 'IN'] },
{ id: 'rechnen', name: 'Rechnen', category: 'allgemein', probe: ['KL', 'KL', 'IN'] },
{ id: 'sagen_sagenkunde', name: 'Sagen & Sagenkunde', category: 'allgemein', probe: ['KL', 'IN', 'CH'] },
{ id: 'staatskunst', name: 'Staatskunst', category: 'allgemein', probe: ['KL', 'IN', 'CH'] },
{ id: 'geographie', name: 'Geographie', category: 'allgemein', probe: ['KL', 'KL', 'IN'] },
{ id: 'geschichtswissen', name: 'Geschichtswissen', category: 'allgemein', probe: ['KL', 'KL', 'IN'] },
{ id: 'götter_kulte', name: 'Götter & Kulte', category: 'allgemein', probe: ['KL', 'KL', 'IN'] },
{ id: 'magiekunde', name: 'Magiekunde', category: 'magie', probe: ['KL', 'KL', 'IN'] },
{ id: 'mechanik', name: 'Mechanik', category: 'allgemein', probe: ['KL', 'FF', 'KK'] }
];
export function getTalent(id: string): TalentDef | undefined {
return TALENTS.find((t) => t.id === id);
}
export function getTalentByRangedSkill(skill: RangedSkillId): TalentDef | undefined {
return TALENTS.find((t) => t.rangedSkill === skill);
}
+95
View File
@@ -0,0 +1,95 @@
export type RangedWeaponId =
| 'elven_bow'
| 'composite_bow'
| 'warbow'
| 'shortbow'
| 'longbow'
| 'orc_bow';
/** Maps to Kampftalent id in talents (Fernkampf subtypes). */
export type RangedSkillId = 'bogen' | 'armbrust' | 'wurfwaffe' | 'schleuder';
export type RangedWeaponDef = {
id: RangedWeaponId;
name: string;
skill: RangedSkillId;
damage: string;
/** Entfernungs-Bonusschaden pro Reichweitenstufe (nah → extrem weit), slash-getrennt */
bonusDamage: string;
/** Reichweiten in Schritt, slash-getrennt */
ranges: string;
reload: number;
/** Mindest-KK für ungehinderten Einsatz (optional) */
strengthRequirement?: number;
/** Erschwernis auf FK, wenn KK unter Anforderung */
strengthModifier?: number;
};
export const RANGED_WEAPONS: RangedWeaponDef[] = [
{
id: 'elven_bow',
name: 'Elfenbogen',
skill: 'bogen',
damage: '1W6+5',
bonusDamage: '3/2/1/1/0',
ranges: '10/25/50/100/200',
reload: 3
},
{
id: 'composite_bow',
name: 'Kompositbogen',
skill: 'bogen',
damage: '1W6+5',
bonusDamage: '2/1/1/0/0',
ranges: '10/20/35/50/80',
reload: 3,
strengthRequirement: 15,
strengthModifier: -2
},
{
id: 'warbow',
name: 'Kriegsbogen',
skill: 'bogen',
damage: '1W6+7',
bonusDamage: '3/2/1/0/0',
ranges: '10/20/40/80/150',
reload: 4,
strengthRequirement: 16,
strengthModifier: -2
},
{
id: 'shortbow',
name: 'Kurzbogen',
skill: 'bogen',
damage: '1W6+4',
bonusDamage: '1/1/0/0/-1',
ranges: '5/15/25/40/60',
reload: 2
},
{
id: 'longbow',
name: 'Langbogen',
skill: 'bogen',
damage: '1W6+6',
bonusDamage: '3/2/1/0/-1',
ranges: '10/25/50/100/200',
reload: 4,
strengthRequirement: 15,
strengthModifier: -2
},
{
id: 'orc_bow',
name: 'Ork. Reiterbogen',
skill: 'bogen',
damage: '1W6+5',
bonusDamage: '3/1/0/-1/-2',
ranges: '5/15/30/60/100',
reload: 3,
strengthRequirement: 15,
strengthModifier: -2
}
];
export function getRangedWeapon(id: RangedWeaponId): RangedWeaponDef | undefined {
return RANGED_WEAPONS.find((w) => w.id === id);
}
+134
View File
@@ -0,0 +1,134 @@
import { z } from 'zod';
import { CURRENT_SCHEMA_VERSION } from '$lib/schema/version';
const attributeSchema = z.object({
startwert: z.number().int().min(1).max(25),
mod: z.number().int().min(-99).max(99)
});
const energySchema = z.object({
mod: z.number().int(),
permanent: z.number().int().optional(),
aktuell: z.number().int().optional()
});
const traitSchema = z.object({
id: z.string(),
name: z.string(),
note: z.string().optional()
});
const sfSchema = z.object({
id: z.string(),
name: z.string(),
note: z.string().optional()
});
const talentEntrySchema = z.object({
id: z.string(),
taw: z.number().int(),
be: z.number().int().optional(),
spezialisierungen: z.array(z.string()).optional()
});
const combatTalentSchema = z.object({
id: z.string(),
taw: z.number().int(),
at: z.number().int(),
pa: z.number().int()
});
const itemSchema = z.object({
id: z.string(),
name: z.string(),
anzahl: z.number().int().positive().default(1),
note: z.string().optional()
});
const rangedWeaponInstanceSchema = z.object({
instanceId: z.string(),
weaponDefId: z.string(),
name: z.string().optional(),
note: z.string().optional()
});
const meleeWeaponInstanceSchema = z.object({
instanceId: z.string(),
name: z.string(),
/** Kampftalent id (z.B. schwerter) */
talentId: z.string(),
atMod: z.number().int().default(0),
paMod: z.number().int().default(0),
damage: z.string().optional(),
note: z.string().optional()
});
const spellEntrySchema = z.object({
id: z.string(),
name: z.string(),
stufe: z.number().int().min(1).max(25),
zfp: z.number().int().optional(),
note: z.string().optional()
});
const liturgyEntrySchema = z.object({
id: z.string(),
name: z.string(),
stufe: z.number().int().min(1).max(25),
note: z.string().optional()
});
const eigenschaftenSchema = z.object({
MU: attributeSchema,
KL: attributeSchema,
IN: attributeSchema,
CH: attributeSchema,
FF: attributeSchema,
GE: attributeSchema,
KO: attributeSchema,
KK: attributeSchema
});
export const characterSchema = z.object({
id: z.string().uuid(),
schemaVersion: z.literal(CURRENT_SCHEMA_VERSION),
meta: z.object({
name: z.string().min(1),
rasse: z.string(),
kultur: z.string(),
profession: z.string(),
geschlecht: z.enum(['m', 'w', 'd']).optional(),
gp: z.number().int().optional()
}),
eigenschaften: eigenschaftenSchema,
energien: z.object({
lep: energySchema,
asp: energySchema.optional(),
kap: energySchema.optional(),
aup: energySchema,
mr: energySchema
}),
vorteile: z.array(traitSchema).default([]),
nachteile: z.array(traitSchema).default([]),
sonderfertigkeiten: z.array(sfSchema).default([]),
talente: z.array(talentEntrySchema).default([]),
kampftalente: z.array(combatTalentSchema).default([]),
zauber: z.array(spellEntrySchema).optional(),
liturgien: z.array(liturgyEntrySchema).optional(),
inventar: z.array(itemSchema).default([]),
waffen: z.object({
fernkampf: z.array(rangedWeaponInstanceSchema).default([]),
nahkampf: z.array(meleeWeaponInstanceSchema).default([])
}),
notizen: z.string().optional()
});
export type Character = z.infer<typeof characterSchema>;
export function parseCharacter(data: unknown): Character {
return characterSchema.parse(data);
}
export function safeParseCharacter(data: unknown) {
return characterSchema.safeParse(data);
}
+13
View File
@@ -0,0 +1,13 @@
export const CURRENT_SCHEMA_VERSION = 1 as const;
export type SchemaVersion = typeof CURRENT_SCHEMA_VERSION;
export function migrateCharacter(raw: unknown): unknown {
if (typeof raw !== 'object' || raw === null) return raw;
const o = raw as Record<string, unknown>;
const v = o.schemaVersion;
if (v === undefined) {
return { ...o, schemaVersion: CURRENT_SCHEMA_VERSION };
}
return raw;
}
+20
View File
@@ -0,0 +1,20 @@
import Dexie, { type Table } from 'dexie';
import type { Character } from '$lib/schema/character';
type MetaRow = { key: string; value: string };
export class DsaDB extends Dexie {
characters!: Table<Character, string>;
meta!: Table<MetaRow, string>;
constructor() {
super('dsa-ranger');
this.version(1).stores({
characters: 'id, meta.name',
meta: 'key'
});
}
}
export const db = new DsaDB();
+29
View File
@@ -0,0 +1,29 @@
import yaml from 'js-yaml';
import { parseCharacter } from '$lib/schema/character';
import { migrateCharacter } from '$lib/schema/version';
import type { Character } from '$lib/schema/character';
export function exportCharacterJson(c: Character, pretty = true): string {
return pretty ? JSON.stringify(c, null, 2) : JSON.stringify(c);
}
export function exportCharacterYaml(c: Character): string {
return yaml.dump(c, { lineWidth: 100, noRefs: true });
}
export function importCharacterFromText(text: string, format: 'json' | 'yaml'): Character {
const raw =
format === 'json' ? (JSON.parse(text) as unknown) : (yaml.load(text) as unknown);
const migrated = migrateCharacter(raw);
return parseCharacter(migrated);
}
export function downloadText(filename: string, content: string, mime: string): void {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
+32
View File
@@ -0,0 +1,32 @@
import type { Character } from '$lib/schema/character';
import { db } from '$lib/storage/db';
const ACTIVE_KEY = 'activeCharacterId';
export async function listCharacters(): Promise<Character[]> {
return db.characters.orderBy('meta.name').toArray();
}
export async function getCharacter(id: string): Promise<Character | undefined> {
return db.characters.get(id);
}
export async function saveCharacter(c: Character): Promise<void> {
await db.characters.put(c);
}
export async function deleteCharacter(id: string): Promise<void> {
await db.characters.delete(id);
const active = await getActiveCharacterId();
if (active === id) await setActiveCharacterId(null);
}
export async function getActiveCharacterId(): Promise<string | null> {
const row = await db.meta.get(ACTIVE_KEY);
return row?.value ?? null;
}
export async function setActiveCharacterId(id: string | null): Promise<void> {
if (id === null) await db.meta.delete(ACTIVE_KEY);
else await db.meta.put({ key: ACTIVE_KEY, value: id });
}
+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { registerSW } from 'virtual:pwa-register';
let online = typeof navigator !== 'undefined' ? navigator.onLine : true;
let deferredPrompt: Event & { prompt: () => Promise<void>; userChoice: Promise<{ outcome: string }> } | null =
null;
let canInstall = false;
onMount(() => {
const updateOnline = () => {
online = navigator.onLine;
};
window.addEventListener('online', updateOnline);
window.addEventListener('offline', updateOnline);
const off = registerSW({
immediate: true,
onOfflineReady() {
console.info('[PWA] Offline bereit');
}
});
const onBip = (e: Event) => {
e.preventDefault();
deferredPrompt = e as typeof deferredPrompt;
canInstall = true;
};
window.addEventListener('beforeinstallprompt', onBip);
return () => {
window.removeEventListener('online', updateOnline);
window.removeEventListener('offline', updateOnline);
window.removeEventListener('beforeinstallprompt', onBip);
if (typeof off === 'function') off();
};
});
async function installApp() {
if (!deferredPrompt) return;
deferredPrompt.prompt();
await deferredPrompt.userChoice;
deferredPrompt = null;
canInstall = false;
}
</script>
<svelte:head>
<title>DSA 4.1 Helfer</title>
</svelte:head>
<header class="topbar">
<div class="brand">
<a href="/">DSA 4.1 Helfer</a>
<span class="pill" class:bad={!online} class:ok={online}>
{online ? 'Online' : 'Offline'}
</span>
</div>
<nav class="nav">
<a href="/" class:active={$page.url.pathname === '/'}>Helden</a>
<a href="/settings" class:active={$page.url.pathname.startsWith('/settings')}>Einstellungen</a>
{#if canInstall}
<button type="button" class="btn primary" on:click={installApp}>Installieren</button>
{/if}
</nav>
</header>
<main class="main">
<slot />
</main>
<style>
.topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky;
top: 0;
z-index: 10;
}
.brand {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 700;
}
.brand a {
color: var(--text);
text-decoration: none;
}
.pill.ok {
border-color: #2d6a4f;
color: #95d5b2;
}
.pill.bad {
border-color: #8a3d3d;
color: #ffb4b4;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.nav a {
padding: 0.35rem 0.6rem;
border-radius: 8px;
color: var(--muted);
}
.nav a.active {
background: var(--surface-2);
color: var(--text);
}
.main {
max-width: 960px;
margin: 0 auto;
padding: 1rem;
}
</style>
+2
View File
@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = true;
+133
View File
@@ -0,0 +1,133 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { Character } from '$lib/schema/character';
import { newCharacter } from '$lib/characters/default';
import { deleteCharacter, listCharacters, saveCharacter, setActiveCharacterId } from '$lib/storage/repo';
import { importCharacterFromText, downloadText, exportCharacterJson, exportCharacterYaml } from '$lib/storage/io';
let characters: Character[] = [];
let importError = '';
let fileInput: HTMLInputElement | null = null;
async function refresh() {
characters = await listCharacters();
}
onMount(refresh);
async function createHero() {
const c = newCharacter();
await saveCharacter(c);
await setActiveCharacterId(c.id);
await refresh();
await goto(`/characters/${c.id}`);
}
async function remove(id: string, ev: MouseEvent) {
ev.stopPropagation();
if (!confirm('Held wirklich löschen?')) return;
await deleteCharacter(id);
await refresh();
}
function exportJson(c: Character) {
const safe = c.meta.name.replace(/[^\wäöüÄÖÜß-]+/gi, '_');
downloadText(`${safe}-${new Date().toISOString().slice(0, 10)}.dsa.json`, exportCharacterJson(c), 'application/json');
}
function exportYaml(c: Character) {
const safe = c.meta.name.replace(/[^\wäöüÄÖÜß-]+/gi, '_');
downloadText(`${safe}-${new Date().toISOString().slice(0, 10)}.dsa.yaml`, exportCharacterYaml(c), 'text/yaml');
}
async function onImportPick(ev: Event) {
importError = '';
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
const text = await file.text();
try {
const fmt = file.name.endsWith('.yaml') || file.name.endsWith('.yml') ? 'yaml' : 'json';
const c = importCharacterFromText(text, fmt);
await saveCharacter(c);
await setActiveCharacterId(c.id);
await refresh();
await goto(`/characters/${c.id}`);
} catch (e) {
importError = e instanceof Error ? e.message : String(e);
}
}
</script>
<h1>Helden</h1>
<p class="muted">Lokal gespeichert (IndexedDB). Export/Import als JSON oder YAML.</p>
<div class="row" style="margin: 1rem 0;">
<button type="button" class="btn primary" on:click={createHero}>Neuer Held</button>
<button type="button" class="btn" on:click={() => fileInput?.click()}>Import …</button>
<input bind:this={fileInput} type="file" accept=".json,.yaml,.yml,application/json" hidden on:change={onImportPick} />
</div>
{#if importError}
<p class="err">{importError}</p>
{/if}
{#if characters.length === 0}
<div class="card">
<p>Noch keine Helden. Lege einen neuen an oder importiere eine Datei.</p>
</div>
{:else}
<ul class="list">
{#each characters as c (c.id)}
<li class="card hero">
<a class="hero-link" href="/characters/{c.id}">
<strong>{c.meta.name}</strong>
<span class="muted">{c.meta.profession || '—'} · {c.meta.rasse}</span>
</a>
<div class="actions">
<button type="button" class="btn" on:click={() => exportJson(c)}>JSON</button>
<button type="button" class="btn" on:click={() => exportYaml(c)}>YAML</button>
<button type="button" class="btn danger" on:click={(e) => remove(c.id, e)}>Löschen</button>
</div>
</li>
{/each}
</ul>
{/if}
<style>
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.hero {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.hero-link {
display: flex;
flex-direction: column;
gap: 0.15rem;
color: inherit;
text-decoration: none;
}
.hero-link:hover strong {
color: var(--accent);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.err {
color: var(--danger);
}
</style>
+1
View File
@@ -0,0 +1 @@
export const prerender = false;
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { Character } from '$lib/schema/character';
import { getCharacter, setActiveCharacterId } from '$lib/storage/repo';
import { computeDerived } from '$lib/engine/derived';
let char: Character | undefined;
let err = '';
const id = $page.params.id ?? '';
onMount(async () => {
if (!id) {
err = 'Ungültige URL.';
return;
}
char = await getCharacter(id);
if (!char) {
err = 'Held nicht gefunden.';
return;
}
await setActiveCharacterId(char.id);
});
$: derived = char ? computeDerived(char) : null;
</script>
{#if err}
<p class="err">{err}</p>
<p><a href="/">Zurück</a></p>
{:else if !char}
<p>Lade …</p>
{:else}
<h1>{char.meta.name}</h1>
<p class="muted">{char.meta.profession} · {char.meta.rasse}</p>
{#if derived}
<div class="grid-2" style="margin-top: 1rem;">
<div class="card">
<h2>Basis</h2>
<ul class="kv">
<li><span>AT-Basis</span><strong>{derived.atBasis}</strong></li>
<li><span>PA-Basis</span><strong>{derived.paBasis}</strong></li>
<li><span>FK-Basis</span><strong>{derived.fkBasis}</strong></li>
<li><span>INI-Basis</span><strong>{derived.iniBasis}</strong></li>
<li><span>MR-Basis</span><strong>{derived.mrBasis}</strong></li>
</ul>
</div>
<div class="card">
<h2>Energien (Max)</h2>
<ul class="kv">
<li><span>LeP</span><strong>{derived.lepMax}</strong></li>
<li><span>AsP</span><strong>{derived.aspMax}</strong></li>
<li><span>AuP</span><strong>{derived.aupMax}</strong></li>
</ul>
</div>
</div>
{/if}
<nav class="subnav card">
<a href="/characters/{char.id}/sheet">Bogen</a>
<a href="/characters/{char.id}/combat/ranged">Fernkampf</a>
<a href="/characters/{char.id}/combat/melee">Nahkampf</a>
<a href="/characters/{char.id}/spells">Magie</a>
<a href="/">Alle Helden</a>
</nav>
{/if}
<style>
.subnav {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.kv {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.kv li {
display: flex;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--border);
padding: 0.25rem 0;
}
.kv span {
color: var(--muted);
}
.err {
color: var(--danger);
}
</style>
@@ -0,0 +1,154 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { Character } from '$lib/schema/character';
import { getCharacter } from '$lib/storage/repo';
import { computeMeleeTargets } from '$lib/engine/melee';
import { TALENTS } from '$lib/rules/talents';
let char: Character | undefined;
let err = '';
const id = $page.params.id ?? '';
let talentId = 'dolche';
let weaponIndex = 0;
let situation = '0';
let atExtra = 0;
let paExtra = 0;
let ansage = 0;
onMount(async () => {
if (!id) {
err = 'Ungültige URL.';
return;
}
char = await getCharacter(id);
if (!char) err = 'Held nicht gefunden.';
else if (char.waffen.nahkampf.length) {
talentId = char.waffen.nahkampf[0].talentId;
}
});
$: wIdx = Number(weaponIndex);
$: weapon = char?.waffen.nahkampf[wIdx];
$: result =
char &&
weapon &&
computeMeleeTargets(
char,
talentId,
weapon.atMod,
weapon.paMod,
{
situationModifiers: situation
.split(/[,;]+/)
.map((s) => parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n)),
atExtra,
paExtra,
ansageAtAnteil: ansage
}
);
</script>
{#if err}
<p class="err">{err}</p>
{:else if !char}
<p>Lade …</p>
{:else}
<h1>Nahkampf · {char.meta.name}</h1>
<p class="muted">
Wähle Waffe und Erschwernisse (Zahlen). Manöver-Details am Tisch hier nur Summen.
</p>
<section class="card stack">
<h2>Waffe</h2>
{#if char.waffen.nahkampf.length === 0}
<p>Keine Nahkampfwaffe im Bogen. Unter „Bogen“ anlegen.</p>
{:else}
<select bind:value={weaponIndex}>
{#each char.waffen.nahkampf as w, i}
<option value={i}>{w.name}</option>
{/each}
</select>
<div class="field">
<label>Kampftalent für diese Attacke</label>
<select bind:value={talentId}>
{#each TALENTS.filter((t) => t.category === 'kampf') as t}
<option value={t.id}>{t.name}</option>
{/each}
</select>
</div>
{/if}
</section>
<section class="card stack">
<h2>Situation</h2>
<div class="field">
<label>Zusätzliche Erschwernisse (Komma-getrennt, z.B. 3,2)</label>
<input bind:value={situation} placeholder="0" />
</div>
<div class="grid-2">
<div class="field">
<label>AT-Zusatz</label>
<input type="number" bind:value={atExtra} />
</div>
<div class="field">
<label>PA-Zusatz</label>
<input type="number" bind:value={paExtra} />
</div>
</div>
<div class="field">
<label>Ansage (AT-Anteil, vereinfacht)</label>
<input type="number" min="0" bind:value={ansage} />
</div>
</section>
{#if result}
<section class="card stack highlight">
<h2>Zielzahlen</h2>
<div class="grid-2">
<div>
<p class="muted">Attacke</p>
<p class="big-number">{result.atTarget}</p>
</div>
<div>
<p class="muted">Parade</p>
<p class="big-number">{result.paTarget}</p>
</div>
</div>
<ul class="kv">
<li><span>AT (inkl. Waffe)</span><strong>{result.at}</strong></li>
<li><span>PA (inkl. Waffe)</span><strong>{result.pa}</strong></li>
<li><span>Summe Modifikatoren</span><strong>{result.modifierSum}</strong></li>
</ul>
{#each result.notes as n}
<p class="muted">{n}</p>
{/each}
</section>
{/if}
<p><a href="/characters/{char.id}">Zurück</a></p>
{/if}
<style>
.highlight {
border-color: var(--accent-dim);
}
.kv {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.kv li {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.err {
color: var(--danger);
}
</style>
@@ -0,0 +1,267 @@
<script lang="ts">
import { onMount } from 'svelte';
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 { RANGED_WEAPONS, type RangedWeaponId } from '$lib/rules/weapons-ranged';
import {
RANGES,
TARGET_SIZES,
VISIBILITY,
TARGET_MOVEMENT,
HIT_ZONES,
OTHER_MODIFIERS,
RELOAD_STATES,
type ExtraModifierId,
type HitZoneId,
type RangeId,
type ReloadStateId
} from '$lib/rules/modifiers-ranged';
let char: Character | undefined;
let err = '';
const id = $page.params.id ?? '';
let weaponId: RangedWeaponId = 'shortbow';
let expertise: Expertise = 'none';
let range: RangeId = 'near';
let targetSize = 'medium' as const;
let visibility = 'clear' as const;
let movement = 'slow' as const;
let hitZone = '' as HitZoneId | '';
let reloadState = '' as ReloadStateId | '';
let targetBuild: 'biped' | 'quadruped' = 'biped';
let aimTurns = 0;
let extras = new Set<ExtraModifierId>();
onMount(async () => {
if (!id) {
err = 'Ungültige URL.';
return;
}
char = await getCharacter(id);
if (!char) err = 'Held nicht gefunden.';
else {
const first = char.waffen.fernkampf[0];
if (first?.weaponDefId) weaponId = first.weaponDefId as RangedWeaponId;
}
});
function toggleExtra(id: ExtraModifierId) {
if (extras.has(id)) extras.delete(id);
else extras.add(id);
extras = extras;
}
$: result =
char &&
(() => {
try {
return computeRangedTarget(
char,
weaponId,
{
range,
targetSize,
visibility,
movement,
hitZone: hitZone === '' ? undefined : hitZone,
reloadState: reloadState || undefined,
extras: [...extras],
aimTurns,
targetBuild
},
expertise
);
} catch {
return null;
}
})();
const hitZoneOptions = HIT_ZONES.filter((z) => z.build === targetBuild);
</script>
{#if err}
<p class="err">{err}</p>
{:else if !char}
<p>Lade …</p>
{:else}
<h1>Fernkampf · {char.meta.name}</h1>
<p class="muted">Wähle Modifikatoren Zielzahl für 1W20 am Tisch.</p>
<section class="card stack">
<h2>Waffe</h2>
<select bind:value={weaponId}>
{#each RANGED_WEAPONS as w}
<option value={w.id}>{w.name}</option>
{/each}
</select>
</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">
<h2>Ziel</h2>
<div class="field">
<label>Körperbau</label>
<select bind:value={targetBuild}>
<option value="biped">Zweibeiner</option>
<option value="quadruped">Vierfüßer</option>
</select>
</div>
</section>
<section class="card stack">
<h2>Entfernung</h2>
<div class="chips">
{#each RANGES as r}
<label class="chip"
><input type="radio" bind:group={range} value={r.id} /> {r.name} ({r.modifier})</label
>
{/each}
</div>
</section>
<section class="card stack">
<h2>Zielgröße</h2>
<div class="chips">
{#each TARGET_SIZES as r}
<label class="chip"
><input type="radio" bind:group={targetSize} value={r.id} /> {r.name}</label
>
{/each}
</div>
</section>
<section class="card stack">
<h2>Sicht</h2>
<div class="chips">
{#each VISIBILITY as r}
<label class="chip"
><input type="radio" bind:group={visibility} value={r.id} /> {r.name}</label
>
{/each}
</div>
</section>
<section class="card stack">
<h2>Bewegung</h2>
<div class="chips">
{#each TARGET_MOVEMENT as r}
<label class="chip"
><input type="radio" bind:group={movement} value={r.id} /> {r.name}</label
>
{/each}
</div>
</section>
<section class="card stack">
<h2>Trefferzone (optional)</h2>
<select bind:value={hitZone}>
<option value="">— keine —</option>
{#each hitZoneOptions as z}
<option value={z.id}>{z.name}</option>
{/each}
</select>
</section>
<section class="card stack">
<h2>Schussart / Zustand</h2>
<select bind:value={reloadState}>
<option value="">— Standard —</option>
{#each RELOAD_STATES as r}
<option value={r.id}>{r.name}</option>
{/each}
</select>
<div class="field">
<label>Zielen (KR)</label>
<input type="number" min="0" max="20" bind:value={aimTurns} />
</div>
</section>
<section class="card stack">
<h2>Weitere Modifikatoren</h2>
<div class="chips">
{#each OTHER_MODIFIERS as r}
<label class="chip"
><input
type="checkbox"
checked={extras.has(r.id as ExtraModifierId)}
on:change={() => toggleExtra(r.id as ExtraModifierId)}
/>
{r.name}</label
>
{/each}
</div>
</section>
{#if result}
<section class="card stack highlight">
<h2>Ergebnis</h2>
<p class="big-number">{result.finalTarget}</p>
<p class="muted">Zielzahl (1W20): ≤ {result.finalTarget} ist Erfolg</p>
<ul class="kv">
<li><span>FK-Basis</span><strong>{result.fkBase}</strong></li>
<li><span>TaW (Bogen)</span><strong>{result.weaponTaW}</strong></li>
<li><span>wirksames TaW</span><strong>{result.effectiveTaW}</strong></li>
<li><span>Basis (FK+TaW)</span><strong>{result.baseTarget}</strong></li>
<li><span>Summe Modifikatoren</span><strong>{result.totalModifier}</strong></li>
</ul>
{#if result.reloadNote}<p class="muted">{result.reloadNote}</p>{/if}
{#if result.strengthNote}<p class="muted">{result.strengthNote}</p>{/if}
<h3>Modifikatoren</h3>
<ul class="mods">
{#each result.modifiers as m}
<li><span>{m.label}</span><strong>{m.value}</strong></li>
{/each}
</ul>
<h3>Schaden (Hinweis)</h3>
<p>{result.damageSummary}</p>
</section>
{/if}
<p><a href="/characters/{char.id}">Zurück</a></p>
{/if}
<style>
.chips {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.chip {
display: flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
}
.highlight {
border-color: var(--accent-dim);
}
.kv,
.mods {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.kv li,
.mods li {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.err {
color: var(--danger);
}
</style>
@@ -0,0 +1,403 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { Character } from '$lib/schema/character';
import { ATTRIBUTE_KEYS, ATTRIBUTE_LABELS } from '$lib/rules/attributes';
import { getCharacter, saveCharacter } from '$lib/storage/repo';
import { TALENTS } from '$lib/rules/talents';
import { computeDerived } from '$lib/engine/derived';
import { atBasis, paBasis } from '$lib/engine/derived';
let char: Character | undefined;
let err = '';
let status = '';
const id = $page.params.id ?? '';
onMount(async () => {
if (!id) {
err = 'Ungültige URL.';
return;
}
char = await getCharacter(id);
if (!char) err = 'Held nicht gefunden.';
});
async function persist() {
if (!char) return;
status = 'Speichere …';
await saveCharacter(char);
status = 'Gespeichert.';
setTimeout(() => (status = ''), 1500);
}
function syncKampfAtPa() {
if (!char) return;
const ab = atBasis(char);
const pb = paBasis(char);
for (const kt of char.kampftalente) {
kt.at = ab + kt.taw;
kt.pa = pb + kt.taw;
}
}
function addTalent() {
if (!char) return;
char.talente = [...char.talente, { id: 'athletik', taw: 0 }];
char = char;
}
function addKampf() {
if (!char) return;
const ab = atBasis(char);
const pb = paBasis(char);
char.kampftalente = [...char.kampftalente, { id: 'hiebwaffen', taw: 0, at: ab, pa: pb }];
char = char;
}
function addVorteil() {
if (!char) return;
char.vorteile = [...char.vorteile, { id: crypto.randomUUID(), name: '' }];
char = char;
}
function addNachteil() {
if (!char) return;
char.nachteile = [...char.nachteile, { id: crypto.randomUUID(), name: '' }];
char = char;
}
function addSF() {
if (!char) return;
char.sonderfertigkeiten = [...char.sonderfertigkeiten, { id: crypto.randomUUID(), name: '' }];
char = char;
}
function addItem() {
if (!char) return;
char.inventar = [...char.inventar, { id: crypto.randomUUID(), name: '', anzahl: 1 }];
char = char;
}
function addMeleeWeapon() {
if (!char) return;
char.waffen.nahkampf = [
...char.waffen.nahkampf,
{
instanceId: crypto.randomUUID(),
name: 'Waffe',
talentId: 'dolche',
atMod: 0,
paMod: 0
}
];
char = char;
}
$: derived = char ? computeDerived(char) : null;
</script>
{#if err}
<p class="err">{err}</p>
{:else if !char}
<p>Lade …</p>
{:else}
<div class="head">
<h1>Bogen · {char.meta.name}</h1>
<button type="button" class="btn primary" on:click={persist}>Speichern</button>
</div>
{#if status}<p class="muted">{status}</p>{/if}
<section class="card stack">
<h2>Stammdaten</h2>
<div class="grid-2">
<div class="field">
<label for="name">Name</label>
<input id="name" bind:value={char.meta.name} />
</div>
<div class="field">
<label for="rasse">Rasse</label>
<input id="rasse" bind:value={char.meta.rasse} />
</div>
<div class="field">
<label for="kultur">Kultur</label>
<input id="kultur" bind:value={char.meta.kultur} />
</div>
<div class="field">
<label for="prof">Profession</label>
<input id="prof" bind:value={char.meta.profession} />
</div>
</div>
</section>
<section class="card stack">
<h2>Eigenschaften</h2>
<div class="grid-2">
{#each ATTRIBUTE_KEYS as key}
<div class="field">
<label for={key}>{ATTRIBUTE_LABELS[key]}</label>
<div class="row">
<input
id="{key}-s"
type="number"
bind:value={char.eigenschaften[key].startwert}
aria-label="{ATTRIBUTE_LABELS[key]} Start"
/>
<input
id="{key}-m"
type="number"
bind:value={char.eigenschaften[key].mod}
aria-label="{ATTRIBUTE_LABELS[key]} Mod"
/>
</div>
</div>
{/each}
</div>
</section>
<section class="card stack">
<h2>Energien</h2>
<div class="grid-2">
<div class="field">
<label>LeP Mod</label>
<input type="number" bind:value={char.energien.lep.mod} />
</div>
<div class="field">
<label>AuP Mod</label>
<input type="number" bind:value={char.energien.aup.mod} />
</div>
<div class="field">
<label>MR Mod</label>
<input type="number" bind:value={char.energien.mr.mod} />
</div>
<div class="field">
<label>AsP Mod (optional)</label>
{#if char.energien.asp}
<input type="number" bind:value={char.energien.asp.mod} />
{:else}
<button
type="button"
class="btn"
on:click={() => {
char!.energien.asp = { mod: 0 };
char = char;
}}>AsP aktivieren</button
>
{/if}
</div>
</div>
{#if derived}
<p class="muted">
Berechnet: LeP-Max {derived.lepMax}, AsP-Max {derived.aspMax}, AuP-Max {derived.aupMax}
</p>
{/if}
</section>
<section class="card stack">
<h2>Kampftalente</h2>
<button type="button" class="btn" on:click={syncKampfAtPa}>AT/PA aus Basis+TaW neu setzen</button>
{#each char.kampftalente as kt, i}
<div class="inline">
<select bind:value={kt.id}>
{#each TALENTS.filter((t) => t.category === 'kampf') as t}
<option value={t.id}>{t.name}</option>
{/each}
</select>
<label class="sr">TaW</label>
<input class="w-tiny" type="number" bind:value={kt.taw} />
<label class="sr">AT</label>
<input class="w-tiny" type="number" bind:value={kt.at} />
<label class="sr">PA</label>
<input class="w-tiny" type="number" bind:value={kt.pa} />
<button
type="button"
class="btn danger"
on:click={() => {
char!.kampftalente = char!.kampftalente.filter((_, j) => j !== i);
char = char;
}}>✕</button
>
</div>
{/each}
<button type="button" class="btn" on:click={addKampf}>Kampftalent</button>
</section>
<section class="card stack">
<h2>Talente</h2>
{#each char.talente as tal, i}
<div class="inline">
<select bind:value={tal.id}>
{#each TALENTS as t}
<option value={t.id}>{t.name}</option>
{/each}
</select>
<input class="w-tiny" type="number" bind:value={tal.taw} />
<button
type="button"
class="btn danger"
on:click={() => {
char!.talente = char!.talente.filter((_, j) => j !== i);
char = char;
}}>✕</button
>
</div>
{/each}
<button type="button" class="btn" on:click={addTalent}>Talent</button>
</section>
<section class="card stack">
<h2>Vorteile</h2>
{#each char.vorteile as v, i}
<div class="inline">
<input placeholder="Name" bind:value={v.name} />
<button
type="button"
class="btn danger"
on:click={() => {
char!.vorteile = char!.vorteile.filter((_, j) => j !== i);
char = char;
}}>✕</button
>
</div>
{/each}
<button type="button" class="btn" on:click={addVorteil}>Vorteil</button>
</section>
<section class="card stack">
<h2>Nachteile</h2>
{#each char.nachteile as v, i}
<div class="inline">
<input placeholder="Name" bind:value={v.name} />
<button
type="button"
class="btn danger"
on:click={() => {
char!.nachteile = char!.nachteile.filter((_, j) => j !== i);
char = char;
}}>✕</button
>
</div>
{/each}
<button type="button" class="btn" on:click={addNachteil}>Nachteil</button>
</section>
<section class="card stack">
<h2>Sonderfertigkeiten</h2>
{#each char.sonderfertigkeiten as s, i}
<div class="inline">
<input placeholder="Name" bind:value={s.name} />
<button
type="button"
class="btn danger"
on:click={() => {
char!.sonderfertigkeiten = char!.sonderfertigkeiten.filter((_, j) => j !== i);
char = char;
}}>✕</button
>
</div>
{/each}
<button type="button" class="btn" on:click={addSF}>SF</button>
</section>
<section class="card stack">
<h2>Inventar</h2>
{#each char.inventar as it, i}
<div class="inline">
<input placeholder="Gegenstand" bind:value={it.name} />
<input class="w-tiny" type="number" bind:value={it.anzahl} min="1" />
<button
type="button"
class="btn danger"
on:click={() => {
char!.inventar = char!.inventar.filter((_, j) => j !== i);
char = char;
}}>✕</button
>
</div>
{/each}
<button type="button" class="btn" on:click={addItem}>Gegenstand</button>
</section>
<section class="card stack">
<h2>Nahkampfwaffen (Instanzen)</h2>
{#each char.waffen.nahkampf as w, i}
<div class="block card" style="background: var(--surface-2)">
<div class="field">
<label>Name</label>
<input bind:value={w.name} />
</div>
<div class="field">
<label>Kampftalent</label>
<select bind:value={w.talentId}>
{#each TALENTS.filter((t) => t.category === 'kampf') as t}
<option value={t.id}>{t.name}</option>
{/each}
</select>
</div>
<div class="grid-2">
<div class="field">
<label>AT-Mod</label>
<input type="number" bind:value={w.atMod} />
</div>
<div class="field">
<label>PA-Mod</label>
<input type="number" bind:value={w.paMod} />
</div>
</div>
<button
type="button"
class="btn danger"
on:click={() => {
char!.waffen.nahkampf = char!.waffen.nahkampf.filter((_, j) => j !== i);
char = char;
}}>Entfernen</button
>
</div>
{/each}
<button type="button" class="btn" on:click={addMeleeWeapon}>Nahkampfwaffe</button>
</section>
<section class="card stack">
<h2>Notizen</h2>
<textarea rows="6" bind:value={char.notizen} placeholder="Freitext …"></textarea>
</section>
<p><a href="/characters/{char.id}">Zur Übersicht</a></p>
{/if}
<style>
.head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.inline {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
margin-bottom: 0.35rem;
}
.w-tiny {
width: 4.5rem;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.err {
color: var(--danger);
}
textarea {
width: 100%;
resize: vertical;
}
</style>
@@ -0,0 +1,179 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { Character } from '$lib/schema/character';
import type { AttributeKey } from '$lib/rules/attributes';
import { getCharacter, saveCharacter } from '$lib/storage/repo';
import { spellProbeTargets } from '$lib/engine/spell';
let char: Character | undefined;
let err = '';
const id = $page.params.id ?? '';
let zfw = 10;
const defaultProbe: [AttributeKey, AttributeKey, AttributeKey] = ['MU', 'KL', 'CH'];
onMount(async () => {
if (!id) {
err = 'Ungültige URL.';
return;
}
const loaded = await getCharacter(id);
if (!loaded) {
err = 'Held nicht gefunden.';
return;
}
if (!loaded.zauber) loaded.zauber = [];
char = loaded;
});
async function persist() {
if (!char) return;
await saveCharacter(char);
}
function addSpell() {
if (!char) return;
if (!char.zauber) char.zauber = [];
char.zauber = [
...char.zauber,
{ id: crypto.randomUUID(), name: 'Neuer Zauber', stufe: 12, zfp: 0 }
];
char = char;
}
function removeSpell(i: number) {
if (!char?.zauber) return;
char.zauber = char.zauber.filter((_, j) => j !== i);
char = char;
}
$: probe = char ? spellProbeTargets(char, defaultProbe, zfw) : null;
</script>
{#if err}
<p class="err">{err}</p>
{:else if !char}
<p>Lade …</p>
{:else}
<h1>Magie · {char.meta.name}</h1>
<p class="muted">
Zauberliste und vereinfachte Zielzahlen (MU/KL/CH + ZfW). Liturgien unten als Freitextliste.
</p>
<section class="card stack">
<h2>Probe (generisch)</h2>
<p class="muted">Attribute: MU / KL / CH im Regelwerk je Zauber anpassen.</p>
<div class="field">
<label>ZfW / ZfP-Ziel</label>
<input type="number" bind:value={zfw} />
</div>
{#if probe}
<ul class="kv">
{#each probe.targets as t, i}
<li
><span>Würfel {i + 1} ({probe.attrs[i]})</span><strong>{t}</strong></li
>
{/each}
</ul>
{/if}
</section>
<section class="card stack">
<h2>Zauber</h2>
{#each char.zauber ?? [] as z, i}
<div class="block card" style="background: var(--surface-2)">
<div class="field">
<label>Name</label>
<input bind:value={z.name} />
</div>
<div class="grid-2">
<div class="field">
<label>Stufe</label>
<input type="number" bind:value={z.stufe} />
</div>
<div class="field">
<label>ZfP gesamt</label>
<input type="number" bind:value={z.zfp} />
</div>
</div>
<div class="field">
<label>Notiz</label>
<input bind:value={z.note} />
</div>
<button type="button" class="btn danger" on:click={() => removeSpell(i)}>Entfernen</button>
</div>
{/each}
<div class="row">
<button type="button" class="btn" on:click={addSpell}>Zauber</button>
<button type="button" class="btn primary" on:click={persist}>Speichern</button>
</div>
</section>
<section class="card stack">
<h2>Liturgien (Freitext)</h2>
{#if !char.liturgien}
<button
type="button"
class="btn"
on:click={() => {
char!.liturgien = [];
char = char;
}}>Liste aktivieren</button
>
{:else}
{#each char.liturgien as lit, i}
<div class="inline">
<input bind:value={lit.name} />
<input class="w-tiny" type="number" bind:value={lit.stufe} />
<button
type="button"
class="btn danger"
on:click={() => {
char!.liturgien = char!.liturgien!.filter((_, j) => j !== i);
char = char;
}}>✕</button
>
</div>
{/each}
<button
type="button"
class="btn"
on:click={() => {
char!.liturgien = [...(char!.liturgien ?? []), { id: crypto.randomUUID(), name: '', stufe: 1 }];
char = char;
}}>Liturgie</button
>
{/if}
</section>
<p><a href="/characters/{char.id}">Zurück</a></p>
{/if}
<style>
.kv {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.kv li {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.inline {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.35rem;
}
.w-tiny {
width: 4rem;
}
.err {
color: var(--danger);
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import DiceRoller from '$lib/components/DiceRoller.svelte';
import { isHeldenImportSupported } from '$lib/import/helden';
</script>
<h1>Einstellungen</h1>
<section class="card stack">
<h2>Helden-Software</h2>
<p class="muted">
Import: {isHeldenImportSupported() ? 'verfügbar' : 'noch nicht implementiert'} (XML/.helden).
</p>
</section>
<section class="card stack">
<h2>Würfel (optional)</h2>
<p class="muted">Am Tisch würfeln reicht hier nur für Schnelltests.</p>
<DiceRoller />
</section>
<p><a href="/">Zurück</a></p>
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="svelte" />
/// <reference types="@sveltejs/kit" />
/// <reference types="vite-plugin-pwa/client" />
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#16213e"/>
<path d="M8 24 L16 6 L24 24 M11 18 h10" stroke="#6c9cff" stroke-width="2" fill="none" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

+22
View File
@@ -0,0 +1,22 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
warningFilter: (warning) => warning.code !== 'a11y_label_has_associated_control'
},
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: 'index.html',
strict: false
}),
prerender: {
handleMissingId: 'ignore',
handleHttpError: 'warn'
}
}
};
export default config;
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { atBasis, fkBasis, lepMax, paBasis, computeDerived } from '$lib/engine/derived';
import { newCharacter } from '$lib/characters/default';
describe('derived', () => {
it('computes AT/PA/FK basis for default human', () => {
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));
});
it('includes race LeP bonus for human', () => {
const c = newCharacter({ name: 'H', id: '00000000-0000-4000-8000-000000000002' });
c.meta.rasse = 'Mensch';
const ko = 12;
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);
expect(lepMax(c)).toBe(base + 5 + c.energien.lep.mod);
});
it('computeDerived returns all keys', () => {
const c = newCharacter({ id: '00000000-0000-4000-8000-000000000003' });
const d = computeDerived(c);
expect(d.atBasis).toBeTypeOf('number');
expect(d.fkBasis).toBeTypeOf('number');
expect(d.lepMax).toBeTypeOf('number');
});
});
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { exportCharacterJson, importCharacterFromText } from '$lib/storage/io';
import { newCharacter } from '$lib/characters/default';
describe('io', () => {
it('roundtrips JSON', () => {
const c = newCharacter({ name: 'IO', id: '00000000-0000-4000-8000-0000000000aa' });
const json = exportCharacterJson(c);
const back = importCharacterFromText(json, 'json');
expect(back.meta.name).toBe('IO');
expect(back.id).toBe(c.id);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { computeRangedTarget } from '$lib/engine/ranged';
import { newCharacter } from '$lib/characters/default';
import type { ExtraModifierId } from '$lib/rules/modifiers-ranged';
const fixedId = '00000000-0000-4000-8000-000000000099';
function charWithBogen(taw: number) {
const c = newCharacter({ name: 'Archer', id: fixedId });
const kt = c.kampftalente.find((k) => k.id === 'bogen');
if (kt) kt.taw = taw;
return c;
}
const baseSel = {
range: 'near' as const,
/** Zielgröße „groß“ hat Modifikator 0 in den Tabellen */
targetSize: 'large' as const,
visibility: 'clear' as const,
movement: 'slow' as const,
extras: [] as ExtraModifierId[],
aimTurns: 0,
targetBuild: 'biped' as const
};
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');
expect(r.fkBase).toBe(fkBase);
expect(r.baseTarget).toBe(fkBase + 4);
expect(r.finalTarget).toBe(fkBase + 4);
});
it('applies distance modifier (mittel = +4 EW)', () => {
const c = charWithBogen(0);
const r = computeRangedTarget(
c,
'shortbow',
{ ...baseSel, range: 'medium', reloadState: 'regular_shot' },
'none'
);
// +4 Entfernung, +1 normaler Schuss, +0 Zielgröße „groß“
expect(r.totalModifier).toBe(5);
expect(r.finalTarget).toBe(r.baseTarget - 5);
});
it('halves TaW for aimed shot without expertise', () => {
const c = charWithBogen(10);
const r = computeRangedTarget(
c,
'shortbow',
{ ...baseSel, reloadState: 'aimed_shot' },
'none'
);
expect(r.effectiveTaW).toBe(5);
expect(r.baseTarget).toBe(r.fkBase + 5);
});
});
+1
View File
@@ -0,0 +1 @@
import 'fake-indexeddb/auto';
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+50
View File
@@ -0,0 +1,50 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
export default defineConfig({
plugins: [
sveltekit(),
SvelteKitPWA({
registerType: 'autoUpdate',
manifest: {
name: 'DSA 4.1 Helfer',
short_name: 'DSA Helfer',
description: 'Offline-Helfer für Das Schwarze Auge 4.1',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#1a1a2e',
theme_color: '#16213e',
lang: 'de',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any'
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any'
},
{
src: '/icons/maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
},
workbox: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}']
}
})
],
test: {
include: ['tests/**/*.{test,spec}.{js,ts}'],
setupFiles: ['tests/setup.ts']
}
});