diff --git a/apps/api/src/database/seeds/npc-content.ts b/apps/api/src/database/seeds/npc-content.ts index df54c14..d5d119d 100644 --- a/apps/api/src/database/seeds/npc-content.ts +++ b/apps/api/src/database/seeds/npc-content.ts @@ -235,7 +235,7 @@ export const DIALOGUE_NODES: SeedDialogueNode[] = [ { npcId: SOUTH_GATE_WARDEN_NPC_ID, key: 'warden-quest-offer', - text: 'The road has gone bad. Start small. Bring me five Ashen Pelts from the rats beyond the gate. I want to know what the ash is doing to them.', + text: 'The road has gone bad. Nobody reached us for days. The animals are going crazy. I want to know what the ash is doing to them. Travel to the Burned Road and hunt for these creatures.', priority: 900, conditions: [ { @@ -272,7 +272,7 @@ export const DIALOGUE_NODES: SeedDialogueNode[] = [ { npcId: SOUTH_GATE_WARDEN_NPC_ID, key: 'warden-quest-active', - text: 'Still out there, then. Five pelts, and no fewer. I am not paying for a guess.', + text: 'Still out there, then. I need to know what happens on our roads. I am not paying for a guess.', priority: 500, conditions: [ { diff --git a/apps/api/src/database/seeds/quest-content.ts b/apps/api/src/database/seeds/quest-content.ts index 25ae878..d245c36 100644 --- a/apps/api/src/database/seeds/quest-content.ts +++ b/apps/api/src/database/seeds/quest-content.ts @@ -93,7 +93,7 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [ type: QuestObjectiveType.COLLECT_ITEM, targetKey: 'ash-pelt', requiredQuantity: 1, - description: 'Collect Ashen Pelts', + description: 'Collect Ashen Pelts from Ash Rats', npcLine: null, hintText: 'You defeatet a burned creature. Return to the South Gate Warden and report your finding.', @@ -112,9 +112,9 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [ type: QuestObjectiveType.TALK_TO_NPC, targetKey: SOUTH_GATE_WARDEN_KEY, requiredQuantity: 1, - description: 'Return to the South Gate Warden', + description: 'Show the pelt to the Warden.', npcLine: - "We need to investigate this further, collect more pelts and bring it to Borin. Wait, you're not equipped for hauling spoils yet. Go see Borin in Graufurt. Tell him I sent you. He'll complain, but he'll give you something useful.", + "We need to investigate this further, collect more pelts and bring them to Borin. Wait, you're not equipped for hauling spoils yet. Go see Borin in Graufurt. Tell him I sent you. He'll complain, but he'll give you something useful.", hintText: null, advanceWhenBlocked: false, consumeOnComplete: false, @@ -136,7 +136,7 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [ requiredQuantity: 1, description: 'Speak with Borin in Graufurt', npcLine: - 'But the South Gate Warden sent you. Fine. Take this. Bring it back full and make it worth my trouble.', + 'What do you want? I have nothing to spare... But the South Gate Warden sent you? Fine. Take this. Bring it back full and make it worth my trouble.', hintText: 'Borin gave you a bag for animal spoils. It fits 5 pelts or similar.', advanceWhenBlocked: false, consumeOnComplete: false, @@ -158,7 +158,7 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [ type: QuestObjectiveType.COLLECT_ITEM, targetKey: 'ash-pelt', requiredQuantity: 5, - description: 'Collect Ashen Pelts', + description: 'Hunt more Ash Rats and collect their pelt', npcLine: null, // The bag holds five of *any* hide, not five pelts: a player carrying // Tough Hides alongside them can fill it before the fifth pelt. This step @@ -182,7 +182,7 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [ type: QuestObjectiveType.TALK_TO_NPC, targetKey: SOUTH_GATE_WARDEN_KEY, requiredQuantity: 1, - description: 'Bring the pelts to the South Gate Warden', + description: 'Hunt Ashen Rats on the Burned Road and collect their pelt. Bring the pelts to the South Gate Warden', npcLine: "Good. That's enough for me. From now on, take hides and trophies to Borin. He'll pay for useful spoils, and word gets around when you keep the roads clear.", hintText: null, diff --git a/apps/web/public/images/items/ash-pelt.png b/apps/web/public/images/items/ash-pelt.png index a6c49bc..0c098e4 100644 Binary files a/apps/web/public/images/items/ash-pelt.png and b/apps/web/public/images/items/ash-pelt.png differ diff --git a/apps/web/public/images/items/raider-warband-mark.png b/apps/web/public/images/items/raider-warband-mark.png new file mode 100644 index 0000000..9e1c031 Binary files /dev/null and b/apps/web/public/images/items/raider-warband-mark.png differ diff --git a/apps/web/public/images/items/scorched-hide.png b/apps/web/public/images/items/scorched-hide.png new file mode 100644 index 0000000..903c3c2 Binary files /dev/null and b/apps/web/public/images/items/scorched-hide.png differ diff --git a/apps/web/src/app/layout/app-shell/app-shell.component.html b/apps/web/src/app/layout/app-shell/app-shell.component.html index 2098179..644939f 100644 --- a/apps/web/src/app/layout/app-shell/app-shell.component.html +++ b/apps/web/src/app/layout/app-shell/app-shell.component.html @@ -1,5 +1,5 @@
- +
diff --git a/apps/web/src/app/layout/app-shell/app-shell.component.spec.ts b/apps/web/src/app/layout/app-shell/app-shell.component.spec.ts new file mode 100644 index 0000000..dcbd469 --- /dev/null +++ b/apps/web/src/app/layout/app-shell/app-shell.component.spec.ts @@ -0,0 +1,88 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import type { CharacterResponse, Combat } from '../../core/api/game-api.models'; +import { CombatStore } from '../../features/combat/combat.store'; +import { WorldStore } from '../../features/world/world.store'; +import { AppShellComponent } from './app-shell.component'; + +function characterFixture(overrides: Partial = {}): CharacterResponse { + return { + id: 'character-1', + name: 'Aric Duskwalker', + renown: 3, + silver: 120, + currentHp: 40, + maxHp: 100, + attack: 10, + hpRegenPerSecond: 1, + // Regenerating, as if the fight started mid-heal -- the world snapshot's + // own HP would keep climbing toward maxHp if the HUD trusted it. + hpRegenSince: new Date().toISOString(), + currentLocation: { id: 'location-1', key: 'aschenfelder', name: 'Ashen Fields' }, + ...overrides, + }; +} + +const activeCombat: Combat = { + id: 'combat-1', + status: 'ACTIVE', + round: 2, + player: { + name: 'Aric Duskwalker', + maxHp: 100, + currentHp: 62, + potionsRemaining: 2, + potionsMax: 2, + statusEffects: [], + }, + monster: { + key: 'ash-rat', + name: 'Ash Rat', + level: 1, + maxHp: 45, + currentHp: 31, + artworkPath: '/images/monsters/ash-rat.png', + pendingIntent: null, + guardRemainingRounds: null, + enraged: false, + }, + events: [], + rewards: null, +}; + +@Component({ template: '' }) +class BlankComponent {} + +describe('AppShellComponent', () => { + async function setup(url: string, combat: Combat | null): Promise { + await TestBed.configureTestingModule({ + imports: [AppShellComponent], + providers: [ + provideRouter([{ path: '**', component: BlankComponent }]), + { provide: WorldStore, useValue: { displayedCharacter: signal(characterFixture()) } }, + { provide: CombatStore, useValue: { combat: signal(combat) } }, + ], + }).compileComponents(); + + const router = TestBed.inject(Router); + await router.navigateByUrl(url); + + const fixture = TestBed.createComponent(AppShellComponent); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + it('shows the live combat HP, not the regen-ticked world snapshot, while in a fight', async () => { + const element = await setup('/combat/combat-1', activeCombat); + + expect(element.textContent).toContain('62 / 100'); + expect(element.textContent).not.toContain('40 / 100'); + }); + + it('shows the world snapshot HP outside of combat', async () => { + const element = await setup('/location', null); + + expect(element.textContent).toContain('40 / 100'); + }); +}); diff --git a/apps/web/src/app/layout/app-shell/app-shell.component.ts b/apps/web/src/app/layout/app-shell/app-shell.component.ts index da31819..903f129 100644 --- a/apps/web/src/app/layout/app-shell/app-shell.component.ts +++ b/apps/web/src/app/layout/app-shell/app-shell.component.ts @@ -1,5 +1,7 @@ -import { Component, inject } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { Router, RouterOutlet, isActive } from '@angular/router'; +import type { CharacterResponse } from '../../core/api/game-api.models'; +import { CombatStore } from '../../features/combat/combat.store'; import { WorldStore } from '../../features/world/world.store'; import { ContextPanelComponent } from '../context-panel/context-panel.component'; import { GameFooterComponent } from '../game-footer/game-footer.component'; @@ -22,11 +24,30 @@ export class AppShellComponent { private readonly router = inject(Router); protected readonly worldStore = inject(WorldStore); + private readonly combatStore = inject(CombatStore); // The fight has its own log rail and wants the width, and the area info // belongs to the world view anyway, so the rail is dropped during combat. private readonly inCombat = isActive('/combat', this.router); + // Outside a fight the HUD shows WorldStore's regen-ticked HP. Mid-fight, + // regen is paused server-side and the only HP that moves is the combat's -- + // the world snapshot is stale, so it must not drive the bar. + protected readonly topBarCharacter = computed(() => { + const character = this.worldStore.displayedCharacter(); + const combat = this.inCombat() ? this.combatStore.combat() : null; + if (!character || !combat) { + return character; + } + + return { + ...character, + currentHp: combat.player.currentHp, + maxHp: combat.player.maxHp, + hpRegenSince: null, + }; + }); + // The location view brings its own, far richer context sidebar. Showing the // shell's generic area panel next to it would say the same thing twice and // squeeze the artwork the screen is built around.