inventory

This commit is contained in:
Bastian Wagner
2026-08-21 12:52:44 +02:00
parent a376fb7128
commit 987242541d
34 changed files with 3569 additions and 1814 deletions

View File

@@ -19,6 +19,7 @@ function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
itemDefinition: { itemDefinition: {
key: 'worn-short-sword', key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert', name: 'Abgenutztes Kurzschwert',
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
rarity: ItemRarity.COMMON, rarity: ItemRarity.COMMON,
type: ItemType.WEAPON, type: ItemType.WEAPON,
equipmentSlot: EquipmentSlot.WEAPON, equipmentSlot: EquipmentSlot.WEAPON,
@@ -64,6 +65,7 @@ describe('InventoryService', () => {
item: { item: {
key: 'worn-short-sword', key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert', name: 'Abgenutztes Kurzschwert',
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
requiredLevel: 1, requiredLevel: 1,

View File

@@ -13,6 +13,7 @@ export interface InventoryItemDto {
item: { item: {
key: string; key: string;
name: string; name: string;
description: string;
rarity: ItemRarity; rarity: ItemRarity;
equipmentSlot: EquipmentSlot | null; equipmentSlot: EquipmentSlot | null;
requiredLevel: number; requiredLevel: number;
@@ -56,6 +57,7 @@ export class InventoryService {
item: { item: {
key: characterItem.itemDefinition.key, key: characterItem.itemDefinition.key,
name: characterItem.itemDefinition.name, name: characterItem.itemDefinition.name,
description: characterItem.itemDefinition.description,
rarity: characterItem.itemDefinition.rarity, rarity: characterItem.itemDefinition.rarity,
equipmentSlot: characterItem.itemDefinition.equipmentSlot, equipmentSlot: characterItem.itemDefinition.equipmentSlot,
requiredLevel: characterItem.itemDefinition.requiredLevel, requiredLevel: characterItem.itemDefinition.requiredLevel,

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -23,6 +23,7 @@ describe('App', () => {
{ path: 'location', children: [] }, { path: 'location', children: [] },
{ path: 'world', children: [] }, { path: 'world', children: [] },
{ path: 'hunt', children: [] }, { path: 'hunt', children: [] },
{ path: 'inventory', children: [] },
]), ]),
{ {
provide: WorldStore, provide: WorldStore,
@@ -112,6 +113,16 @@ describe('App', () => {
expect(fixture.nativeElement.querySelector('app-context-panel')).toBeNull(); expect(fixture.nativeElement.querySelector('app-context-panel')).toBeNull();
}); });
it('drops the shell context rail on /inventory, which needs all three of its own columns', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
await router.navigateByUrl('/inventory');
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-context-panel')).toBeNull();
});
it('keeps the shell context rail on the map', async () => { it('keeps the shell context rail on the map', async () => {
const fixture = TestBed.createComponent(AppShellComponent); const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router); const router = TestBed.inject(Router);

View File

@@ -228,6 +228,7 @@ export interface InventoryItem {
item: { item: {
key: string; key: string;
name: string; name: string;
description: string;
rarity: ItemRarity; rarity: ItemRarity;
equipmentSlot: EquipmentSlot | null; equipmentSlot: EquipmentSlot | null;
requiredLevel: number; requiredLevel: number;

View File

@@ -1,60 +1,85 @@
@if (item(); as item) { <section class="ar-panel detail" aria-label="Gewählter Gegenstand">
<article class="inventory-detail" aria-label="Gegenstandsdetails"> <h2 class="ar-panel__title">Gewählter Gegenstand</h2>
<div class="inventory-detail__header">
<img class="inventory-detail__icon" [src]="item.item.iconPath" [alt]="item.item.name" />
<div>
<h2 class="inventory-detail__name" data-detail-name>{{ item.item.name }}</h2>
<p class="inventory-detail__rarity" data-detail-rarity>{{ rarityLabel() }}</p>
@if (slotLabel(); as slot) {
<p class="inventory-detail__meta">{{ slot }} · Stufe {{ item.item.requiredLevel }}</p>
}
</div>
</div>
@if (statRows().length) { @if (item(); as item) {
<dl class="inventory-detail__stats" data-detail-stats> <article
@for (row of statRows(); track row.label) { class="detail__body"
<div class="inventory-detail__stat"> [class.detail__body--rare]="item.item.rarity === 'RARE'"
<dt>{{ row.label }}</dt> [class.detail__body--epic]="item.item.rarity === 'EPIC'"
>
<header class="detail__head">
<span class="detail__portrait">
<img [src]="item.item.iconPath" [alt]="item.item.name" />
</span>
<div class="detail__ident">
<h3 class="detail__name" data-detail-name>{{ item.item.name }}</h3>
<p class="detail__rarity" data-detail-rarity>{{ rarityLabel() }}</p>
<p class="detail__slot">{{ slotLabel() ?? typeLabel() }}</p>
</div>
</header>
@if (statRows().length) {
<dl class="detail__stats" data-detail-stats>
@for (row of statRows(); track row.label) {
<div class="detail__stat">
<dt>{{ row.label }}</dt>
<dd>
<span class="detail__value">{{ row.value }}</span>
@if (row.diff !== null && row.diff !== 0) {
<span
class="detail__diff"
[class.detail__diff--positive]="row.diff > 0"
[class.detail__diff--negative]="row.diff < 0"
>
({{ row.diff > 0 ? '+' : '' }}{{ row.diff }})
</span>
}
</dd>
</div>
}
</dl>
}
@if (isEquippable()) {
<dl class="detail__meta">
<div class="detail__stat">
<dt>Benötigte Stufe</dt>
<dd> <dd>
{{ row.value }} <span class="detail__value" [class.detail__value--unmet]="!meetsLevelRequirement()">
@if (row.diff !== null && row.diff !== 0) { {{ item.item.requiredLevel }}
<span </span>
class="inventory-detail__diff"
[class.inventory-detail__diff--positive]="row.diff > 0"
[class.inventory-detail__diff--negative]="row.diff < 0"
>
({{ row.diff > 0 ? '+' : '' }}{{ row.diff }})
</span>
}
</dd> </dd>
</div> </div>
} </dl>
</dl>
}
<div class="inventory-detail__actions">
@if (item.equipped) {
<span class="inventory-detail__equipped" data-detail-equipped>Ausgerüstet</span>
} @else if (!isEquippable()) {
<span class="inventory-detail__note">Nicht ausrüstbar</span>
} @else if (!meetsLevelRequirement()) {
<button type="button" class="inventory-detail__equip" data-detail-equip disabled>
Benötigt Stufe {{ item.item.requiredLevel }}
</button>
} @else {
<button
type="button"
class="inventory-detail__equip"
data-detail-equip
[disabled]="busy()"
(click)="onEquip()"
>
Ausrüsten
</button>
} }
</div>
</article> @if (item.item.description) {
} @else { <p class="detail__flavour">{{ item.item.description }}</p>
<p class="inventory-detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p> }
}
<div class="detail__actions">
@if (item.equipped) {
<span class="detail__equipped" data-detail-equipped>Ausgerüstet</span>
} @else if (!isEquippable()) {
<span class="detail__note">Nicht ausrüstbar</span>
} @else if (!meetsLevelRequirement()) {
<button type="button" class="detail__equip" data-detail-equip disabled>
Benötigt Stufe {{ item.item.requiredLevel }}
</button>
} @else {
<button
type="button"
class="detail__equip"
data-detail-equip
[disabled]="busy()"
(click)="onEquip()"
>
Ausrüsten
</button>
}
</div>
</article>
} @else {
<p class="detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
}
</section>

View File

@@ -2,99 +2,177 @@
display: block; display: block;
} }
.inventory-detail { .detail {
padding: var(--ar-space-4); block-size: 100%;
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
background:
linear-gradient(125deg, rgb(255 255 255 / 0.045), transparent 42%), rgb(12 15 17 / 0.96);
box-shadow: var(--ar-shadow-raised);
} }
.inventory-detail__header { .detail__body {
display: grid;
gap: var(--ar-space-3);
/* Rarity tints the name and the icon frame, nothing else — the numbers stay
the loudest thing in the panel. */
--detail-rarity: var(--ar-text);
--detail-rarity-edge: var(--ar-border-highlight);
}
.detail__body--rare {
--detail-rarity: var(--ar-blue);
--detail-rarity-edge: var(--ar-blue);
}
.detail__body--epic {
--detail-rarity: var(--ar-gold);
--detail-rarity-edge: var(--ar-gold);
}
/* ---------- identity ---------- */
.detail__head {
display: flex; display: flex;
gap: var(--ar-space-3); gap: var(--ar-space-3);
align-items: center; align-items: flex-start;
margin-block-end: var(--ar-space-3);
} }
.inventory-detail__icon { .detail__portrait {
inline-size: 4rem; display: grid;
block-size: 4rem; flex: 0 0 auto;
padding: var(--ar-space-1); place-items: center;
border: 1px solid var(--ar-border); inline-size: 4.25rem;
block-size: 4.25rem;
padding: 0.3rem;
border: 1px solid var(--detail-rarity-edge);
background: linear-gradient(180deg, #1b1f22, #0d1012); background: linear-gradient(180deg, #1b1f22, #0d1012);
box-shadow: inset 0 0.15rem 0.6rem rgb(0 0 0 / 0.8);
}
.detail__portrait img {
inline-size: 100%;
block-size: 100%;
object-fit: contain; object-fit: contain;
} }
.inventory-detail__name { .detail__ident {
margin: 0; min-inline-size: 0;
color: var(--ar-text); padding-block-start: 0.15rem;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.15rem;
font-weight: 400;
} }
.inventory-detail__rarity { .detail__name {
margin: 0;
color: var(--detail-rarity);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.1rem;
font-weight: 400;
line-height: 1.2;
}
.detail__rarity {
margin: 0.2rem 0 0;
color: var(--detail-rarity);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.85;
}
.detail__slot {
margin: 0.15rem 0 0; margin: 0.15rem 0 0;
color: var(--ar-text-muted); color: var(--ar-text-muted);
font-size: var(--ar-font-sm); font-size: var(--ar-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
} }
.inventory-detail__meta { /* ---------- numbers ---------- */
margin: 0.25rem 0 0;
.detail__stats,
.detail__meta {
display: grid;
gap: 0.35rem;
margin: 0;
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
}
.detail__stat {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--ar-space-3);
}
.detail__stat dt {
color: var(--ar-text-muted); color: var(--ar-text-muted);
font-size: var(--ar-font-sm); font-size: var(--ar-font-sm);
} }
.inventory-detail__stats { .detail__stat dd {
display: grid;
gap: var(--ar-space-2);
margin: 0 0 var(--ar-space-3);
padding-block: var(--ar-space-2);
border-block: 1px solid rgb(155 122 66 / 0.45);
}
.inventory-detail__stat {
display: flex; display: flex;
justify-content: space-between; gap: 0.4rem;
} align-items: baseline;
.inventory-detail__stat dt {
color: var(--ar-text-muted);
}
.inventory-detail__stat dd {
margin: 0; margin: 0;
white-space: nowrap;
}
.detail__value {
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
font-variant-numeric: tabular-nums;
} }
.inventory-detail__diff--positive { .detail__value--unmet {
color: var(--ar-success);
}
.inventory-detail__diff--negative {
color: var(--ar-danger); color: var(--ar-danger);
} }
.inventory-detail__actions { /* The sign is carried by the text, never by colour alone (spec §52). */
display: flex; .detail__diff {
justify-content: center; font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
} }
.inventory-detail__equipped { .detail__diff--positive {
color: var(--ar-success);
}
.detail__diff--negative {
color: var(--ar-danger);
}
/* ---------- flavour ---------- */
.detail__flavour {
margin: 0;
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
color: var(--ar-text-muted);
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--ar-font-sm);
font-style: italic;
line-height: 1.5;
}
/* ---------- action ---------- */
.detail__actions {
display: grid;
justify-items: center;
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
}
.detail__equipped {
color: var(--ar-gold); color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
letter-spacing: 0.08em;
} }
.inventory-detail__note { .detail__note {
color: var(--ar-text-muted); color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-style: italic; font-style: italic;
} }
.inventory-detail__equip { /* Same steel-blue plate as the travel button: the one committing action. */
.detail__equip {
inline-size: 100%; inline-size: 100%;
padding: var(--ar-space-2) var(--ar-space-4); padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-border-highlight); border: 1px solid var(--ar-border-highlight);
@@ -103,22 +181,25 @@
background: linear-gradient(180deg, #263b4b, #17232d); background: linear-gradient(180deg, #263b4b, #17232d);
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem; font-size: 1rem;
letter-spacing: 0.06em;
} }
.inventory-detail__equip:hover:not(:disabled) { .detail__equip:hover:not(:disabled) {
border-color: #d6b26b; border-color: #d6b26b;
background: linear-gradient(180deg, #315067, #1a2c3a); background: linear-gradient(180deg, #315067, #1a2c3a);
} }
.inventory-detail__equip:disabled { .detail__equip:disabled {
border-color: var(--ar-border); border-color: var(--ar-border);
color: var(--ar-text-muted); color: var(--ar-text-muted);
background: #1a1c1d; background: #1a1c1d;
} }
.inventory-detail__empty { .detail__empty {
padding: var(--ar-space-4); margin: 0;
padding-block: var(--ar-space-5);
color: var(--ar-text-muted); color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-style: italic; font-style: italic;
text-align: center; text-align: center;
} }

View File

@@ -10,6 +10,7 @@ const wornSword: InventoryItem = {
item: { item: {
key: 'worn-short-sword', key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert', name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
requiredLevel: 1, requiredLevel: 1,
@@ -28,6 +29,7 @@ const banditBlade: InventoryItem = {
item: { item: {
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
requiredLevel: 1, requiredLevel: 1,
@@ -70,6 +72,19 @@ describe('InventoryDetailPanelComponent', () => {
expect(element.querySelector('[data-detail-equip]')).toBeNull(); expect(element.querySelector('[data-detail-equip]')).toBeNull();
}); });
it('shows the item flavour text from the server', async () => {
const fixture = await setup({
item: {
...banditBlade,
item: { ...banditBlade.item, description: 'Eine grob gezahnte Klinge.' },
},
});
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Eine grob gezahnte Klinge.',
);
});
it('shows the stat comparison against the equipped item in the same slot', async () => { it('shows the stat comparison against the equipped item in the same slot', async () => {
const fixture = await setup({ item: banditBlade, equippedItemInSlot: wornSword }); const fixture = await setup({ item: banditBlade, equippedItemInSlot: wornSword });
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-stats]')?.textContent ?? ''; const text = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-stats]')?.textContent ?? '';

View File

@@ -1,16 +1,7 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import type { EquipmentSlot, InventoryItem } from '../../core/api/game-api.models'; import type { InventoryItem } from '../../core/api/game-api.models';
import { RARITY_LABELS } from '../../shared/item-card/item-card.component'; import { RARITY_LABELS } from '../../shared/item-card/item-card.component';
import { SLOT_LABELS } from './inventory.labels';
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
WEAPON: 'Waffe',
HEAD: 'Kopf',
CHEST: 'Brust',
HANDS: 'Handschuhe',
LEGS: 'Beine',
FEET: 'Stiefel',
AMULET: 'Amulett',
};
interface StatRow { interface StatRow {
label: string; label: string;
@@ -49,6 +40,9 @@ export class InventoryDetailPanelComponent {
return slot ? SLOT_LABELS[slot] : null; return slot ? SLOT_LABELS[slot] : null;
}); });
/** Stand-in for items with no slot: the API does not expose an item type. */
protected readonly typeLabel = computed(() => 'Gegenstand');
protected readonly statRows = computed<StatRow[]>(() => { protected readonly statRows = computed<StatRow[]>(() => {
const item = this.item(); const item = this.item();
if (!item) { if (!item) {

View File

@@ -1,59 +1,123 @@
<!-- apps/web/src/app/features/inventory/inventory-page.component.html -->
<section class="inventory-page" aria-label="Inventar"> <section class="inventory-page" aria-label="Inventar">
<h1 class="inventory-page__title">Inventar</h1>
@if (inventoryStore.loading() && !inventoryStore.inventory()) { @if (inventoryStore.loading() && !inventoryStore.inventory()) {
<p class="inventory-page__notice" role="status">Inventar wird geladen…</p> <p class="inventory-page__notice" role="status">Inventar wird geladen…</p>
} @else if (inventoryStore.inventory(); as inventory) { } @else if (inventoryStore.inventory(); as inventory) {
<section class="inventory-page__grid" aria-label="Gegenstände"> <div class="inventory-page__columns">
@for (entry of inventory.items; track entry.id) { <!-- ---------- paper doll + derived stats ---------- -->
<button <div class="inventory-page__rail">
type="button" <section class="ar-panel panel--doll" aria-label="Ausrüstung">
class="inventory-page__slot" <h2 class="ar-panel__title">Ausrüstung</h2>
[class.inventory-page__slot--selected]="inventoryStore.selectedItemId() === entry.id"
[attr.aria-pressed]="inventoryStore.selectedItemId() === entry.id"
(click)="selectItem(entry.id)"
>
<app-item-card [item]="entry.item" [quantity]="entry.quantity" />
@if (entry.equipped) {
<span class="inventory-page__equipped-badge" data-slot-equipped>Ausgerüstet</span>
}
</button>
} @empty {
<p class="inventory-page__empty" data-inventory-empty>Noch keine Gegenstände gefunden.</p>
}
</section>
<aside class="inventory-page__side" aria-label="Details und Ausrüstung"> @if (inventoryStore.equipment(); as equipment) {
<div class="doll inventory-page__equipment-list">
<div class="doll__column">
@for (slot of dollLeft; track slot) {
<ng-container
*ngTemplateOutlet="dollSlot; context: { label: slotLabels[slot], entry: equipment.slots[slot] }"
/>
}
</div>
<div class="doll__figure" role="img" aria-label="Ausgerüsteter Charakter"></div>
<div class="doll__column">
@for (slot of dollRight; track slot) {
<ng-container
*ngTemplateOutlet="dollSlot; context: { label: slotLabels[slot], entry: equipment.slots[slot] }"
/>
}
</div>
<div class="doll__below">
<ng-container
*ngTemplateOutlet="
dollSlot;
context: { label: slotLabels[dollBelow], entry: equipment.slots[dollBelow] }
"
/>
</div>
</div>
}
</section>
@if (inventoryStore.equipment(); as equipment) {
<section class="ar-panel panel--values" aria-label="Werte">
<h2 class="ar-panel__title">Werte</h2>
<dl class="values" data-inventory-stats>
<div class="values__row">
<dt><span class="values__glyph" aria-hidden="true"></span>Leben</dt>
<dd>{{ equipment.stats.maxHp }}</dd>
</div>
<div class="values__row">
<dt><span class="values__glyph" aria-hidden="true"></span>Angriff</dt>
<dd>{{ equipment.stats.attack }}</dd>
</div>
<div class="values__row">
<dt><span class="values__glyph" aria-hidden="true"></span>Waffenschaden</dt>
<dd>{{ equipment.stats.weaponDamage }}</dd>
</div>
<div class="values__row">
<dt><span class="values__glyph" aria-hidden="true"></span>Rüstung</dt>
<dd>{{ equipment.stats.armor }}</dd>
</div>
</dl>
</section>
}
</div>
<!-- ---------- the bag ---------- -->
<section class="ar-panel panel--bag" aria-label="Gegenstände">
<h2 class="ar-panel__title">Beutel</h2>
<div class="bag">
@for (entry of bagCells(); track $index) {
@if (entry) {
<button
type="button"
class="cell inventory-page__slot"
[class.cell--selected]="inventoryStore.selectedItemId() === entry.id"
[class.cell--rare]="entry.item.rarity === 'RARE'"
[class.cell--epic]="entry.item.rarity === 'EPIC'"
[attr.aria-pressed]="inventoryStore.selectedItemId() === entry.id"
[attr.aria-label]="entry.item.name"
(click)="selectItem(entry.id)"
>
<img class="cell__icon" [src]="entry.item.iconPath" alt="" />
@if (entry.quantity > 1) {
<span class="cell__quantity">{{ entry.quantity }}</span>
}
@if (entry.equipped) {
<span class="cell__equipped" data-slot-equipped>
<span class="visually-hidden">Ausgerüstet</span>
</span>
}
</button>
} @else {
<div class="cell cell--empty" aria-hidden="true"></div>
}
}
</div>
<footer class="bag__footer">
<span class="bag__count">{{ bagUsed() }} / {{ bagCapacity() }} Plätze belegt</span>
@if (bagUsed() === 0) {
<span class="bag__hint" data-inventory-empty>Noch keine Gegenstände gefunden.</span>
}
</footer>
</section>
<!-- ---------- selected item ---------- -->
<app-inventory-detail-panel <app-inventory-detail-panel
class="inventory-page__detail"
[item]="inventoryStore.selectedItem()" [item]="inventoryStore.selectedItem()"
[equippedItemInSlot]="equippedItemInSelectedSlot()" [equippedItemInSlot]="equippedItemInSelectedSlot()"
[characterLevel]="characterLevel()" [characterLevel]="characterLevel()"
[busy]="inventoryStore.equipping()" [busy]="inventoryStore.equipping()"
(equip)="equipSelected($event)" (equip)="equipSelected($event)"
/> />
</div>
@if (inventoryStore.equipment(); as equipment) {
<section class="inventory-page__equipment" aria-label="Ausrüstung">
<h3>Ausrüstung</h3>
<ul class="inventory-page__equipment-list">
@for (slot of slotOrder; track slot) {
<li>
<span class="inventory-page__equipment-slot-label">{{ slotLabels[slot] }}</span>
<span class="inventory-page__equipment-slot-value">
{{ equipment.slots[slot]?.item?.name ?? 'Leer' }}
</span>
</li>
}
</ul>
<dl class="inventory-page__stats" data-inventory-stats>
<div><dt>Leben</dt><dd>{{ equipment.stats.maxHp }}</dd></div>
<div><dt>Angriff</dt><dd>{{ equipment.stats.attack }}</dd></div>
<div><dt>Waffenschaden</dt><dd>{{ equipment.stats.weaponDamage }}</dd></div>
<div><dt>Rüstung</dt><dd>{{ equipment.stats.armor }}</dd></div>
</dl>
</section>
}
</aside>
} }
@if (inventoryStore.error(); as error) { @if (inventoryStore.error(); as error) {
@@ -63,3 +127,29 @@
</section> </section>
} }
</section> </section>
<!-- One equipment socket: label above, framed icon below. Filled sockets are
buttons so the equipped piece can be inspected like any other item. -->
<ng-template #dollSlot let-label="label" let-entry="entry">
<div class="socket">
<span class="socket__label">{{ label }}</span>
@if (entry) {
<button
type="button"
class="socket__frame socket__frame--filled"
[class.socket__frame--rare]="entry.item.rarity === 'RARE'"
[class.socket__frame--epic]="entry.item.rarity === 'EPIC'"
[class.socket__frame--selected]="inventoryStore.selectedItemId() === entry.characterItemId"
[attr.aria-label]="label + ': ' + entry.item.name"
(click)="selectItem(entry.characterItemId)"
>
<img [src]="entry.item.iconPath" alt="" />
</button>
<span class="visually-hidden">{{ entry.item.name }}</span>
} @else {
<span class="socket__frame socket__frame--empty">
<span class="visually-hidden">Leer</span>
</span>
}
</div>
</ng-template>

View File

@@ -1,121 +1,309 @@
// apps/web/src/app/features/inventory/inventory-page.component.scss
:host { :host {
display: block; display: block;
block-size: 100%;
} }
/* Fits the viewport instead of growing it, so the bag scrolls inside its own
panel rather than dragging the whole screen down. Same measure the combat
stage uses for the shell chrome above and below. */
.inventory-page { .inventory-page {
display: grid;
grid-template-columns: 1fr 20rem;
gap: var(--ar-space-5);
align-items: start;
padding: var(--ar-space-5);
}
.inventory-page__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: var(--ar-space-3);
}
.inventory-page__slot {
position: relative;
padding: var(--ar-space-2);
border: 1px solid transparent;
border-radius: var(--ar-radius-sm);
background: transparent;
}
.inventory-page__slot--selected {
border-color: var(--ar-border-highlight);
background: rgb(155 122 66 / 0.12);
}
.inventory-page__equipped-badge {
position: absolute;
inset-block-start: 0.1rem;
inset-inline-start: 50%;
translate: -50% 0;
padding: 0.05rem 0.4rem;
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-gold);
background: rgb(9 11 13 / 0.9);
font-size: 0.65rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.inventory-page__empty {
grid-column: 1 / -1;
padding: var(--ar-space-4);
color: var(--ar-text-muted);
font-style: italic;
}
.inventory-page__side {
display: grid;
gap: var(--ar-space-4);
}
.inventory-page__equipment {
padding: var(--ar-space-4);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel);
}
.inventory-page__equipment h3 {
margin: 0 0 var(--ar-space-2);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.95rem;
font-weight: 400;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.inventory-page__equipment-list {
display: grid;
gap: var(--ar-space-1);
margin: 0 0 var(--ar-space-3);
padding: 0;
list-style: none;
}
.inventory-page__equipment-list li {
display: flex; display: flex;
flex-direction: column;
block-size: max(34rem, calc(100dvh - 12rem));
min-block-size: 0;
overflow: hidden;
}
.inventory-page__title {
margin: 0 0 var(--ar-space-4);
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.5rem, 2.6vw, 2rem);
font-weight: 400;
letter-spacing: 0.04em;
text-shadow: 0 0.1rem 0.6rem rgb(0 0 0 / 0.8);
}
/* Three rails: what you wear, what you carry, what you are looking at. */
.inventory-page__columns {
display: grid;
flex: 1 1 auto;
grid-template-columns: minmax(18rem, 21rem) minmax(0, 1fr) minmax(16rem, 19rem);
gap: var(--ar-space-4);
align-items: stretch;
min-block-size: 0;
}
/* The doll takes the slack; the derived values sit under it like a plate. */
.inventory-page__rail {
display: grid;
grid-template-rows: 1fr auto;
gap: var(--ar-space-4);
min-block-size: 0;
}
/* Panel chrome (.ar-panel) lives in styles.scss — every screen shares it. */
.panel--doll {
display: grid;
grid-template-rows: auto 1fr;
min-block-size: 0;
}
/* Title, cells, then the count pinned to the bottom edge. */
.panel--bag {
display: grid;
grid-template-rows: auto auto 1fr;
min-block-size: 0;
}
/* ---------- paper doll ---------- */
.doll {
display: grid;
grid-template-columns: auto minmax(3.5rem, 1fr) auto;
grid-template-rows: minmax(0, 1fr) auto;
gap: var(--ar-space-2) var(--ar-space-3);
min-block-size: 0;
}
.doll__column {
display: grid;
grid-row: 1;
gap: var(--ar-space-3);
align-content: center;
}
/* The same warrior that fights on the combat screen stands here wearing the
result. Frame 0 of the six-frame attack sheet is the resting stance; the box
is kept square so that frame lands at its own proportions and never smears. */
.doll__figure {
grid-row: 1;
grid-column: 2;
z-index: 0;
place-self: center;
aspect-ratio: 1;
/* Wider than its column: the character stands behind the sockets rather than
squeezed between them, as in the concept. */
inline-size: 165%;
max-block-size: 100%;
pointer-events: none;
background-image: url('/images/combat/sprites/warrior-attack-sheet-384.png');
background-repeat: no-repeat;
background-position: left center;
background-size: 600% 100%;
filter: drop-shadow(0 0.4rem 0.9rem rgb(0 0 0 / 0.75));
}
.doll__below {
grid-row: 2;
grid-column: 2;
justify-self: center;
}
.socket {
position: relative;
z-index: 1;
display: grid;
gap: 0.15rem;
justify-items: center;
}
.socket__label {
color: var(--ar-text-muted);
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.socket__frame {
display: grid;
place-items: center;
inline-size: 3.35rem;
block-size: 3.35rem;
padding: 0.2rem;
border: 1px solid var(--ar-border);
background: linear-gradient(180deg, #1b1f22, #0d1012);
box-shadow: inset 0 0.15rem 0.5rem rgb(0 0 0 / 0.75);
}
.socket__frame img {
inline-size: 100%;
block-size: 100%;
object-fit: contain;
}
/* An empty socket is a recess, not a pattern — quiet enough that a filled
one is what the eye lands on. */
.socket__frame--empty {
border-color: rgb(85 74 57 / 0.55);
background: linear-gradient(180deg, #141719, #0a0d0f);
}
.socket__frame--filled {
border-color: var(--ar-border-highlight);
}
.socket__frame--filled:hover {
border-color: var(--ar-gold);
}
.socket__frame--rare {
border-color: var(--ar-blue);
}
.socket__frame--epic {
border-color: var(--ar-gold);
}
.socket__frame--selected {
outline: 1px solid var(--ar-blue);
outline-offset: 1px;
}
/* ---------- values ---------- */
.values {
display: grid;
gap: 0.1rem;
margin: 0;
}
.values__row {
display: flex;
align-items: baseline;
justify-content: space-between; justify-content: space-between;
padding-block: var(--ar-space-1); gap: var(--ar-space-3);
border-block-end: 1px solid rgb(85 74 57 / 0.4); padding-block: 0.3rem;
}
.values__row + .values__row {
border-block-start: 1px solid rgb(85 74 57 / 0.32);
}
.values dt {
display: flex;
align-items: baseline;
gap: var(--ar-space-2);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm); font-size: var(--ar-font-sm);
} }
.inventory-page__equipment-slot-label { .values__glyph {
color: var(--ar-text-muted); color: var(--ar-border-highlight);
font-size: 0.8rem;
} }
.inventory-page__stats { .values dd {
display: grid;
gap: var(--ar-space-1);
margin: 0;
padding-block-start: var(--ar-space-2);
border-block-start: 1px solid rgb(155 122 66 / 0.45);
}
.inventory-page__stats div {
display: flex;
justify-content: space-between;
}
.inventory-page__stats dt {
color: var(--ar-text-muted);
}
.inventory-page__stats dd {
margin: 0; margin: 0;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
font-size: 1.05rem;
font-variant-numeric: tabular-nums;
} }
/* ---------- the bag ---------- */
.bag {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: var(--ar-space-2);
align-content: start;
min-block-size: 0;
overflow-y: auto;
scrollbar-color: var(--ar-border) transparent;
scrollbar-width: thin;
/* Room for the scrollbar so cells never sit under it. */
padding-inline-end: var(--ar-space-2);
}
.cell {
position: relative;
aspect-ratio: 1;
padding: 0.42rem;
border: 1px solid var(--ar-border);
background: linear-gradient(180deg, #1b1f22, #0d1012);
box-shadow: inset 0 0.15rem 0.5rem rgb(0 0 0 / 0.7);
}
.cell--empty {
border-color: rgb(85 74 57 / 0.45);
background: linear-gradient(180deg, #141719, #0a0d0f);
}
.cell.inventory-page__slot:hover {
border-color: var(--ar-border-highlight);
}
.cell--rare {
border-color: rgb(92 169 216 / 0.75);
}
.cell--epic {
border-color: rgb(201 164 95 / 0.8);
}
.cell--selected {
border-color: var(--ar-blue);
box-shadow:
inset 0 0.15rem 0.5rem rgb(0 0 0 / 0.7),
0 0 0 1px var(--ar-blue),
0 0 0.7rem rgb(92 169 216 / 0.45);
}
.cell__icon {
inline-size: 100%;
block-size: 100%;
object-fit: contain;
}
.cell__quantity {
position: absolute;
inset-block-end: 0.1rem;
inset-inline-end: 0.25rem;
color: var(--ar-text);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
text-shadow: 0 0 0.3rem #000, 0 0 0.2rem #000;
}
/* Equipped pieces live on the doll; in the bag they carry a quiet gold notch. */
.cell__equipped {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
inline-size: 0;
block-size: 0;
border-block-start: 0.55rem solid var(--ar-gold);
border-inline-end: 0.55rem solid transparent;
}
.bag__footer {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-3);
align-items: end;
justify-content: space-between;
align-self: end;
inline-size: 100%;
margin-block-start: var(--ar-space-4);
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.bag__count {
font-variant-numeric: tabular-nums;
}
.bag__hint {
font-style: italic;
}
/* ---------- notices ---------- */
.inventory-page__notice { .inventory-page__notice {
padding: var(--ar-space-4); padding: var(--ar-space-4);
color: var(--ar-text-muted); color: var(--ar-text-muted);
@@ -123,11 +311,86 @@
} }
.inventory-page__notice--error { .inventory-page__notice--error {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-4);
align-items: center;
justify-content: space-between;
margin-block-start: var(--ar-space-4);
border: 1px solid var(--ar-danger);
background: var(--ar-panel);
color: var(--ar-danger); color: var(--ar-danger);
text-align: start;
} }
@media (width < 960px) { .inventory-page__notice--error p {
margin: 0;
}
.inventory-page__notice--error button {
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: linear-gradient(180deg, #23282c, #14181b);
font-family: Georgia, 'Times New Roman', serif;
}
.inventory-page__notice--error button:hover {
border-color: var(--ar-gold);
color: var(--ar-gold);
}
/* ---------- responsive ---------- */
/* Once the columns start stacking the screen is taller than the viewport, so
the page goes back to scrolling as a whole and the bag stops scrolling
inside itself — two nested scrollers would fight each other. */
@media (width < 76rem) {
.inventory-page { .inventory-page {
grid-template-columns: 1fr; block-size: auto;
overflow: visible;
}
.inventory-page__columns {
grid-template-columns: minmax(16rem, 18rem) minmax(0, 1fr);
align-items: start;
}
.inventory-page__detail {
grid-column: 1 / -1;
}
.bag {
grid-template-columns: repeat(6, minmax(0, 1fr));
overflow-y: visible;
}
.panel--doll,
.panel--bag {
grid-template-rows: none;
}
.doll {
grid-template-rows: none;
}
.doll__figure {
inline-size: 100%;
min-block-size: 12rem;
}
}
@media (width < 52rem) {
.inventory-page {
padding: var(--ar-space-3);
}
.inventory-page__columns {
grid-template-columns: minmax(0, 1fr);
}
.bag {
grid-template-columns: repeat(5, minmax(0, 1fr));
} }
} }

View File

@@ -17,6 +17,7 @@ const inventory: InventoryResponse = {
item: { item: {
key: 'worn-short-sword', key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert', name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
requiredLevel: 1, requiredLevel: 1,
@@ -34,6 +35,7 @@ const inventory: InventoryResponse = {
item: { item: {
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
requiredLevel: 1, requiredLevel: 1,
@@ -176,6 +178,7 @@ describe('InventoryPageComponent', () => {
item: { item: {
key: 'iron-helm', key: 'iron-helm',
name: 'Eiserner Helm', name: 'Eiserner Helm',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'HEAD', equipmentSlot: 'HEAD',
requiredLevel: 1, requiredLevel: 1,

View File

@@ -1,44 +1,61 @@
import { NgTemplateOutlet } from '@angular/common';
import { Component, OnInit, computed, inject } from '@angular/core'; import { Component, OnInit, computed, inject } from '@angular/core';
import type { EquipmentSlot } from '../../core/api/game-api.models'; import type { EquipmentSlot, InventoryItem } from '../../core/api/game-api.models';
import { ItemCardComponent } from '../../shared/item-card/item-card.component';
import { WorldStore } from '../world/world.store'; import { WorldStore } from '../world/world.store';
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component'; import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
import { SLOT_LABELS } from './inventory.labels';
import { InventoryStore } from './inventory.store'; import { InventoryStore } from './inventory.store';
const SLOT_ORDER: readonly EquipmentSlot[] = [ /**
'WEAPON', * The paper doll reads as a body: weapon hand and worn trinkets down the left,
'HEAD', * armour down the right, legs beneath the figure. Only the seven slots the
'CHEST', * game actually models appear — no decorative rings or offhand (spec §38).
'HANDS', */
'LEGS', const DOLL_LEFT: readonly EquipmentSlot[] = ['WEAPON', 'AMULET', 'HANDS'];
'FEET', const DOLL_RIGHT: readonly EquipmentSlot[] = ['HEAD', 'CHEST', 'FEET'];
'AMULET', const DOLL_BELOW: EquipmentSlot = 'LEGS';
];
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = { // Every slot, in the order the equipment list announces them.
WEAPON: 'Waffe', const SLOT_ORDER: readonly EquipmentSlot[] = [...DOLL_LEFT, ...DOLL_RIGHT, DOLL_BELOW];
HEAD: 'Kopf',
CHEST: 'Brust', // The bag is drawn as a fixed grid so it reads as a container with room left,
HANDS: 'Handschuhe', // not as a list that happens to be short. Capacity is not enforced yet
LEGS: 'Beine', // (spec §31): the empty cells are structure, not a limit.
FEET: 'Stiefel', const BAG_COLUMNS = 8;
AMULET: 'Amulett', const BAG_MIN_CELLS = 64;
};
@Component({ @Component({
selector: 'app-inventory-page', selector: 'app-inventory-page',
imports: [ItemCardComponent, InventoryDetailPanelComponent], imports: [NgTemplateOutlet, InventoryDetailPanelComponent],
templateUrl: './inventory-page.component.html', templateUrl: './inventory-page.component.html',
styleUrl: './inventory-page.component.scss', styleUrl: './inventory-page.component.scss',
}) })
export class InventoryPageComponent implements OnInit { export class InventoryPageComponent implements OnInit {
protected readonly inventoryStore = inject(InventoryStore); protected readonly inventoryStore = inject(InventoryStore);
private readonly worldStore = inject(WorldStore); private readonly worldStore = inject(WorldStore);
protected readonly dollLeft = DOLL_LEFT;
protected readonly dollRight = DOLL_RIGHT;
protected readonly dollBelow = DOLL_BELOW;
protected readonly slotOrder = SLOT_ORDER; protected readonly slotOrder = SLOT_ORDER;
protected readonly slotLabels = SLOT_LABELS; protected readonly slotLabels = SLOT_LABELS;
protected readonly characterLevel = computed(() => this.worldStore.character()?.level ?? 1); protected readonly characterLevel = computed(() => this.worldStore.character()?.level ?? 1);
/**
* Pads the owned items out to a full rectangle of cells. `null` is an empty
* slot; the grid never ends on a ragged row.
*/
protected readonly bagCells = computed<(InventoryItem | null)[]>(() => {
const items = this.inventoryStore.inventory()?.items ?? [];
const filled = Math.ceil(items.length / BAG_COLUMNS) * BAG_COLUMNS;
const total = Math.max(BAG_MIN_CELLS, filled);
return Array.from({ length: total }, (_, index) => items[index] ?? null);
});
protected readonly bagUsed = computed(() => this.inventoryStore.inventory()?.items.length ?? 0);
protected readonly bagCapacity = computed(() => this.bagCells().length);
protected readonly equippedItemInSelectedSlot = computed(() => { protected readonly equippedItemInSelectedSlot = computed(() => {
const selected = this.inventoryStore.selectedItem(); const selected = this.inventoryStore.selectedItem();
if (!selected?.item.equipmentSlot) { if (!selected?.item.equipmentSlot) {
@@ -62,11 +79,11 @@ export class InventoryPageComponent implements OnInit {
this.inventoryStore.selectItem(itemId); this.inventoryStore.selectItem(itemId);
} }
protected async equipSelected(characterItemId: string): Promise<void> {
await this.inventoryStore.equip(characterItemId);
}
protected retry(): void { protected retry(): void {
void this.inventoryStore.load(); void this.inventoryStore.load();
} }
protected async equipSelected(characterItemId: string): Promise<void> {
await this.inventoryStore.equip(characterItemId);
}
} }

View File

@@ -0,0 +1,12 @@
import type { EquipmentSlot } from '../../core/api/game-api.models';
/** German slot names, shared by the paper doll and the item detail panel. */
export const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
WEAPON: 'Waffe',
HEAD: 'Kopf',
CHEST: 'Brust',
HANDS: 'Handschuhe',
LEGS: 'Beine',
FEET: 'Stiefel',
AMULET: 'Amulett',
};

View File

@@ -16,6 +16,7 @@ const inventory: InventoryResponse = {
item: { item: {
key: 'worn-short-sword', key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert', name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
requiredLevel: 1, requiredLevel: 1,
@@ -33,6 +34,7 @@ const inventory: InventoryResponse = {
item: { item: {
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
requiredLevel: 1, requiredLevel: 1,

View File

@@ -32,5 +32,10 @@ export class AppShellComponent {
// squeeze the artwork the screen is built around. // squeeze the artwork the screen is built around.
private readonly atLocation = isActive('/location', this.router); private readonly atLocation = isActive('/location', this.router);
protected readonly showContextPanel = () => !this.inCombat() && !this.atLocation(); // The inventory is itself three columns wide — doll, bag, selected item —
// and the area panel would push the bag down to a couple of cells a row.
private readonly atInventory = isActive('/inventory', this.router);
protected readonly showContextPanel = () =>
!this.inCombat() && !this.atLocation() && !this.atInventory();
} }

View File

@@ -1,38 +1,57 @@
/* The rail is the painted `sidepanel-left.png`: an ornate frame with six button
plates down the top and a dragon crest at the foot. The buttons below are
laid out in percentages so they land on those painted plates at any height —
the artwork stretches to the rail, and the tracks stretch with it.
Plate geometry measured from the art (see the ratios in the grid below). */
:host { :host {
display: block; display: block;
background: var(--ar-panel); background: var(--ar-bg) url('/assets/hud-elements/sidepanel-left.png') center / 100% 100%
no-repeat;
} }
.side-navigation { .side-navigation {
display: grid; display: grid;
align-content: start; /* A lead-in, then one track per painted plate. */
padding-block: var(--ar-space-3); grid-template-rows: 5.9% repeat(6, 8%);
row-gap: 1.1%;
block-size: 100%;
/* Percentage padding resolves against width — which is what the side insets
want anyway. */
padding-inline: 18.7% 17.1%;
}
/* Occupies the lead-in track. Without it the first button auto-places there and
the whole stack sits one plate too high. */
.side-navigation::before {
grid-row: 1;
content: '';
} }
.side-navigation__item { .side-navigation__item {
display: flex; display: flex;
grid-row: span 1;
gap: var(--ar-space-2);
align-items: center; align-items: center;
gap: var(--ar-space-3); justify-content: center;
inline-size: 100%; min-inline-size: 0;
min-block-size: 4.3rem; padding: 0 var(--ar-space-2);
padding: var(--ar-space-3) var(--ar-space-4);
border: 0; border: 0;
border-block-end: 1px solid color-mix(in srgb, var(--ar-border) 65%, transparent);
border-inline-start: 3px solid transparent;
color: var(--ar-text-muted); color: var(--ar-text-muted);
/* The plate underneath is the button's frame; nothing is drawn on top. */
background: transparent; background: transparent;
font: inherit; font: inherit;
text-align: start; text-align: start;
text-decoration: none; text-decoration: none;
transition: transition:
color var(--ar-motion-fast), color var(--ar-motion-fast),
background var(--ar-motion-fast), text-shadow var(--ar-motion-fast),
border-color var(--ar-motion-fast); filter var(--ar-motion-fast);
} }
.side-navigation__item img { .side-navigation__item img {
inline-size: 2.25rem; flex: none;
block-size: 2.25rem; inline-size: 1.9rem;
block-size: 1.9rem;
object-fit: cover; object-fit: cover;
border-radius: 50%; border-radius: 50%;
} }
@@ -42,8 +61,8 @@
display: grid; display: grid;
flex: none; flex: none;
place-items: center; place-items: center;
inline-size: 2.25rem; inline-size: 1.9rem;
block-size: 2.25rem; block-size: 1.9rem;
border: 1px solid var(--ar-border-highlight); border: 1px solid var(--ar-border-highlight);
border-radius: 50%; border-radius: 50%;
color: var(--ar-gold); color: var(--ar-gold);
@@ -51,37 +70,58 @@
} }
.side-navigation__glyph app-location-icon { .side-navigation__glyph app-location-icon {
inline-size: 1.25rem; inline-size: 1.1rem;
block-size: 1.25rem; block-size: 1.1rem;
} }
.side-navigation__item span {
overflow: hidden;
font-size: 0.95rem;
letter-spacing: 0.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Active reads as the plate catching light, not as a bar bolted beside it. */
.side-navigation__item--active { .side-navigation__item--active {
border-inline-start-color: var(--ar-blue); color: var(--ar-gold);
color: var(--ar-text); filter: drop-shadow(0 0 0.5rem rgb(201 164 95 / 0.45));
background: linear-gradient(90deg, rgb(92 169 216 / 0.2), transparent); text-shadow: 0 0 0.6rem rgb(201 164 95 / 0.5);
box-shadow: inset 0 0 1.25rem rgb(92 169 216 / 0.08);
} }
.side-navigation__item:not(:disabled):hover, .side-navigation__item:not(:disabled):hover,
.side-navigation__item:not(:disabled):focus-visible { .side-navigation__item:not(:disabled):focus-visible {
color: var(--ar-text); color: var(--ar-text);
background: rgb(255 255 255 / 0.04);
} }
.side-navigation__item:disabled { .side-navigation__item:disabled {
opacity: 0.52; opacity: 0.42;
} }
/* Narrow: the tall painted rail no longer fits, so the bar goes horizontal and
drops the artwork rather than squashing it. */
@media (width < 620px) { @media (width < 620px) {
:host {
background: var(--ar-panel);
}
.side-navigation { .side-navigation {
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-rows: none;
padding: 0; grid-template-columns: repeat(6, minmax(0, 1fr));
row-gap: 0;
block-size: auto;
padding-inline: 0;
}
.side-navigation::before {
display: none;
} }
.side-navigation__item { .side-navigation__item {
justify-content: center; justify-content: center;
min-block-size: 3.5rem; min-block-size: 3.5rem;
padding: var(--ar-space-2); padding: var(--ar-space-2);
border-block-end: 1px solid color-mix(in srgb, var(--ar-border) 65%, transparent);
} }
.side-navigation__item span { .side-navigation__item span {

View File

@@ -83,6 +83,85 @@ img {
max-inline-size: 100%; max-inline-size: 100%;
} }
/* Announced to screen readers, invisible on screen. */
.visually-hidden {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
border: 0;
white-space: nowrap;
}
/* ---------- panel chrome ----------
The forged frame every game panel sits in. The art is `panel-frame.png`,
derived from the hand-made `panel-bg.png` by `tools/asset-gen.mjs`: the
centre-edge gems are painted out there so the frame can be nine-sliced to
any panel size, and put back below as real ornaments. */
.ar-panel {
position: relative;
padding: var(--ar-space-3) var(--ar-space-4);
/* Fallback if the art fails to load — the frame is transparent border. */
border: 1.6rem solid transparent;
border-image: url('/assets/hud-elements/panel-frame.png') 96 fill stretch;
background-clip: padding-box;
filter: drop-shadow(0 0.4rem 1rem rgb(0 0 0 / 0.55));
}
/* The gem the nine-slice cannot carry, restored at the true centre of the
top and bottom edge. */
.ar-panel::before,
.ar-panel::after {
position: absolute;
inset-inline-start: 50%;
inline-size: 2.6rem;
block-size: 2.35rem;
content: '';
background: url('/assets/hud-elements/panel-ornament.png') center / contain no-repeat;
pointer-events: none;
translate: -50% 0;
}
.ar-panel::before {
inset-block-start: -1.5rem;
}
.ar-panel::after {
inset-block-end: -1.5rem;
rotate: 180deg;
}
.ar-panel__title {
position: relative;
margin: 0 0 var(--ar-space-4);
padding-block-end: var(--ar-space-2);
color: var(--ar-text-muted);
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.82rem;
font-weight: 400;
letter-spacing: 0.22em;
text-align: center;
text-transform: uppercase;
}
.ar-panel__title::after {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
block-size: 1px;
content: '';
background: linear-gradient(
90deg,
transparent,
rgb(155 122 66 / 0.75) 22%,
rgb(155 122 66 / 0.75) 78%,
transparent
);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,

View File

@@ -0,0 +1,251 @@
# Ashen Realms Playable Slice 0.10
## Abandoned Watchpost
**Status:** Implementation Specification
**Depends on:** Slice 0.9
**Purpose:** Expand the first region beyond the tutorial road and introduce a stronger hunt location, story investigation and the first meaningful gear/reputation check.
---
## 1. Goal
The player now has enough knowledge and basic equipment to leave the Burned Road loop and push deeper into the Ashen Fields.
The Abandoned Watchpost should feel like the first place where the world opens slightly:
- new location artwork
- stronger encounter pool
- a surviving/remaining NPC or investigation point
- new trade goods
- stronger equipment opportunities
- route toward the Ash Pit
The player should feel that the Burned Road prepared them for this place.
---
## 2. World Connection
Add or activate:
```text
Burned Road ↔ Abandoned Watchpost
```
Initial travel target:
```text
Duration: ~15 seconds
Ambush chance: ~10%
```
Values remain balancing data.
The player is not blocked by character level.
If progression gating is needed, prefer world discovery / quest state / actual combat danger rather than a numeric level requirement.
---
## 3. Local View
The Abandoned Watchpost should use the generic local-location screen pattern.
Minimum interactions:
- Hunt
- Inspect the Watchpost
- Speak with the remaining guard/NPC if present
- Travel back to Burned Road
- discover or unlock route toward Ash Pit
The location must not be a world-map node with no local identity.
---
## 4. Encounter Pool
Recommended initial enemies:
- Road Bandit
- Raider Scout
- Raider Veteran
- Ash Hound / Burned Hound depending on final content naming
- optional rare Raider Captain as first elite target
Reuse existing enemy mechanics where possible.
This slice should deepen combinations, not add several new subsystems.
---
## 5. New Enemy Roles
### Raider Scout
- moderate damage
- simple fast attack pattern
- primarily a loot/reputation farming target
### Raider Veteran
- higher armor/HP
- telegraphed Heavy Strike
- can enter a defensive stance
- encourages the player to react rather than spam Attack
### Burned Hound
- Bleeding
- becomes more aggressive at low HP
- reinforces previously learned status behavior
### Raider Captain Optional Elite
If included in this slice:
- combines telegraphed attack + defensive behavior
- should be dangerous on first arrival
- becomes reliably beatable after several Tier-1 upgrades
- has a focused desirable equipment drop
Do not confuse this elite with the final **Captain of the Ashen Band** area boss in Slice 0.11.
---
## 6. Loot and Bags
The location should make both current loot categories relevant.
Examples:
- beast enemies → HIDE goods
- raiders → RAIDER_TROPHY goods
This gives a reason to own both:
- Basic Hide Bag
- Basic Trophy Pouch
If the player does not own a Trophy Pouch yet, the default capacity of 1 still applies and should naturally point them back toward the merchant.
No direct Silver or reputation from normal kills.
---
## 7. Equipment Progression
The Watchpost should increase the chance of meaningful Tier-1 upgrades.
Relevant items may include:
- Bandit Blade
- Bandit Hood
- Plunderer Gloves
- Reinforced Leather Jacket
- Watchman's Leggings
The player does not need Best-in-Slot gear to proceed.
The intended feeling is:
> "The enemies here are tougher, but the drops here are also the things that make me ready for the Ash Pit."
---
## 8. Story / Investigation
A simple investigation should reveal that the road attacks are organized and point toward the Ash Pit.
This may be implemented through:
- NPC dialogue
- inspectable environment interaction
- quest step
- persistent discovery flag
Keep text concise.
Example clue:
> "The raiders weren't using the watchpost as shelter. They were using it to watch the road. Fresh tracks lead east, toward the old ash excavation."
---
## 9. Unlocking the Ash Pit Route
The route toward the Ash Pit should become visible through world progress.
Recommended rule:
```text
Inspect Watchpost OR complete related objective
→ discover ash-pit connection
```
Avoid `requiredLevel`.
The player may be allowed to travel there even if still undergeared once the route is discovered.
Actual danger is the soft gate.
---
## 10. Merchant/Reputation Integration
New trade goods from the Watchpost must be accepted by the existing merchant exchange system.
The player should now have a reason to make repeated cycles:
```text
Graufurt
→ Burned Road
→ Watchpost
→ bags fill
→ Graufurt merchant
→ Silver + reputation + Renown
→ better supplies/bags/gear
→ return
```
---
## 11. Tests
- travel connection works both directions as configured
- Watchpost hunt uses only its own encounter pool
- Raider Veteran defensive/telegraph mechanics resolve correctly
- trade goods map to correct loot categories
- no normal enemy grants direct money/reputation
- Watchpost investigation persists
- Ash Pit route is hidden before discovery if that rule is used
- Ash Pit route becomes available after discovery
---
## 12. Acceptance Criteria
- [ ] Abandoned Watchpost exists as a full playable location.
- [ ] Travel from Burned Road works with server-authoritative timing.
- [ ] Location has a stronger, distinct encounter pool.
- [ ] At least one stronger enemy combines previously learned mechanics.
- [ ] Both HIDE and RAIDER_TROPHY carrying systems matter.
- [ ] Tier-1 equipment progression is meaningfully improved here.
- [ ] Story/investigation points toward the Ash Pit.
- [ ] Ash Pit route can be discovered without a level gate.
- [ ] Existing merchant/reputation loop continues to work.
- [ ] All player-facing content is English.
---
## 13. Out of Scope
Do not add:
- second region
- crafting
- procedural watchpost events
- complex stealth mechanics
- large dialogue trees
- final area boss

View File

@@ -0,0 +1,261 @@
# Ashen Realms Playable Slice 0.11
## Ash Pit & Captain of the Ashen Band
**Status:** Implementation Specification
**Depends on:** Slice 0.10
**Purpose:** Deliver the first region climax: dangerous regular encounters, the first real area boss and guaranteed progression loot.
---
## 1. Goal
The Ash Pit is the first clear test of whether the player has understood the Ashen Fields progression loop.
The player should arrive with:
- some reputation progression
- useful bag capacity
- several Tier-1 equipment upgrades
- experience with Bleeding
- experience with Telegraphing
- experience with Shield Bash / interrupt
- experience with Defend
The area culminates in the **Captain of the Ashen Band**.
---
## 2. World Connection
Activate/discover:
```text
Abandoned Watchpost ↔ Ash Pit
```
Suggested travel duration:
```text
1520 seconds
```
Travel risk can be higher than the Burned Road connection.
The route should remain a soft gate: the player can enter once discovered, even if the enemies are still too dangerous.
---
## 3. Ash Pit Local View
The Ash Pit should immediately feel more dangerous.
Minimum interactions:
- Hunt
- Enter / challenge the band captain when available
- Inspect the excavation / camp
- Travel back to Abandoned Watchpost
Artwork and UI should communicate that this is the region's current endpoint.
---
## 4. Regular Encounter Pool
Recommended enemies:
- Ash Burrower
- Raider Veteran
- Burned Hound
- optional rare/strong raider variant
The regular pool should be harder than the Watchpost without simply multiplying HP.
---
## 5. Enemy Mechanics
### Ash Burrower
- durable melee enemy
- higher armor than early beasts
- teaches that some enemies are naturally slower to kill
### Raider Veteran
- reused known mechanics
- Heavy Strike telegraph
- defensive stance
### Burned Hound
- Bleeding
- increased aggression at low HP
The Ash Pit should feel like a consolidation of the region's mechanics before the boss.
---
## 6. Boss Captain of the Ashen Band
English display name:
**Captain of the Ashen Band**
The boss should combine known mechanics instead of introducing a new combat resource.
### Phase / behavior concept
#### Normal state
- standard melee attack
- telegraphed Heavy Strike
#### Defensive state
At a configured trigger:
- increases armor for a short duration
- player can choose to Defend/heal/manage the round rather than waste damage
#### Low-health aggression
Below approximately 30% HP:
- increased pressure
- faster/more frequent dangerous action
- no unfair hidden one-shot
---
## 7. Boss Reward Philosophy
The boss must never feel unrewarding.
On victory:
### Guaranteed
- one relevant high-quality Tier-1 equipment item from a configured pool
- boss trade good / trophy
- boss defeated world-state flag
### Additional independent roll
- chance for Ashen Blade
- chance for Charred Captain's Pendant / prestige item if already part of content
Normal repeat kills should not directly print large amounts of Silver or reputation.
The boss trophy can be exchanged through the merchant system for meaningful value.
---
## 8. World Renown Milestone
A first-time area-boss victory is a valid **major achievement** and may grant a one-time World Renown reward directly.
Rules:
- first victory only
- persisted milestone flag
- repeat kills do not repeat the milestone Renown reward
This keeps the distinction:
- normal kills → goods
- trade-in → routine Silver/reputation/Renown progression
- major achievements → optional one-time direct World Renown
---
## 9. Boss Availability
The boss challenge should be visible as a location action once the relevant Ash Pit discovery/story condition is met.
Avoid a hard level requirement.
If the player attacks too early, the boss should simply be very dangerous.
A danger label should clearly communicate this.
---
## 10. Defeat
A boss defeat follows the general Ashen Realms defeat philosophy:
- no item loss
- no reputation loss
- no permanent progression loss
- return to a safe location according to the existing defeat flow
The player should be encouraged to upgrade and return.
---
## 11. Region Completion State
First boss victory should set a persisted region-progress milestone such as:
```text
ashen-fields-boss-defeated
```
This can later unlock/discover the route toward the next region.
Slice 0.12 validates and polishes the entire region; it does not need to fully implement the next region.
---
## 12. Tests
### Boss combat
- Heavy Strike telegraphs correctly
- interrupt works where intended
- defensive phase modifies armor for correct duration
- low-health behavior triggers once/according to rules
- boss victory persists
### Rewards
- guaranteed Tier-1 item always grants
- special drop is independent roll
- trophy grants once per victory as configured
- first victory World Renown milestone is granted once
- repeat boss victory does not duplicate first-clear milestone reward
### Regression
- regular Ash Pit enemies still grant no direct XP/Silver/reputation
---
## 13. Acceptance Criteria
- [ ] Ash Pit exists as the region's hardest current location.
- [ ] Regular encounter pool combines learned mechanics.
- [ ] Captain of the Ashen Band is a dedicated boss encounter.
- [ ] Boss has telegraph, defensive behavior and low-HP pressure.
- [ ] Boss is beatable with appropriate Tier-1 progression.
- [ ] Boss always grants one relevant Tier-1 equipment item.
- [ ] Special boss loot uses a separate roll.
- [ ] First clear can grant one-time World Renown.
- [ ] Repeat clears cannot duplicate the first-clear milestone reward.
- [ ] Region completion state is persisted.
- [ ] All player-facing text is English.
---
## 14. Out of Scope
Do not implement:
- Dusk/Gloam forest region content
- raids
- group combat
- boss matchmaking
- weekly lockouts
- complex multi-phase cinematic boss logic

View File

@@ -0,0 +1,384 @@
# Ashen Realms Playable Slice 0.12
## Ashen Fields Complete Integration, Balancing & Polish
**Status:** Implementation Specification
**Depends on:** Slice 0.11
**Purpose:** Turn all systems built through 0.11 into one coherent, testable first-region experience before expanding to the next region.
---
## 1. Goal
Slice 0.12 is not primarily a new feature slice.
It is the point where the Ashen Fields must work as **one game**.
A fresh character should be able to play through the complete first-region loop without developer intervention:
**Graufurt → South Gate → Burned Road → hunt → bag limit → merchant → reputation → upgrades → Abandoned Watchpost → Ash Pit → Captain of the Ashen Band.**
If this loop is not fun and understandable, do not move on to the next region yet.
---
## 2. Full Region Scope
The complete first-region content should include at least:
### Safe / transition locations
- Graufurt
- Graufurt South Gate
### Progression locations
- Burned Road
- Abandoned Watchpost
- Ash Pit
### Core systems
- travel
- local location view
- hunt
- encounter choice
- turn-based combat
- Bleeding
- Telegraphing
- interrupt
- Defend
- potions/combat bag as already implemented
- trade goods
- monster categories
- loot categories
- loot bags
- merchant trade-in
- Silver
- regional reputation
- World Renown
- reputation-gated offers
- first quest chain
- Tier-1 equipment
- first area boss
---
## 3. Remove Legacy Progression Assumptions
Perform a complete audit for obsolete XP-era behavior.
For the implemented first region:
- normal monsters must not grant XP
- normal monsters must not grant Silver directly
- normal monsters must not grant reputation directly
- old Grenzmarken / border-token progression must not remain active unless explicitly reintroduced as a separate intentional system
- merchant offers must not use old character-level gates as the primary progression requirement
Search:
- seed data
- entities
- DTOs
- API responses
- combat victory code
- loot service
- UI reward summaries
- tests
- docs used by implementation agents
Do not leave old behavior silently active beside the new reputation model.
---
## 4. Tier-1 Item Completion
Ensure the first region has a coherent fixed-item progression.
Recommended Tier-1 set of named items:
- Worn Shortsword
- Bandit Blade
- Ashen Blade
- Bandit Hood
- Reinforced Leather Jacket
- Plunderer Gloves
- Watchman's Leggings
- Ashen Boots
- Mark of the Border Watch
- Charred Captain's Pendant
Not every item needs to be mandatory.
Each relevant item must have at least one clear acquisition path:
- monster drop
- boss guaranteed pool
- reputation-gated merchant offer
- quest reward, only where appropriate
Avoid items that exist in the database but cannot realistically be obtained.
---
## 5. Target Combat Durations
Use the existing balancing targets:
### Normal enemies
**48 rounds** in appropriate content.
### Elite enemies
**610 rounds.**
### Boss
**814 rounds.**
Run deterministic simulations/tests where possible and manually play representative loadouts.
Do not balance only around a fully optimized character.
---
## 6. Progression States to Test
At minimum test these player states:
### Fresh character
- starter weapon
- starter armor
- no loot bags
- no reputation
Expected:
- Ash Rat comfortable
- Road Bandit risky but understandable
- Watchpost clearly too early
### Early Burned Road progression
- Basic Hide Bag
- first equipment upgrade
- some merchant reputation
Expected:
- Burned Road reliable
- Watchpost possible but harder
### Mid Ashen Fields
- several Tier-1 upgrades
- Trophy Pouch or equivalent capacity improvement
- improved reputation
Expected:
- Watchpost reliable
- Ash Pit dangerous but approachable
### Region-ready
- strong but not necessarily Best-in-Slot Tier-1 loadout
Expected:
- Captain of the Ashen Band reasonably beatable with correct decisions
---
## 7. Bag / Return-Trip Balancing
The bag system must create decisions without becoming tedious.
Validate:
- default capacity 1 is useful for the tutorial moment
- Basic Hide Bag capacity 5 feels meaningful
- trade-in trips are frequent enough to create rhythm
- trips are not so frequent that the player spends more time returning than fighting
- higher-value raider trophies do not require excessive backtracking
Adjust bag capacity, travel duration or reward value rather than adding arbitrary shortcuts immediately.
---
## 8. Merchant Economy Balancing
Validate:
- a first meaningful purchase is reachable in a reasonable number of hunt cycles
- reputation unlocks occur often enough to be noticed
- locked items create goals rather than frustration
- Silver does not accumulate with no useful sinks
- trade goods have visibly different value
- rare encounters feel economically exciting
Do not target a perfect final economy. Target a coherent first-region economy.
---
## 9. World Renown Balancing
World Renown is the long-term level-like progression.
For this slice:
- it must visibly increase during normal region play
- it must not race upward from every individual kill
- merchant exchange and major milestones are the key sources
- first area-boss victory may grant a one-time meaningful increase
Do not finalize the complete 1015-rank game-wide curve unless that has already been specified elsewhere.
Only ensure the first region produces a sensible opening segment of that curve.
---
## 10. Hunt Balancing
Check encounter weights and player choice.
The player should regularly see:
- easy/reliable targets
- one more rewarding/riskier option
- occasional rare encounter
Avoid hunts where all three cards are effectively identical.
Rare Charred Raider should feel exciting but not so rare that normal play never demonstrates it.
---
## 11. UI Polish
Review all first-region screens against the established UI design.
Important:
- artwork remains dominant
- local view feels like a browser RPG, not a mobile card game
- current location is obvious
- NPCs/interactions are obvious
- bag capacity is visible when relevant
- reputation requirements are readable
- loot summary clearly distinguishes granted vs left-behind goods
- combat telegraphs are impossible to miss
- locked merchant items are understandable
- no German player-facing strings remain
---
## 12. Content Consistency
Verify names, keys and terminology across:
- database seeds
- backend DTOs
- Angular components
- combat log
- item tooltips
- quest text
- merchant text
- location descriptions
One entity must not appear as `Road Bandit` in one screen and `Street Raider` in another unless intentionally different.
---
## 13. End-to-End Test Scenario
Create at least one automated or documented repeatable E2E path:
```text
1. Start with fresh character.
2. Visit South Gate Warden.
3. Accept Trouble Beyond the Gate.
4. Travel to Burned Road.
5. Hunt Ash Rat.
6. Receive first Ashen Pelt.
7. Hit HIDE capacity 1/1.
8. Return to warden.
9. Receive referral.
10. Visit Borin.
11. Receive Basic Hide Bag.
12. Collect five Ashen Pelts.
13. Complete quest.
14. Hunt/trade goods with Borin.
15. Gain Silver + regional reputation + World Renown.
16. Unlock/buy at least one progression offer.
17. Travel to Abandoned Watchpost.
18. Obtain stronger Tier-1 loot.
19. Discover Ash Pit.
20. Challenge Captain of the Ashen Band.
21. Win with a plausible non-BiS loadout.
22. Receive guaranteed relevant Tier-1 item.
23. Persist first-clear milestone.
```
The flow must survive page reloads and reconnects at sensible points.
---
## 14. Technical Verification
Before declaring Slice 0.12 complete:
- all backend unit tests pass
- integration tests pass
- frontend tests pass
- production build passes
- migration path works from a clean database
- seed is idempotent where designed to be
- Docker production image still starts
- API and Angular static hosting still work together
- no client-authoritative combat/loot/reputation calculation has been introduced
---
## 15. Definition of Done
Slice 0.12 is complete when:
- [ ] A fresh player can finish the complete Ashen Fields progression without manual DB edits.
- [ ] The first quest teaches the bag/merchant loop naturally.
- [ ] Burned Road, Abandoned Watchpost and Ash Pit each have a distinct purpose.
- [ ] Normal enemies reward goods/equipment, not direct XP/Silver/reputation.
- [ ] Merchant trade-in converts goods into Silver + reputation progression.
- [ ] Reputation visibly unlocks useful offers.
- [ ] Bags create a meaningful but not annoying return-trip rhythm.
- [ ] Tier-1 equipment progression is noticeable.
- [ ] Captain of the Ashen Band is a fair first-region climax.
- [ ] Boss guarantees meaningful progression loot.
- [ ] Combat duration targets are broadly met.
- [ ] Player-facing content is consistently English.
- [ ] The entire flow is server-authoritative for critical game state.
- [ ] Production build/tests are green.
---
## 16. What Comes After 0.12
Only after this slice is stable should development expand into the next region.
Recommended next phase:
```text
Ashen Fields retrospective / balancing adjustments
→ next region foundation
→ new region enemies/mechanics
→ new reputation/economy content
```
Do not immediately add many MMO systems.
The first question after 0.12 is:
> **Is the repeatable loop of hunting, carrying, trading, upgrading and overcoming stronger content actually fun?**

View File

@@ -0,0 +1,300 @@
# Ashen Realms Playable Slice 0.6.6
## English Game Content Foundation
**Status:** Implementation Specification
**Depends on:** Slice 0.6.5 Reputation / Renown Foundation
**Purpose:** Convert all currently implemented player-facing game content to English before the next major content expansion.
---
## 1. Goal
From this slice onward, **all player-facing game content is written in English**.
Development documentation may remain German. Internal technical names may remain unchanged when they are already stable, but new data keys should be language-neutral English slugs.
This slice is deliberately a cleanup and convention slice. It must not introduce a large localization framework.
> Project rule after this slice: **All player-facing game content is English. Development documentation may remain German.**
---
## 2. Why this slice exists now
The project is about to add significantly more content:
- more monsters
- trade goods
- loot bags
- merchants
- reputation-gated offers
- NPC dialogue
- quests
- the Abandoned Watchpost
- the Ash Pit
- the first area boss
Translating later would touch much more persisted content, UI copy, seed data and tests. Therefore the language switch happens before Slice 0.7 V2.
---
## 3. Scope
Convert every **currently visible** German string in the playable loop:
- navigation
- locations
- monsters
- items
- combat actions
- combat log
- hunt screen
- travel screen
- loot screen
- inventory
- character screen
- system messages
- danger labels
- error messages visible to the player
Do **not** translate German development documentation in `/docs` unless it is itself rendered in the game.
---
## 4. Naming convention
Technical identity must not depend on display language.
Preferred pattern:
```ts
{
key: 'ash-rat',
name: 'Ash Rat'
}
```
Avoid new language-specific identifiers such as:
```ts
key: 'aschenratte'
```
Existing UUIDs must never change because of the language conversion.
---
## 5. Initial terminology
Use the following English terminology consistently for the currently relevant content.
### Locations
| German / Previous Label | English Player-Facing Label | Suggested Key |
|---|---|---|
| Graufurt | Graufurt | `graufurt` |
| Südtor von Graufurt | Graufurt South Gate | `graufurt-south-gate` |
| Verbrannte Straße | Burned Road | `burned-road` |
| Verlassener Wachtposten | Abandoned Watchpost | `abandoned-watchpost` |
| Aschengrube | Ash Pit | `ash-pit` |
| Aschenfelder | Ashen Fields | `ashen-fields` |
`Graufurt` is treated as a proper place name and remains unchanged for now.
### Monsters
| German / Previous Label | English Label | Suggested Key |
|---|---|---|
| Aschenratte | Ash Rat | `ash-rat` |
| Verwilderter Straßenhund | Feral Road Hound | `feral-road-hound` |
| Straßenräuber | Road Bandit | `road-bandit` |
| Verkohlter Plünderer | Charred Raider | `charred-raider` |
| Plünderer-Späher | Raider Scout | `raider-scout` |
| Plünderer-Veteran | Raider Veteran | `raider-veteran` |
| Aschenwühler | Ash Burrower | `ash-burrower` |
| Verbrannter Jagdhund | Burned Hound | `burned-hound` |
| Hauptmann der Aschenbande | Captain of the Ashen Band | `ashen-band-captain` |
### Core combat actions
- Attack
- Heavy Strike
- Shield Bash
- Defend
- Potion
- Flee
### Navigation
- Map
- Hunt
- Quests
- Inventory
- Character
- Shop
### Common UI
- Current Location
- Travel
- Begin Travel
- Begin Hunt
- Search Again
- Attack
- Back
- Loot
- Equip
- Unequip
- Reputation
- World Renown
- Silver
- Required Reputation
- Bag Capacity
### Danger labels
- Weak
- Suitable
- Strong
- Very Dangerous
- Deadly
---
## 6. Existing item names
At minimum, currently seeded or visible Tier-1 items should use English display names.
Recommended names:
- Worn Shortsword
- Bandit Blade
- Ashen Blade
- Bandit Hood
- Reinforced Leather Jacket
- Plunderer Gloves
- Watchman's Leggings
- Ashen Boots
- Mark of the Border Watch
- Charred Captain's Pendant
Do not rename stable item keys merely to make them prettier. Only display values should be changed unless the old key is clearly temporary and no persisted references depend on it.
---
## 7. Combat log language
All newly generated combat events must resolve to English text.
Examples:
```text
Round 2
You use Shield Bash.
Road Bandit takes 12 damage.
Road Bandit's Heavy Strike is interrupted.
```
```text
Feral Road Hound bites you for 9 damage.
You are Bleeding for 2 rounds.
```
Combat event persistence should continue to prefer structured event data over storing fully rendered localized strings wherever possible.
---
## 8. UI text architecture
Do not build a full translation database in this slice.
For static Angular UI text, it is acceptable to use centralized constants or a lightweight text map where useful.
For persisted game content, keep:
```text
key
name
shortDescription / description
```
with English values.
A future localization layer may introduce translations without changing the content key.
---
## 9. Error messages
Domain error `code` values remain technical and stable.
Example:
```json
{
"code": "BAG_CAPACITY_EXCEEDED",
"message": "You cannot carry any more hides."
}
```
The player-facing `message` is English.
---
## 10. Migration / seed behavior
If names and descriptions are stored in PostgreSQL:
- update existing rows by stable `key`
- do not delete and recreate content rows unnecessarily
- preserve UUIDs
- preserve character inventory references
- keep migrations idempotent where appropriate
If seed files own the content, update both the seed definitions and the existing database through a controlled migration/update step.
---
## 11. Tests
Add or update tests so that:
- current location API returns English names
- hunt encounters return English monster names
- combat UI renders English action labels
- combat log contains English player-facing text
- inventory uses English item names
- no existing test expects German display text
A simple development-only grep may be used to identify obvious German UI remnants, but it is not a substitute for actual runtime verification.
---
## 12. Acceptance Criteria
This slice is complete when:
- [ ] The complete currently playable flow contains no intentional German player-facing text.
- [ ] Existing content IDs and player state remain valid.
- [ ] Locations, monsters and items use English display names.
- [ ] Combat actions and combat log are English.
- [ ] Navigation and common system messages are English.
- [ ] New stable content keys follow English/language-neutral slug conventions.
- [ ] No large localization framework was introduced.
- [ ] All existing tests pass.
---
## 13. Explicitly Out of Scope
Do not implement yet:
- German/English language switch
- browser language detection
- translation management UI
- translation tables for every content entity
- Crowdin / Phrase / Lokalise integration
- automatic machine translation
The only required runtime language after this slice is **English**.

View File

@@ -0,0 +1,321 @@
# Ashen Realms Playable Slice 0.7.5
## Monster Categories & Loot Bags
**Status:** Implementation Specification
**Depends on:** Slice 0.7 V2
**Purpose:** Introduce category-based carrying limits for monster trade goods and create the first logistical reason to return from hunting.
---
## 1. Goal
Monster loot should not be infinitely farmable in one trip.
The player can carry only a small amount of category-specific trade goods without a bag. Dedicated loot bags increase that capacity.
This creates the loop:
**Hunt → fill bag → return → exchange goods → upgrade capacity → hunt longer.**
The system must be simple, visible and data-driven.
---
## 2. Core Rule
Each trade good belongs to exactly one **Loot Category**.
A character has a carrying capacity for each loot category.
Without a matching bag, the default carrying capacity is:
**1 unit per loot category.**
Example:
```text
Ashen Pelt: HIDE
Current HIDE capacity: 1
Player owns 1 Ashen Pelt
→ another Ashen Pelt cannot be carried
```
A Hide Bag with capacity 5 changes this to:
```text
HIDE capacity: 5
```
---
## 3. Initial Categories
Implement only the categories needed by the current content, but design the enum so future categories can be added.
Initial categories:
```text
HIDE
RAIDER_TROPHY
```
Suggested future categories, not required yet:
```text
CHITIN
UNDEAD_RELIC
ARCANE_REMAINS
```
---
## 4. Initial Trade Good Mapping
| Trade Good | Loot Category |
|---|---|
| Ashen Pelt | HIDE |
| Tough Hide | HIDE |
| Raider Insignia | RAIDER_TROPHY |
| Charred Raider Insignia | RAIDER_TROPHY |
The mapping belongs to content data, not hardcoded monster-specific UI logic.
---
## 5. Monster Categories
Monster definitions also receive a broad gameplay category.
Initial examples:
```text
BEAST
HUMANOID
```
Suggested mapping:
| Monster | Monster Category |
|---|---|
| Ash Rat | BEAST |
| Feral Road Hound | BEAST |
| Road Bandit | HUMANOID |
| Charred Raider | HUMANOID |
Monster category and loot category are separate concepts.
Example: different BEAST monsters can later drop HIDE, CHITIN or another loot category.
Do not assume `monsterCategory === lootCategory`.
---
## 6. Bag Model
Loot bags are **not normal armor equipment slots**.
They belong to a separate character loadout/state.
Recommended data model concept:
```text
LootBagDefinition
- id
- key
- name
- lootCategory
- capacity
- iconPath
CharacterLootBag
- characterId
- lootBagDefinitionId
- equipped / active
```
Only one active bag per loot category is required for V1.
No nesting and no physical inventory grid.
---
## 7. Initial Bag Definitions
The system must support at least:
### Basic Hide Bag
```text
Name: Basic Hide Bag
Category: HIDE
Capacity: 5
```
### Basic Trophy Pouch
```text
Name: Basic Trophy Pouch
Category: RAIDER_TROPHY
Capacity: 5
```
They do not both need to be obtainable in this slice. Acquisition is handled by the merchant/quest slices.
---
## 8. Capacity Calculation
The server is the single authority.
Conceptually:
```text
capacity(category) = active bag capacity
if no active bag exists:
capacity(category) = 1
```
The current amount is the sum of all owned trade-good quantities in that category.
Equipment, consumables and normal items are not affected by loot-bag capacity.
---
## 9. Full Bag Behavior
If combat loot would exceed the capacity:
- the combat victory remains valid
- the trade good is not granted beyond capacity
- other loot rolls still succeed normally
- equipment must not be lost because the hide bag is full
- the loot summary explains what was left behind
Example:
```text
Loot
Ashen Pelt ×1 Left behind (Hide Bag full)
Bandit Hood ×1 Added to inventory
```
Do not silently discard loot without player feedback.
---
## 10. Mixed Loot
If one result grants multiple trade goods, grant as many as fit.
Example:
```text
Current HIDE: 4 / 5
Reward: Tough Hide ×2
Granted: 1
Left behind: 1
```
The result DTO should make granted and rejected quantities explicit.
---
## 11. API / DTO Requirements
The frontend needs enough information to display current carrying state.
Example DTO concept:
```json
{
"category": "HIDE",
"current": 4,
"capacity": 5,
"bag": {
"key": "basic-hide-bag",
"name": "Basic Hide Bag"
}
}
```
Provide a server-side way for the player UI to retrieve all relevant category capacities.
This can be part of inventory state or a dedicated endpoint, whichever best fits the current codebase.
---
## 12. UI Requirements
The current carrying state must be visible during or directly after hunting.
Minimum:
```text
Hides 4 / 5
Raider Trophies 1 / 1
```
If a category is full, make it obvious before the player starts another farm cycle.
The loot summary must display:
- granted trade goods
- capacity after loot
- left-behind goods when full
Do not turn this into a large inventory-management screen.
---
## 13. Tests
### Capacity
- no bag gives capacity 1
- Basic Hide Bag gives HIDE capacity 5
- Hide Bag does not increase RAIDER_TROPHY capacity
- only active bag affects capacity
### Loot granting
- first Ashen Pelt can be carried without a bag
- second Ashen Pelt is rejected without a bag
- equipment reward still grants when trade-good capacity is full
- partial quantity grant works
### Security
- client cannot submit a fake capacity
- client cannot fake an equipped bag
- capacity is derived from persisted character state
---
## 14. Acceptance Criteria
- [ ] Monster definitions have a reusable monster category.
- [ ] Trade goods have a reusable loot category.
- [ ] Default capacity without bag is 1 per loot category.
- [ ] Dedicated loot bags increase only their configured category.
- [ ] Capacity enforcement is server-authoritative.
- [ ] Full bags never invalidate a combat victory.
- [ ] Excess trade goods are clearly shown as left behind.
- [ ] Equipment/consumables are unaffected by trade-good capacity.
- [ ] Current bag capacity can be displayed in the UI.
- [ ] Tests cover full, partial and no-bag cases.
---
## 15. Out of Scope
Do not implement:
- weight
- Tetris inventory
- bag durability
- random bag affixes
- crafting bags
- upgrades on one physical bag item
- more than one active bag per category
- automatic sending of excess loot to storage

View File

@@ -0,0 +1,300 @@
# Ashen Realms Playable Slice 0.8
## Graufurt Merchant & Trade-In Loop
**Status:** Implementation Specification
**Depends on:** Slice 0.7.5
**Purpose:** Close the first new progression loop by allowing monster trade goods to be exchanged for Silver, regional reputation and World Renown.
---
## 1. Goal
For the first time, the player can turn hunting success into long-term progression.
The loop becomes:
**Hunt → carry trade goods → return to Graufurt → exchange goods → receive Silver + reputation + World Renown → prepare for the next trip.**
Normal monster kills continue to give no direct money or reputation.
---
## 2. Merchant
Introduce one functional merchant in Graufurt.
Working name:
**Borin, Quartermaster**
If an existing project NPC already fills this role, reuse that NPC instead of creating a duplicate.
The merchant requires:
- local-view/NPC interaction entry point
- dialogue/open-shop action
- trade-good exchange view
- basic shop view
The UI must use English player-facing content.
---
## 3. Merchant Roles
Borin initially supports two separate operations:
### Trade In
The player hands over monster trade goods.
The server grants configured rewards.
### Shop
The player can spend Silver on basic supplies/items already allowed by the current content scope.
Reputation gating is introduced in Slice 0.8.5, not here.
---
## 4. Trade-In Reward Model
Each accepted trade good has a data-driven exchange definition.
Recommended conceptual fields:
```text
tradeGoodKey
merchantKey / regionKey
silverPerUnit
regionalReputationPerUnit
worldRenownPerUnit or batch rule
enabled
```
Exact values are balancing data and may remain provisional.
The important architectural rule is:
> The kill produces the object. The merchant converts the object into economic and reputation progression.
---
## 5. Initial Exchangeable Goods
At minimum:
- Ashen Pelt
- Tough Hide
- Raider Insignia
- Charred Raider Insignia
The rarer Charred Raider trade good should be worth visibly more than the basic Ashen Pelt.
Do not hardcode reward logic in Angular.
---
## 6. Reputation Effects
A successful exchange can improve:
- **regional reputation** for the current progression region
- **World Renown** as the global long-term progression value
Use the reputation/renown system introduced before this slice.
If the existing reputation implementation also tracks NPC-specific reputation, the exchange may optionally improve Borin's personal reputation, but the slice must not invent a second competing progression model.
The exact reward numbers should be configurable.
---
## 7. Silver
Silver is now primarily earned through exchange and quests/services rather than directly from normal monsters.
The merchant exchange must be one of the first reliable Silver sources.
Silver is persisted server-side and must be granted in the same transaction as the removal of trade goods.
---
## 8. Transaction Safety
A trade-in is atomic.
Conceptually:
```text
validate character ownership
validate quantity
validate merchant accepts good
remove goods
add Silver
add regional reputation
add World Renown
persist exchange record if needed
commit
```
If any required step fails, no partial exchange is allowed.
The same trade goods must never be redeemable twice.
---
## 9. API
Recommended operation:
```text
POST /api/merchants/:merchantKey/trade-in
```
Example request:
```json
{
"items": [
{ "itemKey": "ashen-pelt", "quantity": 5 }
]
}
```
Example response shape:
```json
{
"consumed": [
{ "itemKey": "ashen-pelt", "quantity": 5 }
],
"rewards": {
"silver": 20,
"regionalReputation": 10,
"worldRenown": 2
},
"balances": {
"silver": 84,
"regionalReputation": 26,
"worldRenown": 4
}
}
```
Numbers above are illustrative only.
---
## 10. Trade-In UI
The trade-in view should show:
- trade good icon
- name
- quantity carried
- exchange value
- selected quantity
- resulting reward preview
- confirmation button
Useful actions:
- Trade Selected
- Trade All
After completion, show a clear summary:
```text
Trade Complete
5 Ashen Pelts handed in
+20 Silver
+10 Ashen Fields Reputation
+2 World Renown
```
Do not use a mobile-game reward explosion. Keep presentation consistent with the dark browser-RPG UI.
---
## 11. Bag Interaction
Trade-in removes items from the player's carrying total immediately.
Example:
```text
Hides before exchange: 5 / 5
Trade 5 Ashen Pelts
Hides after exchange: 0 / 5
```
This immediately frees bag capacity for another trip.
---
## 12. Basic Shop
The shop may initially sell only already-supported basics, such as:
- Small Healing Potion
- simple starter equipment if desired
Avoid adding many new items just to populate the shop.
The important feature of this slice is **trade-in**, not shop breadth.
---
## 13. Tests
### Exchange
- owned goods can be exchanged
- excessive quantity is rejected
- unsupported item is rejected
- trade removes exact quantity
- Silver is granted
- regional reputation is granted
- World Renown follows configured reward rule
- capacity becomes available after goods are removed
### Atomicity
- reward failure does not consume goods
- duplicate request cannot duplicate rewards if request/idempotency protection exists in current architecture
### Regression
- normal monster victory still grants no Silver/reputation directly
---
## 14. Acceptance Criteria
- [ ] Graufurt has an interactable merchant entry point.
- [ ] Player can view carried trade goods accepted by the merchant.
- [ ] Player can exchange selected quantities.
- [ ] Trade goods are removed server-side.
- [ ] Silver is granted server-side.
- [ ] Regional reputation is granted according to configured values.
- [ ] World Renown is granted according to configured values/rules.
- [ ] Bag capacity is freed by trade-in.
- [ ] Exchange is transactional and cannot be duplicated by client manipulation.
- [ ] The complete Hunt → Return → Trade loop works without DB editing.
---
## 15. Out of Scope
Do not add yet:
- reputation-locked shop items
- referral-based merchant exceptions
- first tutorial quest
- advanced buy/sell economy
- player-to-player trading
- auction house
- crafting materials market

View File

@@ -0,0 +1,247 @@
# Ashen Realms Playable Slice 0.8.5
## Reputation-Gated Merchant Offers
**Status:** Implementation Specification
**Depends on:** Slice 0.8
**Purpose:** Make reputation visible and useful by locking selected merchant offers behind reputation or explicit unlock conditions instead of character levels.
---
## 1. Goal
The reputation system must change what the player can do.
A merchant should be able to communicate:
> "I don't know you well enough for that."
or:
> "Earn more trust in the Ashen Fields first."
The player sees desirable locked items, understands the requirement, and has a reason to continue hunting and trading.
---
## 2. No Level-Gated Merchant Progression
New merchant offers must not use character level as the primary progression gate.
Preferred gates:
- regional reputation
- World Renown
- NPC reputation, if already supported
- quest/unlock flag
The server is authoritative for all purchase requirements.
If `requiredLevel` still exists in an old item schema, it may remain for compatibility, but new Tier-1 merchant progression in these slices should not rely on it.
---
## 3. Offer Requirement Model
Merchant offers should support an optional requirement definition.
Conceptual examples:
```text
NONE
REGION_REPUTATION
WORLD_RENOWN
NPC_REPUTATION
QUEST_FLAG
```
An offer may later support multiple requirements, but do not overbuild a general rule engine unless the current code already has one.
A pragmatic structure is enough:
```text
requiredRegionReputation
requiredWorldRenown
requiredNpcReputation
requiredUnlockFlag
```
with nullable values.
---
## 4. Initial Locked Offers
At least two useful offers should demonstrate the system.
Recommended examples:
### Basic Hide Bag
- important progression item
- may be normally reputation-gated
- Slice 0.9 can temporarily bypass the gate through a quest referral
### Basic Trophy Pouch
- unlocked after the player has demonstrated some regional reputation
Optional additional offer:
### Bandit Blade or another Tier-1 gap filler
- available at a higher reputation threshold
Exact thresholds are balancing data.
---
## 5. Visible Locked Offers
Locked offers should usually remain visible.
Example:
```text
Basic Trophy Pouch
Capacity: 5 Raider Trophies
Price: 40 Silver
Requires: Ashen Fields Reputation 25
Current: 14
```
The Buy action is disabled.
This is preferable to hiding every locked item, because visible rewards create goals.
---
## 6. Server Validation
A malicious client must not bypass a disabled button.
Purchase flow:
```text
load character
load merchant offer
validate price
validate reputation requirement
validate quest/unlock flag
validate inventory/bag ownership rules
remove currency
grant item/bag
commit
```
If reputation is insufficient, return a stable domain error such as:
```text
MERCHANT_REPUTATION_TOO_LOW
```
with an English player-facing message.
---
## 7. Referral / Exception Support
Slice 0.9 needs a special tutorial moment:
The merchant normally would not give the player a useful bag yet, but a gate NPC sends the player with a referral.
Therefore the offer/unlock system must support one minimal exception:
```text
requiredUnlockFlag = referred-by-south-gate-warden
```
or an equivalent quest reward/grant path.
Do not implement a complex faction favor engine.
The requirement is simply that the quest can legally allow one specific acquisition that reputation alone would not yet allow.
---
## 8. UI Requirements
Merchant cards/rows show:
- item/bag name
- icon
- Silver price
- relevant effect
- requirement
- player's current value
- locked/unlocked state
Use clear English copy.
Examples:
```text
Requires Ashen Fields Reputation 25
```
```text
Requires World Renown 3
```
```text
Unavailable Borin does not know you well enough.
```
---
## 9. Feedback on Unlock
When a reputation increase makes an offer newly available, the player should receive lightweight feedback.
Example:
```text
New merchant offer unlocked: Basic Trophy Pouch
```
No large modal is required.
---
## 10. Tests
- offer with no requirement can be purchased
- insufficient regional reputation blocks purchase
- sufficient regional reputation allows purchase
- insufficient World Renown blocks purchase
- quest flag can unlock configured tutorial offer
- disabled client state is not trusted by backend
- price is still required even when reputation condition is met
- unlock state changes after a successful trade-in raises reputation
---
## 11. Acceptance Criteria
- [ ] Merchant offers can define reputation/unlock requirements.
- [ ] At least two offers visibly demonstrate locked states.
- [ ] Requirements are shown to the player in English.
- [ ] Server rejects purchases when requirements are not met.
- [ ] New progression offers do not depend on level gates.
- [ ] Quest/referral unlock support exists for Slice 0.9.
- [ ] Reputation increase can visibly unlock a previously locked offer.
- [ ] No generalized rules engine was added unnecessarily.
---
## 12. Out of Scope
Do not implement:
- dynamic merchant personalities
- haggling
- randomized daily shops
- faction wars
- reputation decay
- negative reputation systems
- multiple currencies per individual offer unless already required

View File

@@ -0,0 +1,321 @@
# Ashen Realms Playable Slice 0.9
## First Quest & Loot-Bag Tutorial
**Status:** Implementation Specification
**Depends on:** Slice 0.8.5
**Purpose:** Introduce quests, NPC-directed progression, bag capacity and the merchant loop through one small coherent tutorial chain.
---
## 1. Goal
This slice should teach several systems without a conventional tutorial popup sequence.
The player learns naturally that:
- NPCs provide direction
- monster goods must be carried
- carrying capacity matters
- merchants can help
- reputation affects relationships/offers
- hunting goods can later be exchanged for progression
The quest should feel like world interaction, not a checklist tutorial.
---
## 2. Quest Concept
Working quest title:
**Trouble Beyond the Gate**
Quest-giver:
**South Gate Warden**
Use an existing named gate NPC if one has already been implemented. Do not create a duplicate NPC merely because this document uses a generic title.
Merchant:
**Borin, Quartermaster** or the existing Graufurt merchant.
---
## 3. Narrative Flow
### Step 1 Speak with the South Gate Warden
The warden is concerned about increasingly aggressive scavengers and raiders beyond the gate.
Player-facing English example:
> "The road has gone bad. Start small. Bring me five Ashen Pelts from the rats beyond the gate. I want to know what the ash is doing to them."
Objective:
```text
Collect 5 Ashen Pelts
```
---
## 4. Capacity Problem
The player currently has no Hide Bag.
Default HIDE capacity is 1.
After collecting the first Ashen Pelt:
```text
Ashen Pelts 1 / 1
Hide capacity reached.
```
The quest remains at `1 / 5`.
The game should now provide a clear next action rather than leaving the player confused.
Suggested quest update:
```text
You cannot carry enough pelts. Return to the South Gate Warden.
```
Do not secretly increase capacity for the quest.
---
## 5. Referral to the Merchant
On returning, the warden acknowledges the problem and refers the player to Borin.
Example:
> "Right. You're not equipped for hauling spoils yet. Go see Borin in Graufurt. Tell him I sent you. He'll complain, but he'll give you something useful."
Set a persisted unlock/referral flag, for example:
```text
referred-by-south-gate-warden
```
New objective:
```text
Speak with Borin in Graufurt
```
---
## 6. Merchant Interaction
Borin normally would not provide the bag at the player's current reputation.
His dialogue should make the relationship system visible.
Example:
> "I don't know you, and I don't hand out gear to every wanderer who walks in here."
Then after recognizing the referral:
> "But the South Gate Warden sent you. Fine. Take this. Bring it back full and make it worth my trouble."
Grant:
**Basic Hide Bag**
```text
Category: HIDE
Capacity: 5
```
The grant should use the quest/referral exception supported by Slice 0.8.5.
---
## 7. Continue the Hunt
The player can now carry five HIDE-category goods.
Objective returns to:
```text
Collect 5 Ashen Pelts
```
Important:
- an already-owned first pelt still counts
- the player should need four more, not five more
- the bag capacity view changes immediately to `1 / 5`
---
## 8. Quest Turn-In
The player returns to the South Gate Warden with five Ashen Pelts.
The quest consumes the required five pelts.
The warden explains the normal long-term loop:
> "Good. That's enough for me. From now on, take hides and trophies to Borin. He'll pay for useful spoils, and word gets around when you keep the roads clear."
This line connects:
- monster loot
- merchant exchange
- Silver
- regional reputation
- World Renown
without needing a separate tutorial screen.
---
## 9. Quest Rewards
The main reward of this quest is the **Basic Hide Bag** and system knowledge.
Optional one-time rewards may include:
- small regional reputation increase
- small World Renown milestone increase
- quest completion flag
Avoid a large Silver reward that undermines the merchant trade loop.
No XP reward.
---
## 10. Minimal Quest System Requirements
The system must support at least:
- quest definition
- quest acceptance
- ordered objectives
- item collection progress
- NPC interaction objective
- persisted completion
- quest flags/unlock flags
- item consumption on turn-in
- one-time rewards
Do not build a full branching narrative engine.
A simple step-based quest state machine is sufficient.
---
## 11. Quest State Safety
The flow must not softlock when:
- player already owns one or more Ashen Pelts before accepting
- player sells/trades pelts while quest is active
- player disconnects during a quest step
- bag was already obtained through development data
- player tries to receive the referral bag twice
Define deterministic behavior.
Recommended:
- objectives derive progress from current owned quantity where appropriate
- quest item consumption validates quantity at turn-in
- bag grant is idempotent
- referral flag persists
---
## 12. UI Requirements
At minimum:
### NPC / Local View
- quest available marker
- quest in progress marker
- quest turn-in marker
- Talk interaction
### Quest UI
Show:
- title
- short description
- current objective
- progress
Example:
```text
Trouble Beyond the Gate
Collect Ashen Pelts 1 / 5
You cannot carry enough pelts. Return to the South Gate Warden.
```
### Bag UI
When the bag is granted:
```text
New Loot Bag
Basic Hide Bag
Hide Capacity: 5
```
---
## 13. Tests
- quest can be accepted only once in active state
- first pelt updates objective to 1/5
- no bag blocks carrying second HIDE item
- return step activates correctly
- referral flag is set
- merchant grants Basic Hide Bag once
- capacity becomes 5
- previously owned pelt remains counted
- five pelts allow turn-in
- turn-in consumes five pelts
- quest completes and cannot reward bag repeatedly
---
## 14. Acceptance Criteria
- [ ] One complete NPC quest chain is playable.
- [ ] The quest intentionally exposes the default 1-item carrying limit.
- [ ] The player is directed back instead of softlocked.
- [ ] A persisted merchant referral is created.
- [ ] Merchant grants Basic Hide Bag because of the referral.
- [ ] Hide capacity becomes 5.
- [ ] Player can collect five Ashen Pelts.
- [ ] Turn-in consumes the pelts and completes the quest.
- [ ] NPC explains that future goods should be traded to the merchant.
- [ ] Quest grants no XP.
- [ ] All dialogue/UI text is English.
---
## 15. Out of Scope
Do not implement:
- branching quest choices
- voice acting
- cinematic dialogue
- daily quests
- repeatable bounty framework
- quest sharing
- party quest progress
- large quest journal taxonomy

View File

@@ -1,283 +0,0 @@
# Ashen Realms Playable Slice 0.11: Grenzmarken & First Merchant
**Status:** Ready for implementation
**Prerequisite:** Playable Slice 0.10 First NPC & Quest
**Scope:** First regional currency and targeted merchant progression
**Currency:** Grenzmarken
**Next Slice:** Playable Slice 0.12 Aschenfelder Complete
## 1. Goal
Implement the first anti-frustration progression system.
The core philosophy is:
> Drops create excitement. Regional currency prevents frustration.
The player can earn Grenzmarken through normal progression and use them to buy targeted Tier-1 upgrades.
## 2. Grenzmarken
Implement persistent:
```text
Grenzmarken
```
Sources include existing configured rewards such as:
```text
Quests
rare enemies
stronger enemies
Elite
Boss
```
Use the current balancing/content definitions as the source of truth.
## 3. Currency model
At this stage a reusable currency model may become worthwhile.
Conceptually:
```text
CurrencyDefinition
CharacterCurrency
```
Example stable keys:
```text
silver
border-marks
```
If Silver is already safely represented directly on Character, do not force a risky large migration solely for architectural purity.
The important requirement is that Grenzmarken are persistent and server-authoritative.
## 4. Merchant
Introduce the first regional merchant at the appropriate existing NPC/location.
Use existing project content if a merchant NPC/location has already been defined.
Minimal merchant data:
```text
ShopDefinition
ShopOffer
```
A ShopOffer should determine:
```text
item
currency
price
availability
```
## 5. Grenzmarken offers
Use the established prices:
| Item | Price |
|---|---:|
| Räuberhaube | 5 Grenzmarken |
| Plündererhandschuhe | 6 Grenzmarken |
| Wachmannsbeinkleid | 8 Grenzmarken |
| Verstärkte Lederjacke | 10 Grenzmarken |
| Aschenklinge | 15 Grenzmarken |
Do not silently rebalance these prices in this implementation slice.
## 6. Shop API
Use the existing REST conventions.
Conceptually:
```http
GET /api/shops/:shopId
POST /api/shops/:shopId/purchases
```
Purchase request:
```json
{
"offerId": "uuid"
}
```
Do not accept authoritative client values such as:
```text
price
itemId
currency amount
discount
```
The server derives them from the persisted offer.
## 7. Purchase transaction
A purchase must be atomic:
```text
load character currency
load and validate offer
validate balance
subtract Grenzmarken
create CharacterItem
commit
```
If the transaction fails, neither the currency deduction nor the item grant should remain partially applied.
## 8. Ownership and duplicate items
Purchased equipment becomes normal persistent CharacterItem data.
Duplicates are allowed.
Do not implement:
```text
salvaging
buyback
duplicate conversion
pity conversion
```
## 9. Merchant UI
Show:
- merchant/NPC presentation
- current Grenzmarken balance
- available offers
- item icon
- item name
- item stats
- price
- affordability
- purchase action
Reuse the item display and comparison language from Inventory where possible.
## 10. Purchase feedback
After purchase:
```text
Grenzmarken balance decreases
item appears in inventory
player may open inventory
player may equip item
```
Do not automatically equip purchased equipment.
## 11. Progression proof
The important flow is:
```text
desired drop does not appear
player keeps playing
Grenzmarken accumulate
merchant provides targeted upgrade
```
The player should always feel that unlucky drops still produce progress.
## 12. Server authority
The server decides:
- currency balance
- reward grants
- offer availability
- offer price
- purchase validity
- resulting item ownership
Angular only requests purchase of a valid server-provided `offerId`.
## 13. Explicit non-goals
Do not implement:
```text
selling
buyback
dynamic prices
limited stock
shop refresh timers
player trading
auction house
discount systems
reputation
crafting vendor
```
## 14. Required tests
Verify:
- Grenzmarken rewards persist
- current balance is server-authoritative
- shop returns persisted offers
- insufficient balance rejects purchase
- valid purchase deducts exact configured cost
- valid purchase grants the correct CharacterItem
- purchase is transactional
- repeated request cannot accidentally duplicate a single transaction through race conditions
- item survives refresh
- purchased item can be equipped using existing equipment flow
## 15. Definition of Done
The player can:
```text
earn Grenzmarken
see balance
open merchant
inspect offers
buy targeted Tier-1 item
item enters inventory
equip item
```
Random loot and guaranteed long-term progression are now connected.
## 16. Handoff
Next:
```text
Playable Slice 0.12 Aschenfelder Complete
```

View File

@@ -1,490 +0,0 @@
# Ashen Realms Playable Slice 0.12: Aschenfelder Complete
**Status:** Final integration slice for the first region
**Prerequisite:** Playable Slices 0.10.11
**Scope:** Integration, balancing, content completion and polish for the Aschenfelder
**Primary Goal:** Complete the first genuinely playable regional progression loop.
## 1. Goal
Playable Slice 0.12 does not introduce a large new system.
It integrates, verifies, balances, and polishes everything built so far into one coherent first region.
The full intended flow is:
```text
Graufurt
Südtor
Verbrannte Straße
first hunts
first combat
first loot
first upgrades
Verlassener Wachtposten
NPC / Quest
stronger enemies
first Elite
Grenzmarken
targeted upgrades
Aschengrube
regional Boss
guaranteed boss loot
path toward Dämmerwald discovered
```
## 2. Complete locations
The Aschenfelder progression contains:
```text
Südtor von Graufurt
Verbrannte Straße
Verlassener Wachtposten
Aschengrube
```
Each location must have a clear purpose and correct travel connections.
## 3. Complete Tier-1 enemy set
The relevant first-region pool should now include the established enemies:
```text
Aschenratte
Verwilderter Straßenhund
Straßenräuber
Plünderer-Späher
Plünderer-Veteran
Verkohlter Plünderer
Aschenwühler
Verbrannter Jagdhund
Plündererhauptmann / Elite
Hauptmann der Aschenbande / Boss
```
If final naming differs in existing project content, preserve the existing finalized names rather than creating duplicates.
## 4. Complete Tier-1 item set
The relevant Tier-1 pool should now include:
```text
Abgenutztes Kurzschwert
Räuberklinge
Aschenklinge
Räuberhaube
Verstärkte Lederjacke
Plündererhandschuhe
Wachmannsbeinkleid
Aschenstiefel
Zeichen der Grenzwacht
Anhänger des verbrannten Hauptmanns
```
Not every item must be required for progression.
Prestige/special drops remain optional.
## 5. Combat duration targets
During the balancing pass, verify:
### Normal enemies
```text
48 rounds
```
### Elite
```text
610 rounds
```
### Boss
```text
814 rounds
```
These are balancing targets rather than hard rules.
## 6. Power progression targets
Use the established Combat Power progression as a reference:
### Start
```text
approximately 47 CP
```
### After first upgrades
```text
approximately 6070 CP
```
### Wachtposten progression
```text
approximately 8090 CP
```
### Aschengrube / boss-ready range
```text
approximately 100110 CP
```
Combat Power is an internal balancing tool, not a hard access gate.
## 7. Progression pacing
A typical player should need roughly:
```text
1218 normal combats
+
quests
+
boss
```
to progress meaningfully through the first region.
The intended experience must not require 50+ normal combats for basic progression.
## 8. First upgrade pacing
Verify that within approximately the first:
```text
35 combats
```
the player is very likely to see a meaningful equipment upgrade.
If this does not happen reliably enough, tune only the smallest necessary variables:
```text
drop rate
quest reward
starter equipment
```
Do not add unnecessary new reward systems.
## 9. Boss readiness
The regional boss should become reasonably beatable at approximately:
```text
7080% of maximum realistically obtainable regional power
```
Best-in-slot equipment should provide comfort and completionist value, not be mandatory.
## 10. Soft-gate validation
The player should not be blocked primarily by arbitrary level errors.
Where possible:
```text
actual world danger
```
should communicate that the player is too weak.
A player may attempt difficult content early, but the game should make the risk clear.
## 11. Full UI consistency pass
Review all implemented screens together:
```text
World
Hunt
Combat
Loot
Inventory
Quests
Merchant
```
Verify consistency of:
```text
Topbar
SideNavigation
Footer
Context Panel
Panel frames
Buttons
DangerBadges
Item icons
Combat actions
Typography
Spacing
Design tokens
```
No screen should feel like a separate web application.
## 12. Loading and error-state pass
All core player actions need intentional UI states:
```text
loading
success
domain error
network error
retry
```
Review at minimum:
```text
Travel
Hunt
Combat Action
Reward
Equip
Quest
Purchase
```
Do not use browser alerts.
## 13. Refresh and recovery pass
The following states must survive browser refresh through authoritative backend state:
```text
active travel
current hunt
active combat
finished combat/reward
inventory
equipment
active quest
Grenzmarken
boss completion
```
No critical gameplay state may exist only in Angular memory.
## 14. Full integration test
Where practical, automate the first complete progression flow:
```text
character starts at Südtor
travel to Verbrannte Straße
hunt
combat
win
receive loot
equip upgrade
travel to Wachtposten
accept quest
defeat relevant enemies
progress quest
earn Grenzmarken
buy targeted item
equip item
travel to Aschengrube
defeat boss
receive guaranteed boss reward
region completion persists
```
## 15. Manual playtest average RNG
Perform at least one normal playthrough and measure:
```text
combat duration
loot frequency
upgrade frequency
number of hunts
travel flow
quest pacing
Grenzmarken income
boss readiness
total region time
```
## 16. Manual playtest bad RNG
Test a deliberately unlucky run.
Verify:
```text
Grenzmarken still provide progress
progression does not hard-block
boss readiness remains achievable
player does not require extreme grind
```
## 17. Manual playtest good RNG
Test a deliberately lucky run.
Verify:
```text
region does not become instantly trivial
progression cannot be skipped too aggressively
boss remains meaningful
```
## 18. Data/content validation
Verify:
- all locations use persisted content
- all encounter pools use persisted relationships
- all loot is data-driven
- all shop offers are data-driven
- quest definitions are persisted/data-driven
- no core gameplay path depends on hardcoded Angular content
- no server domain service contains unnecessary location-specific special cases
## 19. Server-authority validation
Confirm that the backend remains authoritative for:
```text
travel
hunt availability
encounter generation
danger rating
combat
loot
XP
Silver
Grenzmarken
inventory ownership
equipment
effective stats
quest progress
shop purchases
boss completion
```
## 20. Performance / technical cleanup
Review the accumulated implementation for:
- duplicate API calls
- duplicated frontend state
- duplicated domain validation
- unused temporary placeholder logic
- obsolete Slice 0.10.5 shortcuts
- hardcoded demo combat stats that should now use CharacterStatsService
- temporary combat placeholder routes
- duplicated item definitions
- migration quality
- seed idempotency
Do not perform unrelated architectural rewrites.
## 21. Definition of Done
Playable Slice 0.12 / Aschenfelder Complete is complete when a fresh character can, without developer intervention:
```text
start
travel
hunt
fight
receive loot
improve equipment
meet NPC
complete quest
fight Elite
earn Grenzmarken
buy targeted item
reach Aschengrube
defeat regional Boss
receive guaranteed progression reward
discover path toward Dämmerwald
```
## 22. Final product test
The most important qualitative result is:
> After completing the Aschenfelder, the player should want to see what waits in the Dämmerwald.
If the loop is technically correct but does not create that motivation, the region still needs iteration.
## 23. After Slice 0.12
Do not immediately expand into the full Dämmerwald.
First:
```text
playtest
measure
balance
fix friction
validate reward pacing
validate combat decisions
validate boss readiness
```
Only after the first region loop works well should the same systems be expanded into the Dämmerwald and later the Vergessene Ruinen.

View File

@@ -1,235 +0,0 @@
# Ashen Realms Playable Slice 0.7: Complete Verbrannte Straße
**Status:** Ready for implementation
**Prerequisite:** Playable Slice 0.6 Full First Combat
**Scope:** First fully playable hunting location
**Primary Location:** Verbrannte Straße
**Next Slice:** Playable Slice 0.8 Wachtposten & First Elite
## 1. Goal
Turn the technical hunting location into the first complete gameplay location.
The Verbrannte Straße must support repeated hunting, multiple enemy archetypes, differentiated mechanics, meaningful loot, and the first visible progression through equipment.
## 2. Complete encounter pool
The hunting pool becomes:
```text
Aschenratte
Verwilderter Straßenhund
Straßenräuber
Verkohlter Plünderer rare
```
## 3. Aschenratte
Role:
- Level 1.
- Basic enemy.
- No special mechanic required.
Rewards:
```text
47 Silver
8 XP
60% Aschenfell / trade material
8% simple starter-slot equipment
```
## 4. Verwilderter Straßenhund
Role:
- Level 12.
- Introduces the first simple status effect.
Mechanic:
```text
BLEED
```
Keep Bleed deliberately simple:
- fixed duration
- fixed damage
- no complex stacking
- server-authoritative
- represented through structured combat state/events
Rewards:
```text
610 Silver
12 XP
70% Zähes Fell
8% Aschenstiefel
5% Kleiner Heiltrank
```
Do not implement the entire long-term consumable economy merely because a potion can drop.
## 5. Straßenräuber
Role:
- Level 2.
- Reinforces Telegraphing.
Mechanic:
```text
prepared Heavy Attack
```
Rewards:
```text
915 Silver
16 XP
18% Räuberklinge
12% Räuberhaube
8% Plündererhandschuhe
10% Kleiner Heiltrank
```
## 6. Verkohlter Plünderer
Role:
- Level 3.
- Rare encounter.
- Noticeably stronger.
- Early long-term target.
Guaranteed:
```text
2535 Silver
35 XP
1 Grenzmarke
```
Additional equipment drops use the existing balancing/content definitions.
The encounter should communicate:
> This enemy may be too strong right now, but the player can return later.
## 7. Encounter pool weights
All encounter probability belongs in persisted `LocationMonster` content.
Do not hardcode location-specific monster probabilities inside the HuntingService.
Conceptually:
```text
Aschenratte common
Straßenhund common
Straßenräuber normal
Verkohlter Plünderer rare
```
Exact weights may be tuned later.
## 8. Grenzmarken
Slice 0.7 may begin persisting Grenzmarken as reward data because the rare enemy already grants one.
They do not need to be spendable yet.
Spending Grenzmarken belongs to Slice 0.11.
## 9. Progression target
Within approximately the first 35 combats, the player should have a high chance of seeing the first meaningful equipment upgrade.
The purpose is to prove:
```text
hunt
→ fight
→ loot
→ equip
→ become stronger
```
through repeated play at one location.
## 10. Hunt UI
The Hunt context panel should now communicate the broader enemy pool.
Example:
```text
Mögliche Begegnungen
Aschenratte
Verwilderter Straßenhund
Straßenräuber
???
```
A rare enemy may remain hidden until first encountered.
Do not expose exact encounter percentages.
## 11. Explicit non-goals
Do not implement yet:
```text
Quest
NPC
Wachtposten progression
Boss
Merchant
Grenzmarken shop
Area completion
```
## 12. Required tests
Verify:
- all four monsters are persisted content
- all four are assigned to `burned-road`
- weighted selection remains deterministic in tests
- rare encounter selection is supported
- Bleed resolves correctly
- Straßenräuber Telegraphing still works
- loot tables match configured content
- Grenzmarken can be persisted when granted
- item upgrades remain persistent after reward flow
## 13. Definition of Done
The player can remain on the Verbrannte Straße and experience:
```text
different encounters
different mechanics
different rewards
first upgrades
visibly increasing strength
```
The location now feels like a real piece of the game rather than a technical test area.
## 14. Handoff
Next:
```text
Playable Slice 0.8 Wachtposten & First Elite
```

View File

@@ -1,238 +0,0 @@
# Ashen Realms Playable Slice 0.8: Wachtposten & First Elite
**Status:** Ready for implementation
**Prerequisite:** Playable Slice 0.7 Complete Verbrannte Straße
**Scope:** First progression inside a region and first elite challenge
**Primary Location:** Verlassener Wachtposten
**Next Slice:** Playable Slice 0.9 Aschengrube & First Boss
## 1. Goal
Introduce the first meaningful progression from one dangerous location to another.
The player should reach a new location, encounter stronger enemies, discover an elite target, improve equipment, and return strong enough to defeat it.
## 2. New location
Add:
```text
Verlassener Wachtposten
```
Connection:
```text
Verbrannte Straße
Verlassener Wachtposten
```
Travel duration:
```text
approximately 15 seconds
```
Travel remains server-authoritative.
## 3. Location functions
The Wachtposten should support:
```text
Jagd beginnen
Wachtposten untersuchen
NPC visible
travel back to Verbrannte Straße
future travel toward Aschengrube
```
The NPC may already be visually present, but full NPC/quest interaction is deferred to Slice 0.10.
## 4. Encounter pool
Use the existing world/content design as the source of truth.
Core enemies:
```text
Straßenräuber
Plünderer-Späher
Plünderer-Veteran
Plündererhauptmann
```
## 5. Plünderer-Späher
Role:
- lighter humanoid enemy
- familiar combat foundation
- part of Wachtposten progression
Use persisted content and data-driven mechanics.
## 6. Plünderer-Veteran
Role:
- first clearly tougher regular humanoid
- combines existing mechanics
Mechanics:
```text
Heavy Attack
+
defensive stance
```
The defensive stance may temporarily increase armor or otherwise use the established deterministic defensive mechanic.
Avoid adding a generic scripting engine.
## 7. Plündererhauptmann
Role:
```text
ELITE
```
The elite combines 23 already learned mechanics.
The elite should be:
- difficult or impractical for an unupgraded starting character
- manageable after meaningful Tier-1 upgrades
- farmable for a recognizable desirable reward
## 8. Elite combat duration
Target:
```text
610 rounds
```
This is a balancing goal, not a hard mechanical rule.
## 9. Elite loot
The elite must have a clear reason to be farmed.
Use the existing balancing/content data for its target rewards.
Do not invent a separate random loot pool if the project already defines the relevant Tier-1 upgrade source.
A desirable weapon such as the Aschenklinge may serve as a primary target where consistent with existing content.
## 10. Danger Rating
By this slice, danger ratings should use real effective player stats.
The backend should derive them from:
```text
CharacterStatsService
+
enemy / encounter power
```
Supported ratings:
```text
WEAK
MATCH
STRONG
VERY_DANGEROUS
DEADLY
```
Use the balancing thresholds as the initial calibration.
Do not calculate this in Angular.
## 11. Encounter UI
Elite encounters must remain part of the reusable EncounterCard system.
They may receive:
```text
ELITE label
stronger border treatment
higher visual emphasis
```
Do not create a completely separate elite hunting UI.
## 12. World UI
Add the Wachtposten as a real travel node.
The world screen should communicate:
- current location
- Verbrannte Straße connection
- Wachtposten connection
- current danger/recommended range
- future Aschengrube direction as appropriate
## 13. Explicit non-goals
Do not implement yet:
```text
full quest flow
merchant
boss
area completion
Dämmerwald
```
## 14. Required tests
Verify:
- location and connection persistence
- travel works in both intended directions
- encounter pool is location-driven
- Plünderer-Veteran mechanics work
- elite encounter classification is persisted
- Danger Rating uses authoritative effective character stats
- elite loot is server-generated
- an undergeared character receives a stronger danger rating than an upgraded one where expected
## 15. Definition of Done
The player can experience:
```text
reach Wachtposten
see stronger enemies
discover Elite
struggle or fail
farm upgrades
return
defeat Elite
```
This is the first direct playable proof of:
> Become stronger and return.
## 16. Handoff
Next:
```text
Playable Slice 0.9 Aschengrube & First Boss
```

View File

@@ -1,245 +0,0 @@
# Ashen Realms Playable Slice 0.9: Aschengrube & First Boss
**Status:** Ready for implementation
**Prerequisite:** Playable Slice 0.8 Wachtposten & First Elite
**Scope:** First region climax and first boss
**Primary Location:** Aschengrube
**Boss:** Hauptmann der Aschenbande
**Next Slice:** Playable Slice 0.10 First NPC & Quest
## 1. Goal
Implement the first true regional progression check.
The player reaches the hardest Aschenfelder location, fights stronger enemies, challenges the first boss, receives guaranteed meaningful progress, and discovers the future path toward the Dämmerwald.
## 2. New location
Add:
```text
Aschengrube
```
Connection:
```text
Verlassener Wachtposten
Aschengrube
```
Use the existing world/content design for travel duration, danger, artwork, and location metadata.
## 3. Regular encounter pool
Core encounters:
```text
Aschenwühler
Plünderer-Veteran
Verbrannter Jagdhund
```
Use the existing balancing and content definitions.
The location should feel more dangerous than the previous two hunting areas.
## 4. Boss access
The boss should not be rolled as a normal random hunt encounter.
The Aschengrube exposes a dedicated location action:
```text
Hauptmann herausfordern
```
This establishes the reusable concept of a boss/location action without creating a boss-specific one-off controller.
## 5. Boss
Implement:
```text
Hauptmann der Aschenbande
Level 3
BOSS
```
Mechanics:
```text
normal attack
telegraphed Heavy Attack
defensive phase / armor increase
below 30% HP:
more aggressive behavior
```
Use reusable combat mechanics rather than a bespoke boss script framework.
## 6. Boss duration
Target:
```text
814 rounds
```
This is a balancing target.
The boss should not be realistically comfortable for a fresh character, but should become manageable through the Tier-1 progression available in the Aschenfelder.
## 7. Boss reward rule
Introduce the first boss guarantee system.
### Roll A guaranteed progression
Always grant:
```text
1 high-quality Tier-1 item
```
Guaranteed pool:
```text
Verstärkte Lederjacke
Wachmannsbeinkleid
Aschenstiefel
Zeichen der Grenzwacht
```
### Roll B additional special drops
```text
20% Aschenklinge
8% Anhänger des verbrannten Hauptmanns
```
Guaranteed additional rewards:
```text
5070 Silver
80 XP
5 Grenzmarken
```
The guaranteed progression roll and special rolls are separate.
## 8. Loot architecture extension
Extend the existing loot system to support:
```text
guaranteed item pool
+
independent optional rolls
```
Do not implement the boss reward directly inside the controller.
The behavior must remain data-driven and reusable.
## 9. Boss completion persistence
Persist first boss completion / regional progression state.
The exact data structure should follow the existing project conventions.
At minimum, the server must know that the character has defeated the regional boss.
Do not rely on Angular state.
## 10. Future path discovery
After the first boss victory, expose the future progression hint:
```text
Ein Pfad in Richtung Dämmerwald wurde entdeckt.
```
The Dämmerwald itself does not need to be playable yet.
The world state may mark a future node/connection as discovered or unlocked according to the existing architecture.
## 11. Boss UI
The boss encounter should visually communicate that it is a major challenge.
Use:
- existing combat screen
- boss label
- stronger framing
- clear Telegraphing
- phase/status information
- existing combat log/event system
Do not build a separate game mode.
## 12. Explicit non-goals
Do not implement yet:
```text
Dämmerwald content
complex boss scripting DSL
multiple boss instances
raid mechanics
group combat
boss matchmaking
```
## 13. Required tests
Verify:
- boss can only be started through a valid server-side boss/location action
- boss combat uses persisted authoritative stats
- defensive phase changes combat behavior
- low-HP aggression triggers correctly
- boss victory persists
- boss reward is granted exactly once
- guaranteed Tier-1 item always appears
- special drop rolls remain independent
- XP, Silver and Grenzmarken match content rules
- page refresh does not duplicate boss reward
- future path discovery persists
## 14. Definition of Done
The player can:
```text
reach Aschengrube
fight regular encounters
challenge boss
read and react to mechanics
win
receive guaranteed progression item
receive boss rewards
discover route toward Dämmerwald
```
A boss victory must never end without meaningful progress.
## 15. Handoff
Next:
```text
Playable Slice 0.10 First NPC & Quest
```

View File

@@ -0,0 +1,39 @@
# Ashen Realms Slice Pack 0.6.6 to 0.12
This pack replaces the previously planned post-0.6 progression order with a sequence aligned to the new Reputation / World Renown progression model.
## Recommended implementation order
1. `0.6.6-English-Game-Content-Foundation.md`
2. `0.7-Complete-Burned-Road-V2.md`
3. `0.7.5-Monster-Categories-and-Loot-Bags.md`
4. `0.8-Graufurt-Merchant-and-Trade-In.md`
5. `0.8.5-Reputation-Gated-Merchant-Offers.md`
6. `0.9-First-Quest-and-Bag-Tutorial.md`
7. `0.10-Abandoned-Watchpost.md`
8. `0.11-Ash-Pit-and-Ashen-Band-Captain.md`
9. `0.12-Ashen-Fields-Complete-and-Polish.md`
## Core progression rule for this pack
Normal monsters do not directly grant XP, Silver, regional reputation or World Renown.
Instead:
```text
Monster
→ trade goods / equipment
→ loot-bag capacity
→ merchant
→ Silver + regional reputation + World Renown
→ better bags / equipment / access
→ stronger content
```
Major one-time milestones such as a first area-boss victory may grant direct World Renown when explicitly configured.
## Language rule
From Slice 0.6.6 onward:
> All player-facing game content is English. Development documentation may remain German.

180
tools/asset-gen.mjs Normal file
View File

@@ -0,0 +1,180 @@
/**
* Derives 9-slice-safe HUD assets from the hand-made art in
* `apps/web/public/assets/hud-elements/`.
*
* `panel-bg.png` carries a gem ornament at the centre of every edge. Stretched
* by `border-image` those ornaments smear, so this script paints them out
* against a plain stretch of the same edge and saves the result as a frame that
* tiles at any panel size. The ornaments are saved separately so the UI can put
* them back at the true centre of each panel.
*
* The generated files are committed, so this only needs re-running when the
* source art changes. It drives Playwright's bundled Chromium for canvas work;
* Playwright is not a direct dependency (it arrives with the Angular test
* builder), so install it explicitly if that ever stops being true.
*
* Run with the dev server up (a file:// canvas is tainted and cannot be read):
* node tools/asset-gen.mjs # defaults to http://localhost:4455
* ASSET_ORIGIN=http://localhost:4200 node tools/asset-gen.mjs
*/
import { chromium } from 'playwright';
import { fileURLToPath } from 'url';
import { writeFile } from 'fs/promises';
import path from 'path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const outDir = path.join(root, 'apps/web/public/assets/hud-elements');
const ORIGIN = process.env.ASSET_ORIGIN ?? 'http://localhost:4455';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`${ORIGIN}/assets/hud-elements/panel-bg.png`);
const result = await page.evaluate(async () => {
const load = async (src) => {
const img = new Image();
img.src = src;
await img.decode();
return img;
};
const canvasOf = (w, h) => {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
};
const img = await load('panel-bg.png');
const src = canvasOf(img.naturalWidth, img.naturalHeight);
src.getContext('2d').drawImage(img, 0, 0);
const sctx = src.getContext('2d');
// ---- 1. trim the transparent margin so the frame meets the image edge ----
const full = sctx.getImageData(0, 0, src.width, src.height).data;
const alphaAt = (x, y) => full[(y * src.width + x) * 4 + 3];
let x0 = 0;
let x1 = src.width - 1;
let y0 = 0;
let y1 = src.height - 1;
const opaqueCol = (x) => {
for (let y = 0; y < src.height; y++) if (alphaAt(x, y) > 40) return true;
return false;
};
const opaqueRow = (y) => {
for (let x = 0; x < src.width; x++) if (alphaAt(x, y) > 40) return true;
return false;
};
while (x0 < x1 && !opaqueCol(x0)) x0++;
while (x1 > x0 && !opaqueCol(x1)) x1--;
while (y0 < y1 && !opaqueRow(y0)) y0++;
while (y1 > y0 && !opaqueRow(y1)) y1--;
const W = x1 - x0 + 1;
const H = y1 - y0 + 1;
const frame = canvasOf(W, H);
const fctx = frame.getContext('2d');
fctx.drawImage(src, x0, y0, W, H, 0, 0, W, H);
// ---- 2. locate the centred ornament on each edge ----
// An edge is a repeating bar away from its ornament, so a line sampled from a
// quiet stretch is a faithful stand-in. Walk out from the centre until the
// line matches that reference again: that is where the ornament ends.
const band = 96; // how deep into the panel an edge ornament reaches
const data = fctx.getImageData(0, 0, W, H).data;
const px = (x, y) => {
const i = (y * W + x) * 4;
return [data[i], data[i + 1], data[i + 2], data[i + 3]];
};
const diffColumns = (ax, bx, from, to) => {
let sum = 0;
for (let y = from; y < to; y++) {
const a = px(ax, y);
const b = px(bx, y);
sum += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) + Math.abs(a[3] - b[3]);
}
return sum / (to - from);
};
const diffRows = (ay, by, from, to) => {
let sum = 0;
for (let x = from; x < to; x++) {
const a = px(x, ay);
const b = px(x, by);
sum += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) + Math.abs(a[3] - b[3]);
}
return sum / (to - from);
};
const THRESHOLD = 18;
const refX = Math.round(W * 0.26); // quiet stretch of the horizontal edges
const refY = Math.round(H * 0.26); // quiet stretch of the vertical edges
const cx = Math.round(W / 2);
const cy = Math.round(H / 2);
const spanX = (from, to) => {
let a = cx;
let b = cx;
while (a > W * 0.15 && diffColumns(a, refX, from, to) > THRESHOLD) a--;
while (b < W * 0.85 && diffColumns(b, refX, from, to) > THRESHOLD) b++;
return [a, b];
};
const spanY = (from, to) => {
let a = cy;
let b = cy;
while (a > H * 0.15 && diffRows(a, refY, from, to) > THRESHOLD) a--;
while (b < H * 0.85 && diffRows(b, refY, from, to) > THRESHOLD) b++;
return [a, b];
};
const [topA, topB] = spanX(0, band);
const [botA, botB] = spanX(H - band, H);
const [leftA, leftB] = spanY(0, band);
const [rightA, rightB] = spanY(W - band, W);
// ---- 3. keep a copy of the top ornament before painting it out ----
// The bottom edge measures the ornament most tightly (the top edge's texture
// drifts enough to over-report), so use its width for both.
const ornW = Math.max(botB - botA, 96);
const ornH = band;
const ornX = Math.round(cx - ornW / 2);
const ornament = canvasOf(ornW, ornH);
ornament.getContext('2d').drawImage(frame, ornX, 0, ornW, ornH, 0, 0, ornW, ornH);
// ---- 4. paint each ornament out with the plain part of its own edge ----
// `drawImage` composites, so a transparent clean column would leave an opaque
// ornament showing through. Clear the region first, then replace it.
const patchH = (a, b, y) => {
fctx.clearRect(a, y, b - a, band);
for (let x = a; x < b; x++) fctx.drawImage(frame, refX, y, 1, band, x, y, 1, band);
};
const patchV = (a, b, x) => {
fctx.clearRect(x, a, band, b - a);
for (let y = a; y < b; y++) fctx.drawImage(frame, x, refY, band, 1, x, y, band, 1);
};
patchH(topA, topB, 0);
patchH(botA, botB, H - band);
patchV(leftA, leftB, 0);
patchV(rightA, rightB, W - band);
return {
frame: frame.toDataURL('image/png'),
ornament: ornament.toDataURL('image/png'),
meta: {
source: { w: img.naturalWidth, h: img.naturalHeight },
trimmed: { w: W, h: H },
ornaments: { top: [topA, topB], bottom: [botA, botB], left: [leftA, leftB], right: [rightA, rightB] },
ornamentSize: { w: ornW, h: ornH },
},
};
});
const write = async (name, dataUrl) => {
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
await writeFile(path.join(outDir, name), Buffer.from(base64, 'base64'));
};
await write('panel-frame.png', result.frame);
await write('panel-ornament.png', result.ornament);
console.log(JSON.stringify(result.meta, null, 2));
await browser.close();