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>
This commit is contained in:
@@ -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
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
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
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
BIN
apps/web/public/assets/hud-elements/panel-frame.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
apps/web/public/assets/hud-elements/panel-ornament.png
Normal file
BIN
apps/web/public/assets/hud-elements/panel-ornament.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web/public/images/character/female-320.png
Normal file
BIN
apps/web/public/images/character/female-320.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
apps/web/public/images/character/female-portrait-256.png
Normal file
BIN
apps/web/public/images/character/female-portrait-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
apps/web/public/images/character/male-320.png
Normal file
BIN
apps/web/public/images/character/male-320.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/web/public/images/character/male-portrait-256.png
Normal file
BIN
apps/web/public/images/character/male-portrait-256.png
Normal file
Binary file not shown.
|
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
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,
|
||||
|
||||
Reference in New Issue
Block a user