feat(hunting): show cleared encounters and resume interrupted fights
The hunt screen kept whatever roll was last in memory, so a player coming back from a fight saw every encounter as fresh. Encounters now carry their own status, which the combat module advances as fights start and end. - hunt_encounters.status replaces consumed_at, which only recorded that a fight had begun and could not distinguish a win from a loss - a lost fight hands the encounter back as AVAILABLE, so it can be retried; the unique index tying one combat to one encounter goes with it - GET /hunts/active serves the resumable hunt, which the hunt page adopts on entry rather than trusting its in-memory roll - defeated encounters are crossed out and lose their hover and attack action - a fresh page load rejoins a combat the server still holds open Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<article class="encounter-card">
|
||||
<article class="encounter-card" [class.encounter-card--settled]="settled">
|
||||
<div class="encounter-card__crest">
|
||||
@if (iconPath(); as icon) {
|
||||
<img class="encounter-card__crest-icon" [src]="icon" alt="" loading="lazy" decoding="async" />
|
||||
@@ -13,6 +13,16 @@
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
@if (defeated) {
|
||||
<img
|
||||
class="encounter-card__defeated-mark"
|
||||
[src]="defeatedMark"
|
||||
alt="Besiegt"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h3 class="encounter-card__name">{{ encounter.monster.name }}</h3>
|
||||
@@ -22,5 +32,12 @@
|
||||
<app-danger-badge class="encounter-card__danger" [rating]="encounter.dangerRating" />
|
||||
</div>
|
||||
|
||||
<button type="button" class="encounter-card__attack" (click)="onAttack()">Angreifen</button>
|
||||
<button
|
||||
type="button"
|
||||
class="encounter-card__attack"
|
||||
[disabled]="settled"
|
||||
(click)="onAttack()"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</button>
|
||||
</article>
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
/* A settled encounter is out of play, so the card recedes: the artwork loses
|
||||
its colour and the whole frame dims. */
|
||||
.encounter-card--settled .encounter-card__artwork {
|
||||
filter: grayscale(0.85) brightness(0.6);
|
||||
}
|
||||
|
||||
.encounter-card--settled {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
/* ---------- crest ---------- */
|
||||
|
||||
.encounter-card__crest {
|
||||
@@ -54,6 +64,17 @@
|
||||
place-items: end center;
|
||||
}
|
||||
|
||||
/* Sits over the artwork panel rather than the whole card, so the name and
|
||||
the danger badge stay readable under it. */
|
||||
.encounter-card__defeated-mark {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
inline-size: 88%;
|
||||
block-size: 88%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.encounter-card__artwork {
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
@@ -126,15 +147,20 @@
|
||||
text-shadow: 0 0.1rem 0.35rem rgb(0 0 0 / 0.85);
|
||||
}
|
||||
|
||||
.encounter-card__attack:hover {
|
||||
.encounter-card__attack:enabled:hover {
|
||||
color: #f4ecda;
|
||||
box-shadow: inset 0 0 1.1rem rgb(150 200 245 / 0.5);
|
||||
}
|
||||
|
||||
.encounter-card__attack:active {
|
||||
.encounter-card__attack:enabled:active {
|
||||
box-shadow: inset 0 0.15rem 0.7rem rgb(0 0 0 / 0.6);
|
||||
}
|
||||
|
||||
.encounter-card__attack:disabled {
|
||||
color: rgb(214 206 190 / 0.45);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.encounter-card__attack:focus-visible {
|
||||
outline: 2px solid var(--ar-blue);
|
||||
outline-offset: -3px;
|
||||
@@ -145,7 +171,7 @@
|
||||
transition: filter var(--ar-motion-base);
|
||||
}
|
||||
|
||||
.encounter-card:hover {
|
||||
.encounter-card:not(.encounter-card--settled):hover {
|
||||
filter: drop-shadow(0 0 0.9rem rgb(214 178 107 / 0.3));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const dawnwolfEncounter: HuntEncounter = {
|
||||
artworkPath: '/images/enemies/Dawnwolf.png',
|
||||
},
|
||||
dangerRating: 'MATCH',
|
||||
status: 'AVAILABLE',
|
||||
};
|
||||
|
||||
const ashRatEncounter: HuntEncounter = {
|
||||
@@ -23,6 +24,7 @@ const ashRatEncounter: HuntEncounter = {
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
},
|
||||
dangerRating: 'WEAK',
|
||||
status: 'AVAILABLE',
|
||||
};
|
||||
|
||||
function render(encounter: HuntEncounter): HTMLElement {
|
||||
@@ -89,4 +91,62 @@ describe('EncounterCardComponent', () => {
|
||||
expect(emitted).not.toHaveBeenCalledWith(dawnwolfEncounter.monster.key);
|
||||
expect(dawnwolfEncounter.id).not.toBe(dawnwolfEncounter.monster.key);
|
||||
});
|
||||
|
||||
it('leaves an available encounter unmarked and interactive', () => {
|
||||
const element = render(dawnwolfEncounter);
|
||||
|
||||
expect(element.querySelector('.encounter-card__defeated-mark')).toBeNull();
|
||||
expect(element.querySelector('.encounter-card')?.classList).not.toContain(
|
||||
'encounter-card--settled',
|
||||
);
|
||||
expect(
|
||||
element.querySelector<HTMLButtonElement>('.encounter-card__attack')?.disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('crosses out a defeated encounter and disables its attack', () => {
|
||||
const element = render({ ...dawnwolfEncounter, status: 'DEFEATED' });
|
||||
const mark = element.querySelector('.encounter-card__defeated-mark');
|
||||
|
||||
expect(mark).not.toBeNull();
|
||||
expect(mark?.getAttribute('alt')).toBe('Besiegt');
|
||||
expect(
|
||||
element.querySelector<HTMLButtonElement>('.encounter-card__attack')?.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('drops the hover treatment once an encounter is settled', () => {
|
||||
const defeated = render({ ...dawnwolfEncounter, status: 'DEFEATED' });
|
||||
const inProgress = render({ ...dawnwolfEncounter, status: 'IN_PROGRESS' });
|
||||
|
||||
expect(defeated.querySelector('.encounter-card')?.classList).toContain(
|
||||
'encounter-card--settled',
|
||||
);
|
||||
expect(inProgress.querySelector('.encounter-card')?.classList).toContain(
|
||||
'encounter-card--settled',
|
||||
);
|
||||
});
|
||||
|
||||
it('locks an in-progress encounter without crossing it out', () => {
|
||||
const element = render({ ...dawnwolfEncounter, status: 'IN_PROGRESS' });
|
||||
|
||||
expect(element.querySelector('.encounter-card__defeated-mark')).toBeNull();
|
||||
expect(
|
||||
element.querySelector<HTMLButtonElement>('.encounter-card__attack')?.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not emit an attack for a defeated encounter', () => {
|
||||
const fixture = TestBed.createComponent(EncounterCardComponent);
|
||||
fixture.componentRef.setInput('encounter', { ...dawnwolfEncounter, status: 'DEFEATED' });
|
||||
fixture.detectChanges();
|
||||
|
||||
const emitted = vi.fn();
|
||||
fixture.componentInstance.attack.subscribe(emitted);
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
element.querySelector<HTMLButtonElement>('.encounter-card__attack')?.click();
|
||||
|
||||
expect(emitted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { HuntEncounter } from '../../../core/api/game-api.models';
|
||||
import { monsterCutoutPath, monsterIconPath } from '../../../shared/monster-artwork';
|
||||
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
|
||||
|
||||
const DEFEATED_MARK = '/images/hud/runtime/defeated-mark-256.png';
|
||||
|
||||
@Component({
|
||||
selector: 'app-encounter-card',
|
||||
imports: [DangerBadgeComponent],
|
||||
@@ -13,7 +15,31 @@ export class EncounterCardComponent {
|
||||
@Input({ required: true }) encounter!: HuntEncounter;
|
||||
@Output() readonly attack = new EventEmitter<string>();
|
||||
|
||||
protected readonly defeatedMark = DEFEATED_MARK;
|
||||
|
||||
protected get defeated(): boolean {
|
||||
return this.encounter.status === 'DEFEATED';
|
||||
}
|
||||
|
||||
// A cleared or already-running encounter cannot be attacked, so the card
|
||||
// drops its hover invitation as well as the button.
|
||||
protected get settled(): boolean {
|
||||
return this.encounter.status !== 'AVAILABLE';
|
||||
}
|
||||
|
||||
protected get actionLabel(): string {
|
||||
if (this.defeated) {
|
||||
return 'Besiegt';
|
||||
}
|
||||
|
||||
return this.encounter.status === 'IN_PROGRESS' ? 'Im Kampf' : 'Angreifen';
|
||||
}
|
||||
|
||||
protected onAttack(): void {
|
||||
if (this.settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.attack.emit(this.encounter.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ const threeEncounterHunt: HuntResult = {
|
||||
id: 'encounter-1',
|
||||
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
|
||||
dangerRating: 'WEAK',
|
||||
status: 'AVAILABLE',
|
||||
},
|
||||
{
|
||||
id: 'encounter-2',
|
||||
@@ -56,11 +57,13 @@ const threeEncounterHunt: HuntResult = {
|
||||
artworkPath: '/images/enemies/RoadBandit.png',
|
||||
},
|
||||
dangerRating: 'MATCH',
|
||||
status: 'AVAILABLE',
|
||||
},
|
||||
{
|
||||
id: 'encounter-3',
|
||||
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
|
||||
dangerRating: 'WEAK',
|
||||
status: 'AVAILABLE',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -93,6 +96,7 @@ describe('HuntPageComponent', () => {
|
||||
encounters: () => HuntResult['encounters'];
|
||||
startHunt: ReturnType<typeof vi.fn>;
|
||||
refreshHunt: ReturnType<typeof vi.fn>;
|
||||
loadActiveHunt: ReturnType<typeof vi.fn>;
|
||||
selectEncounter: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let combatStore: {
|
||||
@@ -115,6 +119,7 @@ describe('HuntPageComponent', () => {
|
||||
encounters: () => currentHunt()?.encounters ?? [],
|
||||
startHunt: vi.fn(() => Promise.resolve()),
|
||||
refreshHunt: vi.fn(() => Promise.resolve()),
|
||||
loadActiveHunt: vi.fn(() => Promise.resolve()),
|
||||
selectEncounter: vi.fn(),
|
||||
};
|
||||
combatStore = {
|
||||
@@ -301,6 +306,75 @@ describe('HuntPageComponent', () => {
|
||||
expect(huntingStore.startHunt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adopts the resumable hunt on page entry so cleared encounters stay marked', async () => {
|
||||
await setup(burnedRoad);
|
||||
|
||||
expect(huntingStore.loadActiveHunt).toHaveBeenCalledOnce();
|
||||
expect(huntingStore.startHunt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks a defeated encounter and takes away its attack action', async () => {
|
||||
const clearedHunt: HuntResult = {
|
||||
...threeEncounterHunt,
|
||||
encounters: [
|
||||
{ ...threeEncounterHunt.encounters[0], status: 'DEFEATED' },
|
||||
threeEncounterHunt.encounters[1],
|
||||
threeEncounterHunt.encounters[2],
|
||||
],
|
||||
};
|
||||
const fixture = await setup(burnedRoad, clearedHunt);
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
const cards = element.querySelectorAll('app-encounter-card');
|
||||
expect(cards[0].querySelector('.encounter-card__defeated-mark')).not.toBeNull();
|
||||
expect(cards[1].querySelector('.encounter-card__defeated-mark')).toBeNull();
|
||||
|
||||
const attackButtons = Array.from(
|
||||
element.querySelectorAll<HTMLButtonElement>('.encounter-card__attack'),
|
||||
);
|
||||
expect(attackButtons[0].disabled).toBe(true);
|
||||
expect(attackButtons[1].disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an in-progress encounter unmarked but locked', async () => {
|
||||
const fightingHunt: HuntResult = {
|
||||
...threeEncounterHunt,
|
||||
encounters: [
|
||||
{ ...threeEncounterHunt.encounters[0], status: 'IN_PROGRESS' },
|
||||
threeEncounterHunt.encounters[1],
|
||||
threeEncounterHunt.encounters[2],
|
||||
],
|
||||
};
|
||||
const fixture = await setup(burnedRoad, fightingHunt);
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
const cards = element.querySelectorAll('app-encounter-card');
|
||||
expect(cards[0].querySelector('.encounter-card__defeated-mark')).toBeNull();
|
||||
|
||||
const attackButtons = Array.from(
|
||||
element.querySelectorAll<HTMLButtonElement>('.encounter-card__attack'),
|
||||
);
|
||||
expect(attackButtons[0].disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('does not start a combat from a defeated encounter card', async () => {
|
||||
const clearedHunt: HuntResult = {
|
||||
...threeEncounterHunt,
|
||||
encounters: [
|
||||
{ ...threeEncounterHunt.encounters[0], status: 'DEFEATED' },
|
||||
threeEncounterHunt.encounters[1],
|
||||
threeEncounterHunt.encounters[2],
|
||||
],
|
||||
};
|
||||
const fixture = await setup(burnedRoad, clearedHunt);
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
element.querySelectorAll<HTMLButtonElement>('.encounter-card__attack')[0].click();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(combatStore.startCombat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the world state on init when no location has been loaded yet (direct navigation/hard refresh)', async () => {
|
||||
await setup(null);
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ export class HuntPageComponent implements OnInit {
|
||||
if (this.worldStore.currentLocation() === null) {
|
||||
void this.worldStore.load();
|
||||
}
|
||||
|
||||
// The server owns which encounters are still open, so entering the page --
|
||||
// including on the way back from a fight -- takes its word over whatever
|
||||
// roll is still in memory.
|
||||
void this.huntingStore.loadActiveHunt();
|
||||
}
|
||||
|
||||
protected startHunt(): void {
|
||||
|
||||
@@ -14,11 +14,13 @@ const huntResult: HuntResult = {
|
||||
id: 'encounter-1',
|
||||
monster: { key: 'wolf', name: 'Wolf', level: 1, artworkPath: '/images/enemies/Wolf.png' },
|
||||
dangerRating: 'MATCH',
|
||||
status: 'AVAILABLE',
|
||||
},
|
||||
{
|
||||
id: 'encounter-2',
|
||||
monster: { key: 'bear', name: 'Bär', level: 3, artworkPath: '/images/enemies/Bear.png' },
|
||||
dangerRating: 'STRONG',
|
||||
status: 'DEFEATED',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -31,6 +33,7 @@ const refreshedHuntResult: HuntResult = {
|
||||
id: 'encounter-3',
|
||||
monster: { key: 'rat', name: 'Ratte', level: 1, artworkPath: '/images/enemies/Rat.png' },
|
||||
dangerRating: 'WEAK',
|
||||
status: 'AVAILABLE',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -38,12 +41,14 @@ const refreshedHuntResult: HuntResult = {
|
||||
describe('HuntingStore', () => {
|
||||
let api: {
|
||||
startHunt: ReturnType<typeof vi.fn>;
|
||||
getActiveHunt: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let store: HuntingStore;
|
||||
|
||||
beforeEach(() => {
|
||||
api = {
|
||||
startHunt: vi.fn(() => of(huntResult)),
|
||||
getActiveHunt: vi.fn(() => of(huntResult)),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -206,4 +211,44 @@ describe('HuntingStore', () => {
|
||||
|
||||
expect(store.error()).toBe('Netzwerkfehler');
|
||||
});
|
||||
|
||||
describe('loadActiveHunt', () => {
|
||||
it('adopts the resumable hunt so returning players see the encounter statuses', async () => {
|
||||
await store.loadActiveHunt();
|
||||
|
||||
expect(api.getActiveHunt).toHaveBeenCalledOnce();
|
||||
expect(api.startHunt).not.toHaveBeenCalled();
|
||||
expect(store.currentHunt()).toEqual(huntResult);
|
||||
expect(store.encounters()[1].status).toBe('DEFEATED');
|
||||
expect(store.loading()).toBe(false);
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the page on its start screen when there is no resumable hunt', async () => {
|
||||
api.getActiveHunt.mockReturnValue(of(null));
|
||||
|
||||
await store.loadActiveHunt();
|
||||
|
||||
expect(store.currentHunt()).toBeNull();
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
|
||||
it('replaces a stale hunt with the current server state', async () => {
|
||||
await store.startHunt();
|
||||
api.getActiveHunt.mockReturnValue(of(refreshedHuntResult));
|
||||
|
||||
await store.loadActiveHunt();
|
||||
|
||||
expect(store.currentHunt()).toEqual(refreshedHuntResult);
|
||||
});
|
||||
|
||||
it('surfaces a failed reload as an error and clears loading', async () => {
|
||||
api.getActiveHunt.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
|
||||
|
||||
await store.loadActiveHunt();
|
||||
|
||||
expect(store.error()).toBe('Netzwerkfehler');
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,26 @@ export class HuntingStore {
|
||||
await this.startHunt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopts the hunt the server still considers open, so a player coming back
|
||||
* from a fight (or a fresh page load) sees which encounters they have
|
||||
* already cleared instead of a stale in-memory roll.
|
||||
*/
|
||||
async loadActiveHunt(): Promise<void> {
|
||||
this.loadingState.set(true);
|
||||
this.errorState.set(null);
|
||||
|
||||
try {
|
||||
const hunt = await firstValueFrom(this.api.getActiveHunt());
|
||||
this.currentHuntState.set(hunt);
|
||||
this.selectedEncounterIdState.set(null);
|
||||
} catch (error) {
|
||||
this.errorState.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.loadingState.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
selectEncounter(encounterId: string): void {
|
||||
this.selectedEncounterIdState.set(encounterId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user