Compare commits
3 Commits
83f2b555f8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 866c0a9f2d | |||
| a281592541 | |||
| b0a4bd554e |
@@ -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.
|
||||
@@ -24,7 +24,7 @@ export function newCharacter(partial?: { name?: string; id?: string }): Characte
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
meta: {
|
||||
name,
|
||||
rasse: 'Mensch',
|
||||
rasse: 'mensch01',
|
||||
kultur: '',
|
||||
profession: ''
|
||||
},
|
||||
@@ -34,9 +34,9 @@ export function newCharacter(partial?: { name?: string; id?: string }): Characte
|
||||
aup: { mod: 0 },
|
||||
mr: { mod: 0 }
|
||||
},
|
||||
vorteile: [],
|
||||
nachteile: [],
|
||||
sonderfertigkeiten: [],
|
||||
advantages: [],
|
||||
disadvantages: [],
|
||||
abilities: [],
|
||||
talente: [],
|
||||
kampftalente: [],
|
||||
inventar: [],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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';
|
||||
import { getRaceDef } from '$lib/rules/races';
|
||||
|
||||
export function effectiveAttr(char: Character, key: AttributeKey): number {
|
||||
const a = char.eigenschaften[key];
|
||||
@@ -36,11 +36,13 @@ export function iniBasis(char: Character): number {
|
||||
return Math.round(v);
|
||||
}
|
||||
|
||||
/** MR-Basis = (MU + KL + KO) / 5 + MR-Mod aus Energien */
|
||||
/** MR-Basis = (MU + KL + KO) / 5 + MR-Mod aus Energien + Rassen-MR */
|
||||
export function mrBasis(char: Character): number {
|
||||
const race = getRaceDef(char.meta.rasse);
|
||||
const v =
|
||||
(effectiveAttr(char, 'MU') + effectiveAttr(char, 'KL') + effectiveAttr(char, 'KO')) / 5 +
|
||||
char.energien.mr.mod;
|
||||
char.energien.mr.mod +
|
||||
(race?.mr_mod ?? 0);
|
||||
return Math.round(v);
|
||||
}
|
||||
|
||||
@@ -49,20 +51,21 @@ 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;
|
||||
const race = getRaceDef(char.meta.rasse)?.lep_mod ?? 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 race = getRaceDef(char.meta.rasse)?.asp_mod ?? 0;
|
||||
const base = effectiveAttr(char, 'IN') + effectiveAttr(char, 'IN') + effectiveAttr(char, 'CH');
|
||||
return Math.max(0, base + race + char.energien.asp.mod);
|
||||
}
|
||||
|
||||
export function aupMax(char: Character): number {
|
||||
const race = getRaceDef(char.meta.rasse)?.aup_mod ?? 0;
|
||||
const base = effectiveAttr(char, 'MU') + effectiveAttr(char, 'MU') + effectiveAttr(char, 'IN');
|
||||
return Math.max(0, base + char.energien.aup.mod);
|
||||
return Math.max(0, base + race + char.energien.aup.mod);
|
||||
}
|
||||
|
||||
export type DerivedSheet = {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/** 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;
|
||||
};
|
||||
|
||||
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
|
||||
},
|
||||
{
|
||||
id: 'specialize_composite_bow',
|
||||
name: 'Talentspezialisierung Bogen (Kompositbogen)',
|
||||
at_mod: 0,
|
||||
pa_mod: 0,
|
||||
fk_mod: 2
|
||||
},
|
||||
{
|
||||
id: 'specialize_warbow',
|
||||
name: 'Talentspezialisierung Bogen (Kriegsbogen)',
|
||||
at_mod: 0,
|
||||
pa_mod: 0,
|
||||
fk_mod: 2
|
||||
},
|
||||
{
|
||||
id: 'specialize_shortbow',
|
||||
name: 'Talentspezialisierung Bogen (Kurzbogen)',
|
||||
at_mod: 0,
|
||||
pa_mod: 0,
|
||||
fk_mod: 2
|
||||
},
|
||||
{
|
||||
id: 'specialize_longbow',
|
||||
name: 'Talentspezialisierung Bogen (Langbogen)',
|
||||
at_mod: 0,
|
||||
pa_mod: 0,
|
||||
fk_mod: 2
|
||||
},
|
||||
{
|
||||
id: 'specialize_orc_bow',
|
||||
name: 'Talentspezialisierung Bogen (Ork. Reiterbogen)',
|
||||
at_mod: 0,
|
||||
pa_mod: 0,
|
||||
fk_mod: 2
|
||||
}
|
||||
];
|
||||
|
||||
export function getAbility(id: string): AbilityDef | undefined {
|
||||
return ABILITIES.find((s) => s.id === id);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+44
-24
@@ -1,26 +1,46 @@
|
||||
/** 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
|
||||
/** Rassen mit LeP-/AuP-/AsP-/MR-Modifikatoren (typische Werte DSA 4.1, vereinfacht) */
|
||||
export type RaceDef = {
|
||||
id: string;
|
||||
name: string;
|
||||
lep_mod: number;
|
||||
aup_mod: number;
|
||||
asp_mod: number;
|
||||
mr_mod: number;
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
export const RACES: RaceDef[] = [
|
||||
{ id: 'mensch01', name: 'Mittelländer', lep_mod: 10, aup_mod: 10, asp_mod: 0, mr_mod: -4 },
|
||||
{ id: 'mensch02', name: 'Tulamide', lep_mod: 10, aup_mod: 10, asp_mod: 0, mr_mod: -4 },
|
||||
{ id: 'mensch03', name: 'Thorwaler', lep_mod: 11, aup_mod: 10, asp_mod: 0, mr_mod: -5 },
|
||||
{ id: 'mensch04', name: 'Nivese', lep_mod: 9, aup_mod: 12, asp_mod: 0, mr_mod: -5 },
|
||||
{ id: 'mensch05', name: 'Norbarde', lep_mod: 11, aup_mod: 10, asp_mod: 0, mr_mod: -4 },
|
||||
{ id: 'mensch06', name: 'Trollzacker', lep_mod: 11, aup_mod: 18, asp_mod: 0, mr_mod: -5 },
|
||||
{ id: 'mensch07', name: 'Rochshaz', lep_mod: 12, aup_mod: 20, asp_mod: 0, mr_mod: -5 },
|
||||
{ id: 'mensch08', name: 'Waldmensch', lep_mod: 8, aup_mod: 12, asp_mod: 0, mr_mod: -6 },
|
||||
{ id: 'mensch09', name: 'Tocamuyac', lep_mod: 8, aup_mod: 12, asp_mod: 0, mr_mod: -6 },
|
||||
{ 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);
|
||||
}
|
||||
|
||||
@@ -14,13 +14,17 @@ const energySchema = z.object({
|
||||
|
||||
const traitSchema = z.object({
|
||||
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()
|
||||
});
|
||||
|
||||
const sfSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
defId: z.string().default(''),
|
||||
name: z.string().optional(),
|
||||
note: z.string().optional()
|
||||
});
|
||||
|
||||
@@ -108,9 +112,9 @@ export const characterSchema = z.object({
|
||||
aup: energySchema,
|
||||
mr: energySchema
|
||||
}),
|
||||
vorteile: z.array(traitSchema).default([]),
|
||||
nachteile: z.array(traitSchema).default([]),
|
||||
sonderfertigkeiten: z.array(sfSchema).default([]),
|
||||
advantages: z.array(traitSchema).default([]),
|
||||
disadvantages: z.array(traitSchema).default([]),
|
||||
abilities: z.array(sfSchema).default([]),
|
||||
talente: z.array(talentEntrySchema).default([]),
|
||||
kampftalente: z.array(combatTalentSchema).default([]),
|
||||
zauber: z.array(spellEntrySchema).optional(),
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
import type { Character } from '$lib/schema/character';
|
||||
import { ATTRIBUTE_KEYS, ATTRIBUTE_LABELS } from '$lib/rules/attributes';
|
||||
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 { ADVANTAGES, getAdvantage } from '$lib/rules/advantages';
|
||||
import { computeDerived } from '$lib/engine/derived';
|
||||
import { atBasis, paBasis } from '$lib/engine/derived';
|
||||
|
||||
@@ -54,21 +58,59 @@
|
||||
char = char;
|
||||
}
|
||||
|
||||
function addVorteil() {
|
||||
function addAdvantage() {
|
||||
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;
|
||||
}
|
||||
|
||||
function addNachteil() {
|
||||
function addDisadvantage() {
|
||||
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;
|
||||
}
|
||||
|
||||
function addSF() {
|
||||
function addAbility() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -116,7 +158,12 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<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 class="field">
|
||||
<label for="kultur">Kultur</label>
|
||||
@@ -226,7 +273,7 @@
|
||||
{#each char.talente as tal, i}
|
||||
<div class="inline">
|
||||
<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>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -246,56 +293,97 @@
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Vorteile</h2>
|
||||
{#each char.vorteile as v, i}
|
||||
{#each char.advantages as v, i}
|
||||
{@const vd = getAdvantage(v.defId)}
|
||||
<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
|
||||
type="button"
|
||||
class="btn danger"
|
||||
on:click={() => {
|
||||
char!.vorteile = char!.vorteile.filter((_, j) => j !== i);
|
||||
char!.advantages = char!.advantages.filter((_, j) => j !== i);
|
||||
char = char;
|
||||
}}>✕</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="btn" on:click={addVorteil}>Vorteil</button>
|
||||
<button type="button" class="btn" on:click={addAdvantage}>Vorteil</button>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Nachteile</h2>
|
||||
{#each char.nachteile as v, i}
|
||||
{#each char.disadvantages as v, i}
|
||||
{@const nd = getDisadvantage(v.defId)}
|
||||
<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
|
||||
type="button"
|
||||
class="btn danger"
|
||||
on:click={() => {
|
||||
char!.nachteile = char!.nachteile.filter((_, j) => j !== i);
|
||||
char!.disadvantages = char!.disadvantages.filter((_, j) => j !== i);
|
||||
char = char;
|
||||
}}>✕</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="btn" on:click={addNachteil}>Nachteil</button>
|
||||
<button type="button" class="btn" on:click={addDisadvantage}>Nachteil</button>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Sonderfertigkeiten</h2>
|
||||
{#each char.sonderfertigkeiten as s, i}
|
||||
{#each char.abilities as s, i}
|
||||
<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
|
||||
type="button"
|
||||
class="btn danger"
|
||||
on:click={() => {
|
||||
char!.sonderfertigkeiten = char!.sonderfertigkeiten.filter((_, j) => j !== i);
|
||||
char!.abilities = char!.abilities.filter((_, j) => j !== i);
|
||||
char = char;
|
||||
}}>✕</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="btn" on:click={addSF}>SF</button>
|
||||
<button type="button" class="btn" on:click={addAbility}>SF</button>
|
||||
</section>
|
||||
|
||||
<section class="card stack">
|
||||
|
||||
@@ -12,13 +12,13 @@ describe('derived', () => {
|
||||
|
||||
it('includes race LeP bonus for human', () => {
|
||||
const c = newCharacter({ name: 'H', id: '00000000-0000-4000-8000-000000000002' });
|
||||
c.meta.rasse = 'Mensch';
|
||||
c.meta.rasse = 'Mittelländer';
|
||||
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);
|
||||
expect(lepMax(c)).toBe(base + 10 + c.energien.lep.mod);
|
||||
});
|
||||
|
||||
it('computeDerived returns all keys', () => {
|
||||
|
||||
Reference in New Issue
Block a user