Merge branch 'master' into worktree-playable-slice-0.6.5-renown-reputation
Reconciles Slice 0.6.5 (Renown & Reputation Foundation) against master's persistent-HP-and-regeneration slice, which landed independently and touches several of the same files (Character entity, CombatService, EquipmentService, the inventory detail panel). Conflict resolutions: - CharacterStatsService/EquipmentService constructor wiring: kept master's CharacterVitalsService injection, which this branch's version of the same files didn't have yet. - CombatService.performAction: kept master's HP-guard logic (characterTooWounded, vitals pause-on-enter) alongside this branch's multi-line calculate() call style. - Inventory detail panel (.html/.ts/.scss/.spec.ts): master had redesigned the panel (wrapping section, rarity styling, flavour text, a shared inventory.labels.ts) on top of the OLD level-gated component, since this branch's removal of the level gate (R4, Task 8/14) hadn't reached master yet. Kept master's visual redesign in full, but with the level-gate concept removed throughout: no requiredLevel stat block, no meetsLevelRequirement() branch in the equip button, no now-dead .detail__value--unmet SCSS rule. Kept both branches' independent tests (non-equippable-item, flavour-text). - inventory-page.component.ts: dropped master's dead characterLevel computed (nothing in the template read it, and the level concept is gone); kept its independent bagCells/bagUsed/bagCapacity grid feature, which has nothing to do with renown or level. Post-merge fixture repairs (three files failed the Angular bundle compile because they predate master's hpRegenPerSecond/hpRegenSince fields or master's item description field, neither conflict-marked since git considered them non-overlapping edits): - app.spec.ts: a 'renders loaded character values' test added on master after this branch forked still used the abolished level/ experience fields on its decoy fixture -- retargeted to renown. - inventory-detail-panel.component.spec.ts: the ashPelt fixture added by this branch's final-review follow-up predates master's required description field. - top-bar.component.spec.ts: this branch's fixture predates master's required hpRegenPerSecond/hpRegenSince fields. No database migration touches the same column: master's 1792000000000-AddHpRegeneration only adds characters.hp_regen_since, independent of this slice's 1791000000000-CreateRenownAndReputation. Timestamp ordering between the two was already correct with no rename needed. Verified: API 288/288 (267 from this slice + 21 from master), API build zero errors, web 237/237 (230 from this slice + 7 from master). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -4,6 +4,7 @@ import { CharacterStatsService } from './character-stats.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||
import { CharacterVitalsService } from './character-vitals.service';
|
||||
|
||||
type EquippedFixture = {
|
||||
slot: EquipmentSlot;
|
||||
@@ -36,12 +37,16 @@ function character(overrides: Partial<Character> = {}): Character {
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 90,
|
||||
hpRegenSince: null,
|
||||
...overrides,
|
||||
} as Character;
|
||||
}
|
||||
|
||||
describe('CharacterStatsService', () => {
|
||||
const service = new CharacterStatsService({} as DataSource);
|
||||
const characterVitals = new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-21T12:00:00.000Z'),
|
||||
});
|
||||
const service = new CharacterStatsService({} as DataSource, characterVitals);
|
||||
|
||||
it('derives stats from the starting weapon alone', async () => {
|
||||
const scope = fakeScope([
|
||||
@@ -117,11 +122,46 @@ describe('CharacterStatsService', () => {
|
||||
expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5);
|
||||
});
|
||||
|
||||
it('passes currentHp through unchanged from the character', async () => {
|
||||
it('returns the raw current HP unchanged while regeneration is paused', async () => {
|
||||
const scope = fakeScope([]);
|
||||
|
||||
const stats = await service.calculate(character({ currentHp: 42 }), scope);
|
||||
const stats = await service.calculate(
|
||||
character({ currentHp: 42, hpRegenSince: null }),
|
||||
scope,
|
||||
);
|
||||
|
||||
expect(stats.currentHp).toBe(42);
|
||||
});
|
||||
|
||||
it('adds elapsed regeneration, clamped to maxHp, when a regen anchor is set', async () => {
|
||||
const scope = fakeScope([]);
|
||||
|
||||
const regenerating = await service.calculate(
|
||||
character({
|
||||
currentHp: 40,
|
||||
hpRegenSince: new Date('2026-08-21T11:59:30.000Z'),
|
||||
}),
|
||||
scope,
|
||||
);
|
||||
expect(regenerating.currentHp).toBe(70);
|
||||
|
||||
const clamped = await service.calculate(
|
||||
character({
|
||||
currentHp: 40,
|
||||
hpRegenSince: new Date('2026-08-21T11:40:00.000Z'),
|
||||
}),
|
||||
scope,
|
||||
);
|
||||
expect(clamped.currentHp).toBe(100);
|
||||
});
|
||||
|
||||
it('reports the regeneration rate and anchor alongside the effective stats', async () => {
|
||||
const scope = fakeScope([]);
|
||||
const anchor = new Date('2026-08-21T11:59:30.000Z');
|
||||
|
||||
const stats = await service.calculate(character({ hpRegenSince: anchor }), scope);
|
||||
|
||||
expect(stats.hpRegenPerSecond).toBe(1);
|
||||
expect(stats.hpRegenSince).toEqual(anchor);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||
import { HP_REGEN_PER_SECOND } from './character-vitals.constants';
|
||||
import { CharacterVitalsService } from './character-vitals.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
export interface EffectiveCharacterStats {
|
||||
@@ -11,6 +13,8 @@ export interface EffectiveCharacterStats {
|
||||
weaponDamage: number;
|
||||
armor: number;
|
||||
combatPower: number;
|
||||
hpRegenPerSecond: number;
|
||||
hpRegenSince: Date | null;
|
||||
}
|
||||
|
||||
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||
@@ -21,7 +25,10 @@ type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||
*/
|
||||
@Injectable()
|
||||
export class CharacterStatsService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly characterVitals: CharacterVitalsService,
|
||||
) {}
|
||||
|
||||
async calculate(
|
||||
character: Character,
|
||||
@@ -54,11 +61,13 @@ export class CharacterStatsService {
|
||||
|
||||
return {
|
||||
maxHp,
|
||||
currentHp: character.currentHp,
|
||||
currentHp: this.characterVitals.effectiveHp(character, maxHp),
|
||||
attack,
|
||||
weaponDamage,
|
||||
armor,
|
||||
combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5,
|
||||
hpRegenPerSecond: HP_REGEN_PER_SECOND,
|
||||
hpRegenSince: character.hpRegenSince,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/api/src/characters/character-vitals.constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const HP_REGEN_PER_SECOND = 1;
|
||||
136
apps/api/src/characters/character-vitals.service.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Clock } from '../shared/clock';
|
||||
import { CharacterVitalsService } from './character-vitals.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
function fakeClock(initialIso: string): {
|
||||
clock: Clock;
|
||||
advanceSeconds: (seconds: number) => void;
|
||||
} {
|
||||
let current = Date.parse(initialIso);
|
||||
return {
|
||||
clock: { now: () => new Date(current) },
|
||||
advanceSeconds: (seconds: number) => {
|
||||
current += seconds * 1000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function character(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: 'character-1',
|
||||
currentHp: 50,
|
||||
hpRegenSince: null,
|
||||
...overrides,
|
||||
} as Character;
|
||||
}
|
||||
|
||||
describe('CharacterVitalsService', () => {
|
||||
describe('effectiveHp', () => {
|
||||
it('returns the raw current HP when regeneration is paused', () => {
|
||||
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
|
||||
const hp = service.effectiveHp(character({ currentHp: 37, hpRegenSince: null }), 100);
|
||||
|
||||
expect(hp).toBe(37);
|
||||
});
|
||||
|
||||
it('adds one HP per elapsed second since the anchor', () => {
|
||||
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||
|
||||
advanceSeconds(25);
|
||||
|
||||
expect(service.effectiveHp(target, 100)).toBe(65);
|
||||
});
|
||||
|
||||
it('floors partial seconds instead of rounding up', () => {
|
||||
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||
|
||||
advanceSeconds(1.9);
|
||||
|
||||
expect(service.effectiveHp(target, 100)).toBe(41);
|
||||
});
|
||||
|
||||
it('clamps regeneration at maxHp', () => {
|
||||
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||
const target = character({ currentHp: 90, hpRegenSince: anchor });
|
||||
|
||||
advanceSeconds(50);
|
||||
|
||||
expect(service.effectiveHp(target, 100)).toBe(100);
|
||||
});
|
||||
|
||||
it('never lets HP fall if the clock moves backwards', () => {
|
||||
const clock: Clock = { now: () => new Date('2026-08-21T11:59:00.000Z') };
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||
|
||||
expect(service.effectiveHp(target, 100)).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pause', () => {
|
||||
it('freezes current HP at the given value and clears the anchor', () => {
|
||||
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const target = character({
|
||||
currentHp: 100,
|
||||
hpRegenSince: new Date('2026-08-21T11:00:00.000Z'),
|
||||
});
|
||||
|
||||
service.pause(target, 62);
|
||||
|
||||
expect(target.currentHp).toBe(62);
|
||||
expect(target.hpRegenSince).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resume', () => {
|
||||
it('sets current HP and anchors regeneration at now', () => {
|
||||
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const target = character({ currentHp: 0, hpRegenSince: null });
|
||||
|
||||
service.resume(target, 15);
|
||||
|
||||
expect(target.currentHp).toBe(15);
|
||||
expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:00.000Z'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('settle', () => {
|
||||
it('re-anchors at the current effective value without changing it', () => {
|
||||
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||
advanceSeconds(10);
|
||||
|
||||
service.settle(target, 100);
|
||||
|
||||
expect(target.currentHp).toBe(50);
|
||||
expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:10.000Z'));
|
||||
});
|
||||
|
||||
it('does not gift overflow past the pre-change maxHp when re-anchoring', () => {
|
||||
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||
const service = new CharacterVitalsService(clock);
|
||||
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||
const target = character({ currentHp: 100, hpRegenSince: anchor });
|
||||
advanceSeconds(600);
|
||||
|
||||
service.settle(target, 100);
|
||||
|
||||
expect(target.currentHp).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
apps/api/src/characters/character-vitals.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { CLOCK } from '../shared/clock';
|
||||
import type { Clock } from '../shared/clock';
|
||||
import { HP_REGEN_PER_SECOND } from './character-vitals.constants';
|
||||
import { Character } from './entities/character.entity';
|
||||
|
||||
/**
|
||||
* The only place that turns (current_hp, hp_regen_since) into an effective
|
||||
* HP value, or moves that pair. `current_hp` is exact only while the anchor
|
||||
* is null; everything else must go through here (persistent-hp-and-
|
||||
* regeneration design, R3).
|
||||
*/
|
||||
@Injectable()
|
||||
export class CharacterVitalsService {
|
||||
constructor(@Inject(CLOCK) private readonly clock: Clock) {}
|
||||
|
||||
effectiveHp(
|
||||
character: Pick<Character, 'currentHp' | 'hpRegenSince'>,
|
||||
maxHp: number,
|
||||
): number {
|
||||
if (character.hpRegenSince === null) {
|
||||
return Math.min(maxHp, character.currentHp);
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(
|
||||
0,
|
||||
(this.clock.now().getTime() - character.hpRegenSince.getTime()) / 1000,
|
||||
);
|
||||
const regenerated = Math.floor(elapsedSeconds * HP_REGEN_PER_SECOND);
|
||||
return Math.min(maxHp, character.currentHp + regenerated);
|
||||
}
|
||||
|
||||
pause(character: Character, value: number): void {
|
||||
character.currentHp = value;
|
||||
character.hpRegenSince = null;
|
||||
}
|
||||
|
||||
resume(character: Character, value: number): void {
|
||||
character.currentHp = value;
|
||||
character.hpRegenSince = this.clock.now();
|
||||
}
|
||||
|
||||
settle(character: Character, maxHp: number): void {
|
||||
this.resume(character, this.effectiveHp(character, maxHp));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CLOCK, systemClock } from '../shared/clock';
|
||||
import { CharacterStatsService } from './character-stats.service';
|
||||
import { CharacterVitalsService } from './character-vitals.service';
|
||||
import { CharactersController } from './characters.controller';
|
||||
import { CharactersService } from './characters.service';
|
||||
import { Character } from './entities/character.entity';
|
||||
@@ -8,7 +10,12 @@ import { Character } from './entities/character.entity';
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Character])],
|
||||
controllers: [CharactersController],
|
||||
providers: [CharactersService, CharacterStatsService],
|
||||
exports: [CharacterStatsService],
|
||||
providers: [
|
||||
CharactersService,
|
||||
CharacterStatsService,
|
||||
CharacterVitalsService,
|
||||
{ provide: CLOCK, useValue: systemClock },
|
||||
],
|
||||
exports: [CharacterStatsService, CharacterVitalsService],
|
||||
})
|
||||
export class CharactersModule {}
|
||||
|
||||
@@ -17,6 +17,8 @@ function fakeCharacterStats(
|
||||
weaponDamage: 8,
|
||||
armor: 0,
|
||||
combatPower: 0,
|
||||
hpRegenPerSecond: 1,
|
||||
hpRegenSince: new Date('2026-08-18T09:00:00.000Z'),
|
||||
}),
|
||||
} as unknown as CharacterStatsService;
|
||||
}
|
||||
@@ -50,6 +52,8 @@ describe('CharactersService', () => {
|
||||
currentHp: 100,
|
||||
maxHp: 115,
|
||||
attack: 7,
|
||||
hpRegenPerSecond: 1,
|
||||
hpRegenSince: '2026-08-18T09:00:00.000Z',
|
||||
currentLocation: {
|
||||
id: SOUTH_GATE_ID,
|
||||
key: 'south-gate',
|
||||
|
||||
@@ -30,9 +30,11 @@ export class CharactersService {
|
||||
name: character.name,
|
||||
renown: character.renown,
|
||||
silver: character.silver,
|
||||
currentHp: character.currentHp,
|
||||
currentHp: stats.currentHp,
|
||||
maxHp: stats.maxHp,
|
||||
attack: stats.attack,
|
||||
hpRegenPerSecond: stats.hpRegenPerSecond,
|
||||
hpRegenSince: stats.hpRegenSince ? stats.hpRegenSince.toISOString() : null,
|
||||
currentLocation: {
|
||||
id: character.currentLocation.id,
|
||||
key: character.currentLocation.key,
|
||||
|
||||
@@ -32,6 +32,12 @@ export class Character {
|
||||
@Column({ name: 'current_hp', type: 'integer' })
|
||||
currentHp!: number;
|
||||
|
||||
// `current_hp` is only exact while this is null (regeneration paused, e.g.
|
||||
// mid-combat). Otherwise it's the HP as of this timestamp -- read it
|
||||
// through CharacterVitalsService.effectiveHp(), never directly.
|
||||
@Column({ name: 'hp_regen_since', type: 'timestamptz', nullable: true })
|
||||
hpRegenSince!: Date | null;
|
||||
|
||||
@Column({ name: 'current_location_id', type: 'uuid' })
|
||||
currentLocationId!: string;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||
import { EquipmentService } from '../equipment/equipment.service';
|
||||
@@ -194,6 +195,7 @@ function character(overrides: Partial<Character> = {}): Character {
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
hpRegenSince: null,
|
||||
currentLocationId: 'location-1',
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
@@ -335,18 +337,24 @@ function createHarness() {
|
||||
],
|
||||
};
|
||||
const dataSource = new FakeDataSource(state);
|
||||
const characterVitals = new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
});
|
||||
const characterStats = new CharacterStatsService(
|
||||
dataSource as unknown as DataSource,
|
||||
characterVitals,
|
||||
);
|
||||
const equipmentService = new EquipmentService(
|
||||
dataSource as unknown as DataSource,
|
||||
characterStats,
|
||||
characterVitals,
|
||||
);
|
||||
const combatService = new CombatService(
|
||||
dataSource as unknown as DataSource,
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
characterStats,
|
||||
characterVitals,
|
||||
fakeRewardService(),
|
||||
);
|
||||
return { state, equipmentService, combatService };
|
||||
|
||||
@@ -5,6 +5,7 @@ export type CombatErrorCode =
|
||||
| 'HUNT_ENCOUNTER_ALREADY_CONSUMED'
|
||||
| 'INVALID_HUNT_ENCOUNTER'
|
||||
| 'CHARACTER_TRAVELLING'
|
||||
| 'CHARACTER_TOO_WOUNDED'
|
||||
| 'COMBAT_ALREADY_ACTIVE'
|
||||
| 'COMBAT_NOT_FOUND'
|
||||
| 'COMBAT_ALREADY_FINISHED'
|
||||
@@ -53,6 +54,14 @@ export function characterTravelling(): CombatDomainError {
|
||||
);
|
||||
}
|
||||
|
||||
export function characterTooWounded(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'CHARACTER_TOO_WOUNDED',
|
||||
HttpStatus.CONFLICT,
|
||||
'The character is too wounded to fight.',
|
||||
);
|
||||
}
|
||||
|
||||
export function combatAlreadyActive(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'COMBAT_ALREADY_ACTIVE',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||
@@ -178,6 +179,7 @@ function character(overrides: Partial<Character> = {}): Character {
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
hpRegenSince: null,
|
||||
currentLocationId: 'location-1',
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
@@ -283,11 +285,15 @@ function createService(
|
||||
const travelService = options.travelService ?? fakeTravelService();
|
||||
const combatEngine = new CombatEngineService();
|
||||
const characterCombatStats = fakeCharacterStats();
|
||||
const characterVitals = new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
});
|
||||
const service = new CombatService(
|
||||
dataSource as unknown as DataSource,
|
||||
travelService,
|
||||
combatEngine,
|
||||
characterCombatStats,
|
||||
characterVitals,
|
||||
fakeRewardService(),
|
||||
);
|
||||
return { dataSource, service, travelService };
|
||||
@@ -347,6 +353,49 @@ describe('CombatService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds player HP from the character, carrying HP from a previous fight rather than starting full', async () => {
|
||||
const state = createState({ characters: [character({ currentHp: 63 })] });
|
||||
const { dataSource, service } = createService({ state });
|
||||
|
||||
const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
expect(combat.player.currentHp).toBe(63);
|
||||
expect(dataSource.state.combats[0].playerCurrentHp).toBe(63);
|
||||
});
|
||||
|
||||
it('pauses regeneration on the character once a combat starts', async () => {
|
||||
const state = createState({
|
||||
characters: [
|
||||
character({ currentHp: 63, hpRegenSince: new Date('2026-08-18T08:00:00.000Z') }),
|
||||
],
|
||||
});
|
||||
const { dataSource, service } = createService({ state });
|
||||
|
||||
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
expect(dataSource.state.characters[0].currentHp).toBe(63);
|
||||
expect(dataSource.state.characters[0].hpRegenSince).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects starting a combat when the character has 0 effective HP', async () => {
|
||||
const state = createState({ characters: [character({ currentHp: 0 })] });
|
||||
const { service } = createService({ state });
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||
'CHARACTER_TOO_WOUNDED',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows starting a combat at exactly 1 effective HP', async () => {
|
||||
const state = createState({ characters: [character({ currentHp: 1 })] });
|
||||
const { service } = createService({ state });
|
||||
|
||||
await expect(
|
||||
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||
).resolves.toMatchObject({ status: 'ACTIVE' });
|
||||
});
|
||||
|
||||
it('marks the encounter as IN_PROGRESS', async () => {
|
||||
const { dataSource, service } = createService();
|
||||
|
||||
@@ -528,6 +577,41 @@ describe('CombatService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('mirrors the player HP onto the character each round while the fight continues', async () => {
|
||||
const { dataSource, service, combatId } = await startedCombat();
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
expect(dataSource.state.characters[0].currentHp).toBe(95);
|
||||
expect(dataSource.state.characters[0].hpRegenSince).toBeNull();
|
||||
});
|
||||
|
||||
it('restarts regeneration on the character once the fight is won', async () => {
|
||||
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
expect(dataSource.state.characters[0].currentHp).toBe(
|
||||
dataSource.state.combats[0].playerCurrentHp,
|
||||
);
|
||||
expect(dataSource.state.characters[0].hpRegenSince).toEqual(
|
||||
new Date('2026-08-18T09:00:00.000Z'),
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts regeneration from 0 HP once the fight is lost', async () => {
|
||||
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
expect(dataSource.state.characters[0].currentHp).toBe(0);
|
||||
expect(dataSource.state.characters[0].hpRegenSince).toEqual(
|
||||
new Date('2026-08-18T09:00:00.000Z'),
|
||||
);
|
||||
});
|
||||
|
||||
it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
|
||||
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
@@ -548,7 +632,7 @@ describe('CombatService', () => {
|
||||
|
||||
it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => {
|
||||
const state = createState({
|
||||
characters: [character({ baseHp: 1 })],
|
||||
characters: [character({ baseHp: 1, currentHp: 1 })],
|
||||
});
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
|
||||
@@ -579,7 +663,7 @@ describe('CombatService', () => {
|
||||
});
|
||||
|
||||
it('frees the encounter for another attempt when the fight is lost', async () => {
|
||||
const state = createState({ characters: [character({ baseHp: 1 })] });
|
||||
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
@@ -599,11 +683,19 @@ describe('CombatService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a lost encounter be fought again as a fresh combat', async () => {
|
||||
const state = createState({ characters: [character({ baseHp: 1 })] });
|
||||
it('lets a lost encounter be fought again once the character has recovered HP', async () => {
|
||||
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
|
||||
const { dataSource, service, combatId } = await startedCombat(state);
|
||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||
|
||||
// The loss now mirrors 0 HP onto the character (this task); simulate
|
||||
// that regeneration has since restored it before retrying. This test
|
||||
// is about the encounter itself being retryable once the character
|
||||
// can fight again, not about regen math (covered by
|
||||
// CharacterVitalsService's own tests).
|
||||
dataSource.state.characters[0].currentHp = 1;
|
||||
dataSource.state.characters[0].hpRegenSince = null;
|
||||
|
||||
const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
expect(retry.id).not.toBe(combatId);
|
||||
@@ -787,7 +879,7 @@ describe('CombatService', () => {
|
||||
|
||||
it('keeps returning LOST after the combat has ended', async () => {
|
||||
const state = createState({
|
||||
characters: [character({ baseHp: 1 })],
|
||||
characters: [character({ baseHp: 1, currentHp: 1 })],
|
||||
});
|
||||
const context = createService({ state });
|
||||
const started = await context.service.startCombat(
|
||||
@@ -844,6 +936,9 @@ describe('CombatService', () => {
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
fakeCharacterStats(),
|
||||
new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
}),
|
||||
rewards,
|
||||
);
|
||||
|
||||
@@ -896,6 +991,9 @@ describe('CombatService', () => {
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
fakeCharacterStats(),
|
||||
new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
}),
|
||||
rewards,
|
||||
);
|
||||
|
||||
@@ -956,6 +1054,9 @@ describe('CombatService', () => {
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
fakeCharacterStats(),
|
||||
new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
}),
|
||||
rewards,
|
||||
);
|
||||
|
||||
@@ -993,6 +1094,9 @@ describe('CombatService', () => {
|
||||
fakeTravelService(),
|
||||
new CombatEngineService(),
|
||||
fakeCharacterStats(),
|
||||
new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
}),
|
||||
fakeRewardService({
|
||||
// Genuinely write renown/silver through the transaction's manager
|
||||
// before failing, so the assertions below prove the rollback
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||
@@ -16,6 +17,7 @@ import { CombatEngineService } from './combat-engine.service';
|
||||
import { CombatEngineState, CombatIntent } from './combat-engine.types';
|
||||
import {
|
||||
characterNotFound,
|
||||
characterTooWounded,
|
||||
characterTravelling,
|
||||
combatAlreadyActive,
|
||||
combatAlreadyFinished,
|
||||
@@ -78,6 +80,7 @@ export class CombatService {
|
||||
private readonly travelService: TravelService,
|
||||
private readonly combatEngine: CombatEngineService,
|
||||
private readonly characterStats: CharacterStatsService,
|
||||
private readonly characterVitals: CharacterVitalsService,
|
||||
private readonly combatRewards: CombatRewardService,
|
||||
) {}
|
||||
|
||||
@@ -138,6 +141,11 @@ export class CombatService {
|
||||
character,
|
||||
manager,
|
||||
);
|
||||
if (playerStats.currentHp < 1) {
|
||||
throw characterTooWounded();
|
||||
}
|
||||
this.characterVitals.pause(character, playerStats.currentHp);
|
||||
await characters.save(character);
|
||||
|
||||
const combat = combats.create({
|
||||
characterId,
|
||||
@@ -146,7 +154,7 @@ export class CombatService {
|
||||
status: CombatStatus.ACTIVE,
|
||||
round: 1,
|
||||
playerMaxHp: playerStats.maxHp,
|
||||
playerCurrentHp: playerStats.maxHp,
|
||||
playerCurrentHp: playerStats.currentHp,
|
||||
monsterMaxHp: monster.maxHp,
|
||||
monsterCurrentHp: monster.maxHp,
|
||||
playerState: {
|
||||
@@ -220,7 +228,7 @@ export class CombatService {
|
||||
// no-op re-lock — but locking it first here keeps both code paths
|
||||
// consistent and avoids a lock-order inversion that could deadlock two
|
||||
// concurrent requests against the same character. Do not reorder this.
|
||||
await this.lockCharacter(characters, characterId);
|
||||
const character = await this.lockCharacter(characters, characterId);
|
||||
|
||||
const combat = await combats.findOne({
|
||||
where: { id: combatId, characterId },
|
||||
@@ -251,12 +259,16 @@ export class CombatService {
|
||||
combat.monsterState = result.state.monster.stats;
|
||||
if (combat.status !== CombatStatus.ACTIVE) {
|
||||
combat.completedAt = new Date();
|
||||
this.characterVitals.resume(character, combat.playerCurrentHp);
|
||||
await this.settleEncounter(
|
||||
manager.getRepository(HuntEncounter),
|
||||
combat.huntEncounterId,
|
||||
combat.status,
|
||||
);
|
||||
} else {
|
||||
this.characterVitals.pause(character, combat.playerCurrentHp);
|
||||
}
|
||||
await characters.save(character);
|
||||
await combats.save(combat);
|
||||
|
||||
const startingSequence = await combatEvents.count({
|
||||
@@ -284,7 +296,7 @@ export class CombatService {
|
||||
? await this.combatRewards.grantVictoryRewards(manager, combat)
|
||||
: null;
|
||||
|
||||
const [character, monster, events] = await Promise.all([
|
||||
const [reloadedCharacter, monster, events] = await Promise.all([
|
||||
this.loadCharacter(
|
||||
combat.characterId,
|
||||
manager.getRepository(Character),
|
||||
@@ -296,7 +308,7 @@ export class CombatService {
|
||||
this.loadEvents(combat.id, combatEvents),
|
||||
]);
|
||||
|
||||
return this.toCombatDto(combat, character.name, monster, events, rewards);
|
||||
return this.toCombatDto(combat, reloadedCharacter.name, monster, events, rewards);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddHpRegeneration1792000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "characters" ADD COLUMN "hp_regen_since" TIMESTAMP WITH TIME ZONE',
|
||||
);
|
||||
|
||||
// Existing characters start regenerating immediately from their current
|
||||
// HP. A character whose fight is still ACTIVE keeps regeneration paused
|
||||
// until that fight resolves, matching the "no combat-time regen" rule
|
||||
// (persistent-hp-and-regeneration design, R4) -- this migration must not
|
||||
// gift them free healing mid-fight.
|
||||
await queryRunner.query('UPDATE "characters" SET "hp_regen_since" = now()');
|
||||
await queryRunner.query(`UPDATE "characters" AS "character"
|
||||
SET "hp_regen_since" = NULL
|
||||
FROM "combats" AS "combat"
|
||||
WHERE "combat"."character_id" = "character"."id"
|
||||
AND "combat"."status" = 'ACTIVE'`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "hp_regen_since"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
|
||||
describe('characters.hp_regen_since schema', () => {
|
||||
it('stores the regeneration anchor as a nullable timestamptz', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
const column = metadata.columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === Character && candidate.propertyName === 'hpRegenSince',
|
||||
);
|
||||
|
||||
expect(column).toBeDefined();
|
||||
expect(column?.options.type).toBe('timestamptz');
|
||||
expect(column?.options.nullable).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -251,6 +251,7 @@ export async function seedVisibleVerticalSlice(
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
hpRegenSince: new Date(),
|
||||
currentLocationId: southGateId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { CombatStatus } from '../combat/combat-status.enum';
|
||||
import { Combat } from '../combat/entities/combat.entity';
|
||||
@@ -191,6 +192,7 @@ function character(overrides: Partial<Character> = {}): Character {
|
||||
baseHp: 100,
|
||||
baseAttack: 6,
|
||||
currentHp: 100,
|
||||
hpRegenSince: null,
|
||||
...overrides,
|
||||
} as Character;
|
||||
}
|
||||
@@ -205,12 +207,17 @@ function createHarness(state: Partial<State> = {}) {
|
||||
...state,
|
||||
};
|
||||
const dataSource = new FakeDataSource(fullState);
|
||||
const characterVitals = new CharacterVitalsService({
|
||||
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||
});
|
||||
const characterStats = new CharacterStatsService(
|
||||
dataSource as unknown as DataSource,
|
||||
characterVitals,
|
||||
);
|
||||
const service = new EquipmentService(
|
||||
dataSource as unknown as DataSource,
|
||||
characterStats,
|
||||
characterVitals,
|
||||
);
|
||||
return { state: fullState, service };
|
||||
}
|
||||
@@ -452,6 +459,38 @@ describe('EquipmentService', () => {
|
||||
'CHARACTER_IN_COMBAT',
|
||||
);
|
||||
});
|
||||
|
||||
it('re-anchors HP regeneration so a later max-HP increase does not gift accumulated overflow', async () => {
|
||||
const bonusHpHelm = itemDefinition({
|
||||
id: 'def-bonus-hp-helm',
|
||||
key: 'bonus-hp-helm',
|
||||
name: 'Gepolsterter Helm',
|
||||
equipmentSlot: EquipmentSlot.HEAD,
|
||||
bonusHp: 20,
|
||||
weaponDamage: 0,
|
||||
});
|
||||
const { state, service } = createHarness({
|
||||
characters: [
|
||||
character({
|
||||
currentHp: 90,
|
||||
hpRegenSince: new Date('2026-08-18T08:50:00.000Z'),
|
||||
}),
|
||||
],
|
||||
itemDefinitions: [bonusHpHelm],
|
||||
characterItems: [
|
||||
{
|
||||
id: BANDIT_HOOD_ITEM_ID,
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: bonusHpHelm.id,
|
||||
quantity: 1,
|
||||
} as CharacterItem,
|
||||
],
|
||||
});
|
||||
|
||||
await service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID);
|
||||
|
||||
expect(state.characters[0].currentHp).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEquipment', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { CombatStatus } from '../combat/combat-status.enum';
|
||||
import { Combat } from '../combat/entities/combat.entity';
|
||||
@@ -50,6 +51,7 @@ export class EquipmentService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly characterStats: CharacterStatsService,
|
||||
private readonly characterVitals: CharacterVitalsService,
|
||||
) {}
|
||||
|
||||
async getEquipment(characterId: string): Promise<EquipmentResponseDto> {
|
||||
@@ -108,6 +110,10 @@ export class EquipmentService {
|
||||
throw itemNotEquippable();
|
||||
}
|
||||
|
||||
const statsBeforeChange = await this.characterStats.calculate(character, manager);
|
||||
this.characterVitals.settle(character, statsBeforeChange.maxHp);
|
||||
await characters.save(character);
|
||||
|
||||
const existing = await equipmentRepo.findOne({
|
||||
where: { characterId, slot: definition.equipmentSlot },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
|
||||
@@ -19,6 +19,7 @@ function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
|
||||
itemDefinition: {
|
||||
key: 'worn-short-sword',
|
||||
name: 'Abgenutztes Kurzschwert',
|
||||
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
|
||||
rarity: ItemRarity.COMMON,
|
||||
type: ItemType.EQUIPMENT,
|
||||
equipmentSlot: EquipmentSlot.WEAPON,
|
||||
@@ -66,6 +67,7 @@ describe('InventoryService', () => {
|
||||
item: {
|
||||
key: 'worn-short-sword',
|
||||
name: 'Abgenutztes Kurzschwert',
|
||||
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
|
||||
rarity: 'COMMON',
|
||||
equipmentSlot: 'WEAPON',
|
||||
weaponDamage: 8,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface InventoryItemDto {
|
||||
item: {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
rarity: ItemRarity;
|
||||
equipmentSlot: EquipmentSlot | null;
|
||||
weaponDamage: number;
|
||||
@@ -55,6 +56,7 @@ export class InventoryService {
|
||||
item: {
|
||||
key: characterItem.itemDefinition.key,
|
||||
name: characterItem.itemDefinition.name,
|
||||
description: characterItem.itemDefinition.description,
|
||||
rarity: characterItem.itemDefinition.rarity,
|
||||
equipmentSlot: characterItem.itemDefinition.equipmentSlot,
|
||||
weaponDamage: characterItem.itemDefinition.weaponDamage,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { LocationConnection } from '../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
||||
import { CLOCK, systemClock } from './clock';
|
||||
import { CLOCK, systemClock } from '../shared/clock';
|
||||
import { Travel } from './entities/travel.entity';
|
||||
import { TravelController } from './travel.controller';
|
||||
import { TravelService } from './travel.service';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '../database/seeds/vertical-slice.constants';
|
||||
import { LocationConnection } from '../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
||||
import { Clock } from './clock';
|
||||
import { Clock } from '../shared/clock';
|
||||
import { Travel } from './entities/travel.entity';
|
||||
import { TravelDomainError } from './travel.errors';
|
||||
import { TravelService } from './travel.service';
|
||||
|
||||
@@ -3,8 +3,8 @@ import { DataSource, Repository } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { LocationConnection } from '../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
||||
import { CLOCK } from './clock';
|
||||
import type { Clock } from './clock';
|
||||
import { CLOCK } from '../shared/clock';
|
||||
import type { Clock } from '../shared/clock';
|
||||
import { Travel } from './entities/travel.entity';
|
||||
import {
|
||||
characterNotFound,
|
||||
|
||||
BIN
apps/web/public/assets/hud-elements/panel-frame.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
apps/web/public/assets/hud-elements/panel-ornament.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web/public/images/character/female-320.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
apps/web/public/images/character/female-portrait-256.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
apps/web/public/images/character/male-320.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/web/public/images/character/male-portrait-256.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
@@ -8,11 +8,13 @@ import { routes } from './app.routes';
|
||||
|
||||
describe('App', () => {
|
||||
let character: WritableSignal<CharacterResponse | null>;
|
||||
let displayedCharacter: WritableSignal<CharacterResponse | null>;
|
||||
let currentLocation: WritableSignal<null>;
|
||||
let selectedConnection: WritableSignal<null>;
|
||||
|
||||
beforeEach(async () => {
|
||||
character = signal<CharacterResponse | null>(null);
|
||||
displayedCharacter = signal<CharacterResponse | null>(null);
|
||||
currentLocation = signal(null);
|
||||
selectedConnection = signal(null);
|
||||
|
||||
@@ -23,10 +25,11 @@ describe('App', () => {
|
||||
{ path: 'location', children: [] },
|
||||
{ path: 'world', children: [] },
|
||||
{ path: 'hunt', children: [] },
|
||||
{ path: 'inventory', children: [] },
|
||||
]),
|
||||
{
|
||||
provide: WorldStore,
|
||||
useValue: { character, currentLocation, selectedConnection },
|
||||
useValue: { character, displayedCharacter, currentLocation, selectedConnection },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
@@ -112,6 +115,16 @@ describe('App', () => {
|
||||
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 () => {
|
||||
const fixture = TestBed.createComponent(AppShellComponent);
|
||||
const router = TestBed.inject(Router);
|
||||
@@ -138,7 +151,19 @@ describe('App', () => {
|
||||
});
|
||||
|
||||
it('renders loaded character values supplied by the WorldStore', () => {
|
||||
character.set({
|
||||
const decoy: CharacterResponse = {
|
||||
id: 'stale-id',
|
||||
name: 'Stale Decoy',
|
||||
renown: 1,
|
||||
silver: 0,
|
||||
currentHp: 1,
|
||||
maxHp: 1,
|
||||
attack: 1,
|
||||
hpRegenPerSecond: 1,
|
||||
hpRegenSince: null,
|
||||
currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' },
|
||||
};
|
||||
const value: CharacterResponse = {
|
||||
id: 'character-id',
|
||||
name: 'Mara Ashfall',
|
||||
renown: 7,
|
||||
@@ -146,8 +171,12 @@ describe('App', () => {
|
||||
currentHp: 52,
|
||||
maxHp: 80,
|
||||
attack: 12,
|
||||
hpRegenPerSecond: 1,
|
||||
hpRegenSince: null,
|
||||
currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' },
|
||||
});
|
||||
};
|
||||
character.set(decoy);
|
||||
displayedCharacter.set(value);
|
||||
const fixture = TestBed.createComponent(AppShellComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface CharacterResponse {
|
||||
currentHp: number;
|
||||
maxHp: number;
|
||||
attack: number;
|
||||
hpRegenPerSecond: number;
|
||||
hpRegenSince: string | null;
|
||||
currentLocation: LocationSummary;
|
||||
}
|
||||
|
||||
@@ -226,6 +228,7 @@ export interface InventoryItem {
|
||||
item: {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
rarity: ItemRarity;
|
||||
equipmentSlot: EquipmentSlot | null;
|
||||
weaponDamage: number;
|
||||
|
||||
@@ -84,6 +84,22 @@ describe('CombatStore', () => {
|
||||
expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE');
|
||||
});
|
||||
|
||||
it('maps CHARACTER_TOO_WOUNDED to its German message', async () => {
|
||||
api.startCombat.mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 409,
|
||||
error: { statusCode: 409, code: 'CHARACTER_TOO_WOUNDED', message: 'Too wounded.' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await store.startCombat('encounter-1');
|
||||
|
||||
expect(store.error()).toBe('Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast.');
|
||||
});
|
||||
|
||||
it('loads the running combat and clears the error that sent us looking for it', async () => {
|
||||
api.startCombat.mockReturnValue(
|
||||
throwError(
|
||||
|
||||
@@ -13,6 +13,7 @@ const COMBAT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
||||
HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.',
|
||||
INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.',
|
||||
CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.',
|
||||
CHARACTER_TOO_WOUNDED: 'Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast.',
|
||||
COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.',
|
||||
COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.',
|
||||
COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',
|
||||
|
||||
@@ -1,56 +1,68 @@
|
||||
@if (item(); as item) {
|
||||
<article class="inventory-detail" aria-label="Gegenstandsdetails">
|
||||
<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 }}</p>
|
||||
<section class="ar-panel detail" aria-label="Gewählter Gegenstand">
|
||||
<h2 class="ar-panel__title">Gewählter Gegenstand</h2>
|
||||
|
||||
@if (item(); as item) {
|
||||
<article
|
||||
class="detail__body"
|
||||
[class.detail__body--rare]="item.item.rarity === 'RARE'"
|
||||
[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 (item.item.description) {
|
||||
<p class="detail__flavour">{{ item.item.description }}</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 {
|
||||
<button
|
||||
type="button"
|
||||
class="detail__equip"
|
||||
data-detail-equip
|
||||
[disabled]="busy()"
|
||||
(click)="onEquip()"
|
||||
>
|
||||
Ausrüsten
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (statRows().length) {
|
||||
<dl class="inventory-detail__stats" data-detail-stats>
|
||||
@for (row of statRows(); track row.label) {
|
||||
<div class="inventory-detail__stat">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>
|
||||
{{ row.value }}
|
||||
@if (row.diff !== null && row.diff !== 0) {
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
</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 {
|
||||
<button
|
||||
type="button"
|
||||
class="inventory-detail__equip"
|
||||
data-detail-equip
|
||||
[disabled]="busy()"
|
||||
(click)="onEquip()"
|
||||
>
|
||||
Ausrüsten
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
} @else {
|
||||
<p class="inventory-detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
|
||||
}
|
||||
</article>
|
||||
} @else {
|
||||
<p class="detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -2,99 +2,174 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.inventory-detail {
|
||||
padding: var(--ar-space-4);
|
||||
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);
|
||||
.detail {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: var(--ar-space-3);
|
||||
align-items: center;
|
||||
margin-block-end: var(--ar-space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.inventory-detail__icon {
|
||||
inline-size: 4rem;
|
||||
block-size: 4rem;
|
||||
padding: var(--ar-space-1);
|
||||
border: 1px solid var(--ar-border);
|
||||
.detail__portrait {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
inline-size: 4.25rem;
|
||||
block-size: 4.25rem;
|
||||
padding: 0.3rem;
|
||||
border: 1px solid var(--detail-rarity-edge);
|
||||
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;
|
||||
}
|
||||
|
||||
.inventory-detail__name {
|
||||
margin: 0;
|
||||
color: var(--ar-text);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 400;
|
||||
.detail__ident {
|
||||
min-inline-size: 0;
|
||||
padding-block-start: 0.15rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: var(--ar-text-muted);
|
||||
font-size: var(--ar-font-sm);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.inventory-detail__meta {
|
||||
margin: 0.25rem 0 0;
|
||||
/* ---------- numbers ---------- */
|
||||
|
||||
.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);
|
||||
font-size: var(--ar-font-sm);
|
||||
}
|
||||
|
||||
.inventory-detail__stats {
|
||||
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 {
|
||||
.detail__stat dd {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.inventory-detail__stat dt {
|
||||
color: var(--ar-text-muted);
|
||||
}
|
||||
|
||||
.inventory-detail__stat dd {
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inventory-detail__diff--positive {
|
||||
.detail__value {
|
||||
color: var(--ar-text);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
|
||||
/* The sign is carried by the text, never by colour alone (spec §52). */
|
||||
.detail__diff {
|
||||
font-size: var(--ar-font-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.detail__diff--positive {
|
||||
color: var(--ar-success);
|
||||
}
|
||||
|
||||
.inventory-detail__diff--negative {
|
||||
.detail__diff--negative {
|
||||
color: var(--ar-danger);
|
||||
}
|
||||
|
||||
.inventory-detail__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* ---------- 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;
|
||||
}
|
||||
|
||||
.inventory-detail__equipped {
|
||||
/* ---------- 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);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.inventory-detail__note {
|
||||
.detail__note {
|
||||
color: var(--ar-text-muted);
|
||||
font-size: var(--ar-font-sm);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.inventory-detail__equip {
|
||||
/* Same steel-blue plate as the travel button: the one committing action. */
|
||||
.detail__equip {
|
||||
inline-size: 100%;
|
||||
padding: var(--ar-space-2) var(--ar-space-4);
|
||||
border: 1px solid var(--ar-border-highlight);
|
||||
@@ -103,22 +178,25 @@
|
||||
background: linear-gradient(180deg, #263b4b, #17232d);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.inventory-detail__equip:hover:not(:disabled) {
|
||||
.detail__equip:hover:not(:disabled) {
|
||||
border-color: #d6b26b;
|
||||
background: linear-gradient(180deg, #315067, #1a2c3a);
|
||||
}
|
||||
|
||||
.inventory-detail__equip:disabled {
|
||||
.detail__equip:disabled {
|
||||
border-color: var(--ar-border);
|
||||
color: var(--ar-text-muted);
|
||||
background: #1a1c1d;
|
||||
}
|
||||
|
||||
.inventory-detail__empty {
|
||||
padding: var(--ar-space-4);
|
||||
.detail__empty {
|
||||
margin: 0;
|
||||
padding-block: var(--ar-space-5);
|
||||
color: var(--ar-text-muted);
|
||||
font-size: var(--ar-font-sm);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const wornSword: InventoryItem = {
|
||||
item: {
|
||||
key: 'worn-short-sword',
|
||||
name: 'Abgenutztes Kurzschwert',
|
||||
description: 'Beschreibung des Gegenstands.',
|
||||
rarity: 'COMMON',
|
||||
equipmentSlot: 'WEAPON',
|
||||
weaponDamage: 8,
|
||||
@@ -27,6 +28,7 @@ const banditBlade: InventoryItem = {
|
||||
item: {
|
||||
key: 'bandit-blade',
|
||||
name: 'Räuberklinge',
|
||||
description: 'Beschreibung des Gegenstands.',
|
||||
rarity: 'COMMON',
|
||||
equipmentSlot: 'WEAPON',
|
||||
weaponDamage: 11,
|
||||
@@ -44,6 +46,7 @@ const ashPelt: InventoryItem = {
|
||||
item: {
|
||||
key: 'ash-pelt',
|
||||
name: 'Aschenfell',
|
||||
description: 'Beschreibung des Gegenstands.',
|
||||
rarity: 'COMMON',
|
||||
equipmentSlot: null,
|
||||
weaponDamage: 0,
|
||||
@@ -92,6 +95,19 @@ describe('InventoryDetailPanelComponent', () => {
|
||||
expect(element.querySelector('[data-detail-equipped]')).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 () => {
|
||||
const fixture = await setup({ item: banditBlade, equippedItemInSlot: wornSword });
|
||||
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-stats]')?.textContent ?? '';
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
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';
|
||||
|
||||
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
|
||||
WEAPON: 'Waffe',
|
||||
HEAD: 'Kopf',
|
||||
CHEST: 'Brust',
|
||||
HANDS: 'Handschuhe',
|
||||
LEGS: 'Beine',
|
||||
FEET: 'Stiefel',
|
||||
AMULET: 'Amulett',
|
||||
};
|
||||
import { SLOT_LABELS } from './inventory.labels';
|
||||
|
||||
interface StatRow {
|
||||
label: string;
|
||||
@@ -48,6 +39,9 @@ export class InventoryDetailPanelComponent {
|
||||
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[]>(() => {
|
||||
const item = this.item();
|
||||
if (!item) {
|
||||
|
||||
@@ -1,58 +1,122 @@
|
||||
<!-- apps/web/src/app/features/inventory/inventory-page.component.html -->
|
||||
<section class="inventory-page" aria-label="Inventar">
|
||||
<h1 class="inventory-page__title">Inventar</h1>
|
||||
|
||||
@if (inventoryStore.loading() && !inventoryStore.inventory()) {
|
||||
<p class="inventory-page__notice" role="status">Inventar wird geladen…</p>
|
||||
} @else if (inventoryStore.inventory(); as inventory) {
|
||||
<section class="inventory-page__grid" aria-label="Gegenstände">
|
||||
@for (entry of inventory.items; track entry.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="inventory-page__slot"
|
||||
[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>
|
||||
<div class="inventory-page__columns">
|
||||
<!-- ---------- paper doll + derived stats ---------- -->
|
||||
<div class="inventory-page__rail">
|
||||
<section class="ar-panel panel--doll" aria-label="Ausrüstung">
|
||||
<h2 class="ar-panel__title">Ausrüstung</h2>
|
||||
|
||||
<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
|
||||
class="inventory-page__detail"
|
||||
[item]="inventoryStore.selectedItem()"
|
||||
[equippedItemInSlot]="equippedItemInSelectedSlot()"
|
||||
[busy]="inventoryStore.equipping()"
|
||||
(equip)="equipSelected($event)"
|
||||
/>
|
||||
|
||||
@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>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (inventoryStore.error(); as error) {
|
||||
@@ -62,3 +126,29 @@
|
||||
</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,310 @@
|
||||
// apps/web/src/app/features/inventory/inventory-page.component.scss
|
||||
:host {
|
||||
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 {
|
||||
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;
|
||||
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/character/female-320.png');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
filter: drop-shadow(0 0.4rem 0.9rem rgb(0 0 0 / 0.75));
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding-block: var(--ar-space-1);
|
||||
border-block-end: 1px solid rgb(85 74 57 / 0.4);
|
||||
gap: var(--ar-space-3);
|
||||
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);
|
||||
}
|
||||
|
||||
.inventory-page__equipment-slot-label {
|
||||
color: var(--ar-text-muted);
|
||||
.values__glyph {
|
||||
color: var(--ar-border-highlight);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.inventory-page__stats {
|
||||
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 {
|
||||
.values dd {
|
||||
margin: 0;
|
||||
color: var(--ar-text);
|
||||
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 {
|
||||
padding: var(--ar-space-4);
|
||||
color: var(--ar-text-muted);
|
||||
@@ -123,11 +312,86 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
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 {
|
||||
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: {
|
||||
key: 'worn-short-sword',
|
||||
name: 'Abgenutztes Kurzschwert',
|
||||
description: 'Beschreibung des Gegenstands.',
|
||||
rarity: 'COMMON',
|
||||
equipmentSlot: 'WEAPON',
|
||||
weaponDamage: 8,
|
||||
@@ -33,6 +34,7 @@ const inventory: InventoryResponse = {
|
||||
item: {
|
||||
key: 'bandit-blade',
|
||||
name: 'Räuberklinge',
|
||||
description: 'Beschreibung des Gegenstands.',
|
||||
rarity: 'COMMON',
|
||||
equipmentSlot: 'WEAPON',
|
||||
weaponDamage: 11,
|
||||
@@ -66,6 +68,8 @@ const character: CharacterResponse = {
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
attack: 6,
|
||||
hpRegenPerSecond: 1,
|
||||
hpRegenSince: null,
|
||||
currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' },
|
||||
};
|
||||
|
||||
@@ -173,6 +177,7 @@ describe('InventoryPageComponent', () => {
|
||||
item: {
|
||||
key: 'iron-helm',
|
||||
name: 'Eiserner Helm',
|
||||
description: 'Beschreibung des Gegenstands.',
|
||||
rarity: 'COMMON',
|
||||
equipmentSlot: 'HEAD',
|
||||
weaponDamage: 0,
|
||||
|
||||
@@ -1,42 +1,59 @@
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { Component, OnInit, computed, inject } from '@angular/core';
|
||||
import type { EquipmentSlot } from '../../core/api/game-api.models';
|
||||
import { ItemCardComponent } from '../../shared/item-card/item-card.component';
|
||||
import type { EquipmentSlot, InventoryItem } from '../../core/api/game-api.models';
|
||||
import { WorldStore } from '../world/world.store';
|
||||
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
|
||||
import { SLOT_LABELS } from './inventory.labels';
|
||||
import { InventoryStore } from './inventory.store';
|
||||
|
||||
const SLOT_ORDER: readonly EquipmentSlot[] = [
|
||||
'WEAPON',
|
||||
'HEAD',
|
||||
'CHEST',
|
||||
'HANDS',
|
||||
'LEGS',
|
||||
'FEET',
|
||||
'AMULET',
|
||||
];
|
||||
/**
|
||||
* The paper doll reads as a body: weapon hand and worn trinkets down the left,
|
||||
* armour down the right, legs beneath the figure. Only the seven slots the
|
||||
* game actually models appear — no decorative rings or offhand (spec §38).
|
||||
*/
|
||||
const DOLL_LEFT: readonly EquipmentSlot[] = ['WEAPON', 'AMULET', 'HANDS'];
|
||||
const DOLL_RIGHT: readonly EquipmentSlot[] = ['HEAD', 'CHEST', 'FEET'];
|
||||
const DOLL_BELOW: EquipmentSlot = 'LEGS';
|
||||
|
||||
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
|
||||
WEAPON: 'Waffe',
|
||||
HEAD: 'Kopf',
|
||||
CHEST: 'Brust',
|
||||
HANDS: 'Handschuhe',
|
||||
LEGS: 'Beine',
|
||||
FEET: 'Stiefel',
|
||||
AMULET: 'Amulett',
|
||||
};
|
||||
// Every slot, in the order the equipment list announces them.
|
||||
const SLOT_ORDER: readonly EquipmentSlot[] = [...DOLL_LEFT, ...DOLL_RIGHT, DOLL_BELOW];
|
||||
|
||||
// The bag is drawn as a fixed grid so it reads as a container with room left,
|
||||
// not as a list that happens to be short. Capacity is not enforced yet
|
||||
// (spec §31): the empty cells are structure, not a limit.
|
||||
const BAG_COLUMNS = 8;
|
||||
const BAG_MIN_CELLS = 64;
|
||||
|
||||
@Component({
|
||||
selector: 'app-inventory-page',
|
||||
imports: [ItemCardComponent, InventoryDetailPanelComponent],
|
||||
imports: [NgTemplateOutlet, InventoryDetailPanelComponent],
|
||||
templateUrl: './inventory-page.component.html',
|
||||
styleUrl: './inventory-page.component.scss',
|
||||
})
|
||||
export class InventoryPageComponent implements OnInit {
|
||||
protected readonly inventoryStore = inject(InventoryStore);
|
||||
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 slotLabels = SLOT_LABELS;
|
||||
|
||||
/**
|
||||
* 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(() => {
|
||||
const selected = this.inventoryStore.selectedItem();
|
||||
if (!selected?.item.equipmentSlot) {
|
||||
@@ -60,11 +77,11 @@ export class InventoryPageComponent implements OnInit {
|
||||
this.inventoryStore.selectItem(itemId);
|
||||
}
|
||||
|
||||
protected async equipSelected(characterItemId: string): Promise<void> {
|
||||
await this.inventoryStore.equip(characterItemId);
|
||||
}
|
||||
|
||||
protected retry(): void {
|
||||
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
@@ -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: {
|
||||
key: 'worn-short-sword',
|
||||
name: 'Abgenutztes Kurzschwert',
|
||||
description: 'Beschreibung des Gegenstands.',
|
||||
rarity: 'COMMON',
|
||||
equipmentSlot: 'WEAPON',
|
||||
weaponDamage: 8,
|
||||
@@ -32,6 +33,7 @@ const inventory: InventoryResponse = {
|
||||
item: {
|
||||
key: 'bandit-blade',
|
||||
name: 'Räuberklinge',
|
||||
description: 'Beschreibung des Gegenstands.',
|
||||
rarity: 'COMMON',
|
||||
equipmentSlot: 'WEAPON',
|
||||
weaponDamage: 11,
|
||||
|
||||
@@ -19,6 +19,8 @@ const character: CharacterResponse = {
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
attack: 6,
|
||||
hpRegenPerSecond: 1,
|
||||
hpRegenSince: null,
|
||||
currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' },
|
||||
};
|
||||
|
||||
@@ -371,4 +373,71 @@ describe('WorldStore', () => {
|
||||
expect(store.character()?.silver).toBe(0);
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
|
||||
describe('HP regeneration display', () => {
|
||||
it('counts displayedCharacter up once per second while an anchor is set', async () => {
|
||||
const wounded: CharacterResponse = {
|
||||
...character,
|
||||
currentHp: 40,
|
||||
maxHp: 100,
|
||||
hpRegenSince: '2026-08-18T10:00:00.000Z',
|
||||
};
|
||||
api.getCharacter.mockReturnValue(of(wounded));
|
||||
|
||||
await store.load();
|
||||
expect(store.displayedCharacter()?.currentHp).toBe(40);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
|
||||
expect(store.displayedCharacter()?.currentHp).toBe(43);
|
||||
});
|
||||
|
||||
it('stops at maxHp instead of counting past it', async () => {
|
||||
const almostHealed: CharacterResponse = {
|
||||
...character,
|
||||
currentHp: 99,
|
||||
maxHp: 100,
|
||||
hpRegenSince: '2026-08-18T10:00:00.000Z',
|
||||
};
|
||||
api.getCharacter.mockReturnValue(of(almostHealed));
|
||||
|
||||
await store.load();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(store.displayedCharacter()?.currentHp).toBe(100);
|
||||
});
|
||||
|
||||
it('does not tick while regeneration is paused', async () => {
|
||||
const paused: CharacterResponse = {
|
||||
...character,
|
||||
currentHp: 40,
|
||||
maxHp: 100,
|
||||
hpRegenSince: null,
|
||||
};
|
||||
api.getCharacter.mockReturnValue(of(paused));
|
||||
|
||||
await store.load();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(store.displayedCharacter()?.currentHp).toBe(40);
|
||||
});
|
||||
|
||||
it('resyncs the ticker to a freshly loaded anchor on refreshCharacter', async () => {
|
||||
api.getCharacter.mockReturnValue(of(character));
|
||||
await store.load();
|
||||
|
||||
const stillWounded: CharacterResponse = {
|
||||
...character,
|
||||
currentHp: 10,
|
||||
maxHp: 100,
|
||||
hpRegenSince: '2026-08-18T10:00:00.000Z',
|
||||
};
|
||||
api.getCharacter.mockReturnValue(of(stillWounded));
|
||||
await store.refreshCharacter();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
|
||||
expect(store.displayedCharacter()?.currentHp).toBe(14);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ const TRAVEL_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class WorldStore implements OnDestroy {
|
||||
private readonly characterState = signal<CharacterResponse | null>(null);
|
||||
private readonly displayedCharacterState = signal<CharacterResponse | null>(null);
|
||||
private readonly currentLocationState = signal<CurrentLocationResponse | null>(null);
|
||||
private readonly selectedConnectionState = signal<CurrentLocationConnection | null>(null);
|
||||
private readonly currentTravelState = signal<CurrentTravel | null>(null);
|
||||
@@ -32,11 +33,13 @@ export class WorldStore implements OnDestroy {
|
||||
private readonly loadingState = signal(false);
|
||||
private readonly errorState = signal<string | null>(null);
|
||||
private countdownTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private regenTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private travelRetryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private travelPollInFlight = false;
|
||||
private destroyed = false;
|
||||
|
||||
readonly character = this.characterState.asReadonly();
|
||||
readonly displayedCharacter = this.displayedCharacterState.asReadonly();
|
||||
readonly currentLocation = this.currentLocationState.asReadonly();
|
||||
readonly selectedConnection = this.selectedConnectionState.asReadonly();
|
||||
readonly currentTravel = this.currentTravelState.asReadonly();
|
||||
@@ -62,7 +65,7 @@ export class WorldStore implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.characterState.set(character);
|
||||
this.applyCharacter(character);
|
||||
this.currentLocationState.set(location);
|
||||
this.selectedConnectionState.set(null);
|
||||
await this.setCurrentTravel(travel);
|
||||
@@ -102,7 +105,7 @@ export class WorldStore implements OnDestroy {
|
||||
try {
|
||||
const character = await firstValueFrom(this.api.getCharacter());
|
||||
if (!this.destroyed) {
|
||||
this.characterState.set(character);
|
||||
this.applyCharacter(character);
|
||||
}
|
||||
} catch {
|
||||
// Keep the previous character; the next load() will resync.
|
||||
@@ -144,6 +147,7 @@ export class WorldStore implements OnDestroy {
|
||||
ngOnDestroy(): void {
|
||||
this.destroyed = true;
|
||||
this.stopCountdown();
|
||||
this.stopRegenTicker();
|
||||
this.clearTravelRetry();
|
||||
}
|
||||
|
||||
@@ -259,11 +263,49 @@ export class WorldStore implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.characterState.set(character);
|
||||
this.applyCharacter(character);
|
||||
this.currentLocationState.set(location);
|
||||
this.selectedConnectionState.set(null);
|
||||
}
|
||||
|
||||
private applyCharacter(character: CharacterResponse): void {
|
||||
this.characterState.set(character);
|
||||
this.stopRegenTicker();
|
||||
this.refreshDisplayedCharacter(character);
|
||||
if (character.hpRegenSince !== null) {
|
||||
this.regenTimer = setInterval(() => this.refreshDisplayedCharacter(character), 1_000);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshDisplayedCharacter(character: CharacterResponse): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentHp = this.computeDisplayedHp(character);
|
||||
this.displayedCharacterState.set({ ...character, currentHp });
|
||||
if (currentHp >= character.maxHp) {
|
||||
this.stopRegenTicker();
|
||||
}
|
||||
}
|
||||
|
||||
private computeDisplayedHp(character: CharacterResponse): number {
|
||||
if (character.hpRegenSince === null) {
|
||||
return character.currentHp;
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(0, (Date.now() - Date.parse(character.hpRegenSince)) / 1000);
|
||||
const regenerated = Math.floor(elapsedSeconds * character.hpRegenPerSecond);
|
||||
return Math.min(character.maxHp, character.currentHp + regenerated);
|
||||
}
|
||||
|
||||
private stopRegenTicker(): void {
|
||||
if (this.regenTimer !== undefined) {
|
||||
clearInterval(this.regenTimer);
|
||||
this.regenTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private stopCountdown(): void {
|
||||
if (this.countdownTimer !== undefined) {
|
||||
clearInterval(this.countdownTimer);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="app-shell">
|
||||
<app-top-bar [character]="worldStore.character()" />
|
||||
<app-top-bar [character]="worldStore.displayedCharacter()" />
|
||||
|
||||
<div class="app-shell__content" [class.app-shell__content--no-context]="!showContextPanel()">
|
||||
<app-side-navigation />
|
||||
|
||||
@@ -32,5 +32,10 @@ export class AppShellComponent {
|
||||
// squeeze the artwork the screen is built around.
|
||||
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 {
|
||||
display: block;
|
||||
background: var(--ar-panel);
|
||||
background: var(--ar-bg) url('/assets/hud-elements/sidepanel-left.png') center / 100% 100%
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
.side-navigation {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
padding-block: var(--ar-space-3);
|
||||
/* A lead-in, then one track per painted plate. */
|
||||
grid-template-rows: 5.9% repeat(6, 8%);
|
||||
row-gap: 1.3%;
|
||||
block-size: 100%;
|
||||
/* Percentage padding resolves against width — which is what the side insets
|
||||
want anyway. */
|
||||
padding-inline: 10.7% 15.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 {
|
||||
display: flex;
|
||||
grid-row: span 1;
|
||||
gap: var(--ar-space-2);
|
||||
align-items: center;
|
||||
gap: var(--ar-space-3);
|
||||
inline-size: 100%;
|
||||
min-block-size: 4.3rem;
|
||||
padding: var(--ar-space-3) var(--ar-space-4);
|
||||
justify-content: center;
|
||||
min-inline-size: 0;
|
||||
padding: 0 var(--ar-space-2);
|
||||
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);
|
||||
/* The plate underneath is the button's frame; nothing is drawn on top. */
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--ar-motion-fast),
|
||||
background var(--ar-motion-fast),
|
||||
border-color var(--ar-motion-fast);
|
||||
text-shadow var(--ar-motion-fast),
|
||||
filter var(--ar-motion-fast);
|
||||
}
|
||||
|
||||
.side-navigation__item img {
|
||||
inline-size: 2.25rem;
|
||||
block-size: 2.25rem;
|
||||
flex: none;
|
||||
inline-size: 1.9rem;
|
||||
block-size: 1.9rem;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -42,8 +61,8 @@
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
inline-size: 2.25rem;
|
||||
block-size: 2.25rem;
|
||||
inline-size: 1.9rem;
|
||||
block-size: 1.9rem;
|
||||
border: 1px solid var(--ar-border-highlight);
|
||||
border-radius: 50%;
|
||||
color: var(--ar-gold);
|
||||
@@ -51,37 +70,58 @@
|
||||
}
|
||||
|
||||
.side-navigation__glyph app-location-icon {
|
||||
inline-size: 1.25rem;
|
||||
block-size: 1.25rem;
|
||||
inline-size: 1.1rem;
|
||||
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 {
|
||||
border-inline-start-color: var(--ar-blue);
|
||||
color: var(--ar-text);
|
||||
background: linear-gradient(90deg, rgb(92 169 216 / 0.2), transparent);
|
||||
box-shadow: inset 0 0 1.25rem rgb(92 169 216 / 0.08);
|
||||
color: var(--ar-gold);
|
||||
filter: drop-shadow(0 0 0.5rem rgb(201 164 95 / 0.45));
|
||||
text-shadow: 0 0 0.6rem rgb(201 164 95 / 0.5);
|
||||
}
|
||||
|
||||
.side-navigation__item:not(:disabled):hover,
|
||||
.side-navigation__item:not(:disabled):focus-visible {
|
||||
color: var(--ar-text);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
}
|
||||
|
||||
.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) {
|
||||
:host {
|
||||
background: var(--ar-panel);
|
||||
}
|
||||
|
||||
.side-navigation {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
padding: 0;
|
||||
grid-template-rows: none;
|
||||
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 {
|
||||
justify-content: center;
|
||||
min-block-size: 3.5rem;
|
||||
padding: var(--ar-space-2);
|
||||
border-block-end: 1px solid color-mix(in srgb, var(--ar-border) 65%, transparent);
|
||||
}
|
||||
|
||||
.side-navigation__item span {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<header class="top-bar">
|
||||
<div class="top-bar__character">
|
||||
<img class="top-bar__portrait" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
|
||||
<img class="top-bar__portrait" src="/images/character/female-portrait-256.png" alt="" />
|
||||
@if (character(); as character) {
|
||||
<div class="top-bar__identity">
|
||||
<span class="top-bar__name">{{ character.name }}</span>
|
||||
|
||||
@@ -11,6 +11,8 @@ function characterFixture(overrides: Partial<CharacterResponse> = {}): Character
|
||||
currentHp: 80,
|
||||
maxHp: 100,
|
||||
attack: 10,
|
||||
hpRegenPerSecond: 1,
|
||||
hpRegenSince: null,
|
||||
currentLocation: { id: 'location-1', key: 'aschenfelder', name: 'Aschenfelder' },
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -83,6 +83,85 @@ img {
|
||||
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) {
|
||||
*,
|
||||
*::before,
|
||||
|
||||
BIN
art/sprites/player/female/female-portrait.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
art/sprites/player/female/female.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
art/sprites/player/male/male-portrait.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
art/sprites/player/male/male.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
1958
docs/superpowers/plans/2026-08-21-persistent-hp-and-regeneration.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Persistent Character HP & Out-of-Combat Regeneration: Design
|
||||
|
||||
**Status:** Approved section by section in brainstorming on 2026-08-21. Every open question the request left is recorded below as a ruling with its cost if wrong.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The player starts every combat at full HP. Finishing a fight on 10 HP and immediately entering the next one hands the player a free heal, so damage taken has no cost beyond the current round and there is no reason to ever retreat.
|
||||
|
||||
Requested behaviour:
|
||||
|
||||
1. HP surviving a combat carries over to the character.
|
||||
2. The character regenerates 1 HP per second outside combat.
|
||||
3. The HP the character walks in with is the HP the next combat starts with.
|
||||
4. No automatic regeneration while a combat is running.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
Architectural. It introduces a resource mechanic shared by the characters, combat, and equipment modules, adds a column and a service, changes the meaning of an existing column, and changes two API payloads plus one frontend store.
|
||||
|
||||
## 3. Current-state findings that shape this design
|
||||
|
||||
(From a codebase survey on `master` at `dd90e05`.)
|
||||
|
||||
- `characters.current_hp` **already exists** (`character.entity.ts:35-36`), is already surfaced by `GET /api/characters/me` (`characters.service.ts:34`), and is already rendered by the HUD health bar (`top-bar.component.html`). Nothing writes it after seeding.
|
||||
- `CombatService.startCombat` seeds the fight with `playerCurrentHp: playerStats.maxHp` (`combat.service.ts:146`). This one line is the actual bug.
|
||||
- Combat never writes back: `performAction` mutates `combat.playerCurrentHp` (`combat.service.ts:242`) and saves the combat row, but the character row is only locked, never updated.
|
||||
- `CharacterStatsService` is documented as the "single authoritative source of effective character stats (spec §18)" and already passes `currentHp` through (`character-stats.service.ts:57`). Its three consumers are `CharactersService`, `EquipmentService`, and `CombatService`.
|
||||
- A `Clock` abstraction with a `CLOCK` injection token already exists, but lives in the travel module (`travel/clock.ts`) and is provided only by `TravelModule`.
|
||||
- `TravelService.completeTravelIfDue` establishes the house lazy-settle-on-read pattern: open a transaction, lock the character, apply elapsed time, write back.
|
||||
- `EquipmentService.equip` is the **only** way equipment changes today — there is no unequip endpoint, and it already refuses to run during an active combat (`characterInCombat()`, `equipment.service.ts:83-88`).
|
||||
- `WorldStore` already owns the character signal that feeds the top bar (`app-shell.component.html:2`), already runs a one-second `countdownTimer` for travel, and already cleans it up in `ngOnDestroy`.
|
||||
- Migration convention: timestamp-prefixed files in `apps/api/src/database/migrations/`, each with a sibling `*.migration.spec.ts` that asserts TypeORM entity metadata only and never touches a live database. Latest on disk is `1790000000000-ExtendCombatEventTypes.ts`; the not-yet-implemented Renown plan reserves `1791000000000`.
|
||||
- No design document in `docs/` specifies any HP regeneration rule. This mechanic is genuinely new.
|
||||
|
||||
## 4. Rulings
|
||||
|
||||
**R1 — Defeat leaves the character at 0 HP and regenerates up from there.**
|
||||
No revive, no partial restore. Losing costs real time, which is what makes retreating a decision.
|
||||
*Cost if wrong:* a single line in the combat-end path.
|
||||
|
||||
**R2 — Starting a combat requires ≥ 1 effective HP; there is no separate defeat lockout.**
|
||||
One rule the player can learn, no "was defeated" flag on the character, no second timer. After a loss the player waits roughly one second.
|
||||
*Cost if wrong:* raising the threshold is a constant change plus one test; it needs no schema change.
|
||||
|
||||
**R3 — Regeneration is a derived value anchored to a timestamp, not a scheduled job and not a settle-on-read write.**
|
||||
`characters.current_hp` becomes "HP as of `hp_regen_since`". Effective HP is a pure function of `(current_hp, hp_regen_since, maxHp, now)`.
|
||||
Rejected alternatives:
|
||||
- *A cron tick incrementing every character each second* — one write per character per second, and every server restart or outage silently swallows the elapsed time. Only testable against real timers.
|
||||
- *Settle-on-read after the `completeTravelIfDue` pattern* — turns every `GET` into a locking write transaction. Since the client ticks locally, the API has to return the anchor timestamp regardless, which makes the write pure redundancy and opens a lost-update window between concurrent reads.
|
||||
|
||||
The derived model additionally gets max-HP clamping for free (the `min(maxHp, …)` happens at read time, so an equipment change never has to touch HP) and produces exactly the four values the client needs to count up locally.
|
||||
*Cost if wrong:* switching to settle-on-read later reuses the same column and the same arithmetic; only the call sites change.
|
||||
|
||||
**R4 — Regeneration is paused during combat by setting the anchor to `NULL`, and time spent in combat is not credited afterwards.**
|
||||
This is what "no regeneration during combat" means: a paused clock, not a deferred one. `NULL` encodes "frozen at `current_hp`" and needs no extra column.
|
||||
*Cost if wrong:* crediting combat time instead would mean not nulling the anchor at all — a deletion, not a rewrite.
|
||||
|
||||
**R5 — Every combat round mirrors the player's HP onto the character, rather than only writing back once at combat end.**
|
||||
`performAction` already holds a pessimistic write lock on the character row, so this is one extra field on a row that is being saved anyway. It keeps the HUD truthful mid-fight and removes "combat end" as a special case — the end differs only in that it restarts the anchor.
|
||||
*Cost if wrong:* dropping the mirror leaves the write-back at combat end; the code path stays identical.
|
||||
|
||||
**R6 — `EquipmentService.equip` re-anchors HP, to stop accumulated overflow from being cashed in.**
|
||||
The anchor clamps on read, not on write. A character idling ten minutes at 100/100 (anchor: 100 HP, 600 s ago) who then equips +20 max HP would evaluate to `min(120, 100 + 600) = 120` and receive the new points instantly. `settle()` before the equipment change collapses the anchor to the clamped value at `now()`.
|
||||
This is the only call site: there is no unequip endpoint, and `equip` is already blocked during combat — so `settle()` here always runs against a live (non-`NULL`) anchor.
|
||||
*Cost if wrong:* removing the call restores the old behaviour; it is one line.
|
||||
|
||||
**R7 — The regeneration rate is one exported constant, not a column and not per-character.**
|
||||
`HP_REGEN_PER_SECOND = 1` in `characters/character-vitals.constants.ts`, matching how `STARTING_POTION_COUNT` lives beside its service.
|
||||
*Cost if wrong:* making it character-derived later changes the service signature and one migration; no consumer outside the vitals service reads it.
|
||||
|
||||
**R8 — The client counts up locally from the server's anchor; the server stays authoritative.**
|
||||
The API returns `hpRegenPerSecond` and `hpRegenSince`; `WorldStore` recomputes the displayed value every second using the same formula, clamped to `maxHp`. Every real request overwrites the local value. No polling loop.
|
||||
*Cost if wrong:* falling back to "update only on navigation" is deleting the timer.
|
||||
|
||||
## 5. Data model
|
||||
|
||||
Migration `1792000000000-AddHpRegeneration.ts` (skipping `1791000000000`, which the pending Renown plan reserves):
|
||||
|
||||
```sql
|
||||
ALTER TABLE characters ADD COLUMN hp_regen_since timestamptz NULL;
|
||||
```
|
||||
|
||||
Backfill in the same migration:
|
||||
|
||||
- `hp_regen_since = now()` for every character, then
|
||||
- `hp_regen_since = NULL` for characters holding a combat with `status = 'ACTIVE'`, so the migration does not gift an in-flight fight.
|
||||
|
||||
`down()` drops the column.
|
||||
|
||||
**Column contract**, documented on the entity: `current_hp` is the HP value as of `hp_regen_since`. When `hp_regen_since` is `NULL`, regeneration is paused and `current_hp` is exact. **No code outside `CharacterVitalsService` may read `current_hp` directly.**
|
||||
|
||||
## 6. Components
|
||||
|
||||
### 6.1 `shared/clock.ts` (moved)
|
||||
|
||||
`travel/clock.ts` moves to `shared/clock.ts` verbatim. Two modules must not borrow a clock from the travel module. `TravelModule` keeps providing it; `CharactersModule` provides it too. Import paths in `travel.module.ts` and `travel.service.ts` change; nothing else does.
|
||||
|
||||
### 6.2 `CharacterVitalsService` (new)
|
||||
|
||||
`apps/api/src/characters/character-vitals.service.ts`. The only place the anchor arithmetic exists. Injects `CLOCK`. No method opens a transaction — each operates on an already-loaded `Character` instance, leaving locking and saving to the caller.
|
||||
|
||||
| Method | Behaviour |
|
||||
|---|---|
|
||||
| `effectiveHp(character, maxHp): number` | Pure. Anchor `NULL` ⇒ `min(maxHp, current_hp)`. Otherwise `min(maxHp, current_hp + floor(elapsedSeconds × HP_REGEN_PER_SECOND))`, with `elapsedSeconds` floored at 0 so a backwards-moving clock can never reduce HP. |
|
||||
| `pause(character, value)` | `current_hp = value`, `hp_regen_since = null`. |
|
||||
| `resume(character, value)` | `current_hp = value`, `hp_regen_since = clock.now()`. |
|
||||
| `settle(character, maxHp)` | `resume(character, effectiveHp(character, maxHp))`. Re-anchors without changing the effective value. |
|
||||
|
||||
### 6.3 `CharacterStatsService` (changed)
|
||||
|
||||
`currentHp` in `EffectiveCharacterStats` becomes the effective value rather than the raw column, via an injected `CharacterVitalsService`. This keeps the documented "single authoritative source" claim true and makes all three existing consumers correct without touching them individually.
|
||||
|
||||
### 6.4 Module wiring
|
||||
|
||||
`CharactersModule` adds `CharacterVitalsService` and `{ provide: CLOCK, useValue: systemClock }` to `providers`, and `CharacterVitalsService` to `exports`. `CombatModule` and `EquipmentModule` already import `CharactersModule`, so they need no new imports — only the constructor injection.
|
||||
|
||||
## 7. Data flow
|
||||
|
||||
| Event | Effect |
|
||||
|---|---|
|
||||
| `GET /api/characters/me` | Read-only. Response gains `hpRegenPerSecond: number` and `hpRegenSince: string \| null` (ISO 8601). `currentHp` is the effective value. |
|
||||
| `POST /api/combats` | Compute effective HP. If `< 1`, throw `CHARACTER_TOO_WOUNDED`. Otherwise `pause(character, effectiveHp)`, save the character, and seed `combat.playerCurrentHp` with that value instead of `playerStats.maxHp`. `playerMaxHp` still comes from `playerStats.maxHp`. |
|
||||
| `POST /api/combats/:id/actions`, fight continues | After the engine resolves the round: `pause(character, combat.playerCurrentHp)`, save the character in the same transaction. |
|
||||
| `POST /api/combats/:id/actions`, fight ends (`WON`/`LOST`) | `resume(character, combat.playerCurrentHp)` instead of `pause`. The anchor restarts; a loss regenerates from 0. |
|
||||
| `POST /api/equipment` | `settle(character, maxHpBeforeChange)` before writing the equipment row. |
|
||||
|
||||
The character row is already locked in all three write paths, so no new locking or lock ordering is introduced.
|
||||
|
||||
## 8. Frontend
|
||||
|
||||
- `CharacterResponse` (`game-api.models.ts`) gains `hpRegenPerSecond` and `hpRegenSince`.
|
||||
- `WorldStore` gains a second one-second timer beside `countdownTimer`, cleaned up in the same `ngOnDestroy`, and a `displayedCharacter` signal that applies the section 6.2 formula to the last server snapshot. The timer only runs while `hpRegenSince !== null`. `characterState` keeps the raw server value; every real request resyncs it.
|
||||
- `app-shell.component.html` binds the top bar to `worldStore.displayedCharacter()`.
|
||||
- `TopBarComponent` is unchanged — it still reads one input.
|
||||
- Error messages for `CHARACTER_TOO_WOUNDED` go into both `COMBAT_ERROR_MESSAGES` (`combat.store.ts`) and `HUNT_ERROR_MESSAGES` (`hunting.store.ts`), because the fight is started from the hunt screen: *"Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast."*
|
||||
|
||||
## 9. Error handling
|
||||
|
||||
One new code in `combat.errors.ts`, following the existing factory-function pattern:
|
||||
|
||||
```ts
|
||||
export function characterTooWounded(): CombatDomainError {
|
||||
return new CombatDomainError(
|
||||
'CHARACTER_TOO_WOUNDED',
|
||||
HttpStatus.CONFLICT,
|
||||
'The character is too wounded to fight.',
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
added to the `CombatErrorCode` union. `409 Conflict` matches `CHARACTER_TRAVELLING`, the other "right request, wrong moment" case.
|
||||
|
||||
## 10. Edge cases
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| Max HP rises (equipping) | R6's `settle()` re-anchors first, so only time elapsed *after* the change counts toward the new ceiling. |
|
||||
| Max HP falls | No unequip endpoint exists today, but `min(maxHp, …)` clamps on every read, so a future one needs no HP-specific code beyond the existing `settle()` call. |
|
||||
| Clock moves backwards | Elapsed seconds floor at 0; HP can never decrease through regeneration. |
|
||||
| Combat abandoned mid-fight | The anchor stays `NULL`, so the character does not regenerate. This follows directly from R4. It is not a dead end: `App.ngOnInit` already resumes an open combat on the next page load. |
|
||||
| Equipment changed mid-combat | Cannot happen — `EquipmentService.equip` already throws `characterInCombat()`. |
|
||||
| Potion healing | Unchanged. The engine still clamps to `combat.playerMaxHp`, which is frozen at combat start. |
|
||||
|
||||
## 11. Testing
|
||||
|
||||
TDD throughout, in plan order.
|
||||
|
||||
- **`character-vitals.service.spec.ts`** (the core): anchor arithmetic against a fake clock, clamping at `maxHp`, `NULL` anchor returns the frozen value, backwards clock yields no loss, `pause`/`resume`/`settle` write the expected pair of fields.
|
||||
- **`character-stats.service.spec.ts`**: the existing `passes currentHp through unchanged from the character` case is replaced — `currentHp` is now the effective value.
|
||||
- **`combat.service.spec.ts`**: start seeds from character HP rather than max HP; start at 0 effective HP throws `CHARACTER_TOO_WOUNDED`; start at exactly 1 HP succeeds; a continuing round mirrors HP onto the character with a `NULL` anchor; a win and a loss both restart the anchor at `now()`.
|
||||
- **`equipment.service.spec.ts`**: equipping re-anchors, so a long-idle character at full HP does not instantly gain the item's bonus HP.
|
||||
- **`add-hp-regeneration.migration.spec.ts`**: entity-metadata assertions matching the eight existing migration specs, plus the backfill rule for a character with an active combat.
|
||||
- **`world.store.spec.ts`**: local count-up under Jest fake timers, stop at `maxHp`, no ticking while the anchor is `null`, timer cleared on destroy.
|
||||
- **E2E**: finish a combat with damage taken → `GET /characters/me` reports the reduced HP → after advancing the injected clock, it reports a higher value.
|
||||
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();
|
||||