inventory
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
BIN
apps/web/public/assets/hud-elements/panel-frame.png
Normal file
BIN
apps/web/public/assets/hud-elements/panel-frame.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
apps/web/public/assets/hud-elements/panel-ornament.png
Normal file
BIN
apps/web/public/assets/hud-elements/panel-ornament.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? '';
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
apps/web/src/app/features/inventory/inventory.labels.ts
Normal file
12
apps/web/src/app/features/inventory/inventory.labels.ts
Normal 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',
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
251
docs/playable-slices/0.10-Abandoned-Watchpost.md
Normal file
251
docs/playable-slices/0.10-Abandoned-Watchpost.md
Normal 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
|
||||||
261
docs/playable-slices/0.11-Ash-Pit-and-Ashen-Band-Captain.md
Normal file
261
docs/playable-slices/0.11-Ash-Pit-and-Ashen-Band-Captain.md
Normal 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
|
||||||
|
15–20 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
|
||||||
384
docs/playable-slices/0.12-Ashen-Fields-Complete-and-Polish.md
Normal file
384
docs/playable-slices/0.12-Ashen-Fields-Complete-and-Polish.md
Normal 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
|
||||||
|
|
||||||
|
**4–8 rounds** in appropriate content.
|
||||||
|
|
||||||
|
### Elite enemies
|
||||||
|
|
||||||
|
**6–10 rounds.**
|
||||||
|
|
||||||
|
### Boss
|
||||||
|
|
||||||
|
**8–14 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 10–15-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?**
|
||||||
300
docs/playable-slices/0.6.6-English-Game-Content-Foundation.md
Normal file
300
docs/playable-slices/0.6.6-English-Game-Content-Foundation.md
Normal 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**.
|
||||||
321
docs/playable-slices/0.7.5-Monster-Categories-and-Loot-Bags.md
Normal file
321
docs/playable-slices/0.7.5-Monster-Categories-and-Loot-Bags.md
Normal 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
|
||||||
300
docs/playable-slices/0.8-Graufurt-Merchant-and-Trade-In.md
Normal file
300
docs/playable-slices/0.8-Graufurt-Merchant-and-Trade-In.md
Normal 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
|
||||||
247
docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md
Normal file
247
docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md
Normal 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
|
||||||
321
docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md
Normal file
321
docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md
Normal 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
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -1,490 +0,0 @@
|
|||||||
# Ashen Realms – Playable Slice 0.12: Aschenfelder Complete
|
|
||||||
|
|
||||||
**Status:** Final integration slice for the first region
|
|
||||||
**Prerequisite:** Playable Slices 0.1–0.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
|
|
||||||
4–8 rounds
|
|
||||||
```
|
|
||||||
|
|
||||||
### Elite
|
|
||||||
|
|
||||||
```text
|
|
||||||
6–10 rounds
|
|
||||||
```
|
|
||||||
|
|
||||||
### Boss
|
|
||||||
|
|
||||||
```text
|
|
||||||
8–14 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 60–70 CP
|
|
||||||
```
|
|
||||||
|
|
||||||
### Wachtposten progression
|
|
||||||
|
|
||||||
```text
|
|
||||||
approximately 80–90 CP
|
|
||||||
```
|
|
||||||
|
|
||||||
### Aschengrube / boss-ready range
|
|
||||||
|
|
||||||
```text
|
|
||||||
approximately 100–110 CP
|
|
||||||
```
|
|
||||||
|
|
||||||
Combat Power is an internal balancing tool, not a hard access gate.
|
|
||||||
|
|
||||||
## 7. Progression pacing
|
|
||||||
|
|
||||||
A typical player should need roughly:
|
|
||||||
|
|
||||||
```text
|
|
||||||
12–18 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
|
|
||||||
3–5 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
|
|
||||||
70–80% 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.1–0.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.
|
|
||||||
@@ -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
|
|
||||||
4–7 Silver
|
|
||||||
8 XP
|
|
||||||
60% Aschenfell / trade material
|
|
||||||
8% simple starter-slot equipment
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Verwilderter Straßenhund
|
|
||||||
|
|
||||||
Role:
|
|
||||||
|
|
||||||
- Level 1–2.
|
|
||||||
- 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
|
|
||||||
6–10 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
|
|
||||||
9–15 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
|
|
||||||
25–35 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 3–5 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
|
|
||||||
```
|
|
||||||
@@ -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 2–3 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
|
|
||||||
6–10 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
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
8–14 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
|
|
||||||
50–70 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
|
|
||||||
```
|
|
||||||
39
docs/playable-slices/README.md
Normal file
39
docs/playable-slices/README.md
Normal 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
180
tools/asset-gen.mjs
Normal 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();
|
||||||
Reference in New Issue
Block a user