This commit is contained in:
Bastian Wagner
2026-08-22 22:34:51 +02:00
parent 2dadc5c4a7
commit 93916457cb
14 changed files with 72 additions and 6 deletions

View File

@@ -30,6 +30,10 @@ for one demo character, with no login required.
## Local setup ## Local setup
Run from the repository root: Run from the repository root:
```
docker run --name ashen-postgres -e POSTGRES_USER=ashen -e POSTGRES_PASSWORD=ashen -e POSTGRES_DB=ashen_realms -p 5432:5432 -d postgres:16
```
```powershell ```powershell
npm install npm install

View File

@@ -278,13 +278,13 @@ function entry(
* rather than seeding an item that does nothing. * rather than seeding an item that does nothing.
*/ */
export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [ export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [
entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '1.0000'), entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '0.6000'),
entry(ASH_RAT_LOOT_TABLE_ID, 'worn-short-sword', 2, '0.0800'), entry(ASH_RAT_LOOT_TABLE_ID, 'worn-short-sword', 2, '0.0800'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-blade', 1, '0.1800'), entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-blade', 1, '0.1800'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-hood', 2, '0.1200'), entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-hood', 2, '0.1200'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'raider-gloves', 3, '0.0800'), entry(ROAD_BANDIT_LOOT_TABLE_ID, 'raider-gloves', 3, '0.0800'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-insignia', 4, '1.0000'), entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-insignia', 4, '0.6000'),
entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'tough-hide', 1, '1.0000'), entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'tough-hide', 1, '0.6000'),
entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'ash-boots', 2, '0.0800'), entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'ash-boots', 2, '0.0800'),
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'charred-raider-insignia', 1, '1.0000'), entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'charred-raider-insignia', 1, '1.0000'),
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'reinforced-leather-jacket', 2, '0.1000'), entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'reinforced-leather-jacket', 2, '0.1000'),

View File

@@ -171,6 +171,7 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
resultTitle: 'Gate Watch', resultTitle: 'Gate Watch',
resultText: resultText:
'"Beyond the gate, Graufurt\'s protection ends. Whoever heads south does so at their own risk — and rarely comes back the way they left."', '"Beyond the gate, Graufurt\'s protection ends. Whoever heads south does so at their own risk — and rarely comes back the way they left."',
resultImg: '/images/npcs/graufurt-gate-watch.png',
}, },
// Borin stands at the gate rather than deeper in a town that does not // Borin stands at the gate rather than deeper in a town that does not
// exist yet as a location. Carries `npcKey` instead of result text, so // exist yet as a location. Carries `npcKey` instead of result text, so

View File

@@ -63,6 +63,7 @@ const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [
enabled: true, enabled: true,
resultTitle: 'Gate Watch', resultTitle: 'Gate Watch',
resultText: 'Only heard at the South Gate.', resultText: 'Only heard at the South Gate.',
resultImg: '/images/npcs/graufurt-gate-watch.png',
}, },
]; ];
@@ -114,6 +115,19 @@ describe('WorldService.runLocalInteraction', () => {
}); });
}); });
it('includes the authored image when the interaction carries one', async () => {
const service = createService(location(SOUTH_GATE_ID, SOUTH_GATE_POIS));
await expect(
service.runLocalInteraction(CHARACTER_ID, 'gate-watch'),
).resolves.toEqual({
interactionKey: 'gate-watch',
title: 'Gate Watch',
text: 'Only heard at the South Gate.',
img: '/images/npcs/graufurt-gate-watch.png',
});
});
it('settles travel before resolving which location the character stands at', async () => { it('settles travel before resolving which location the character stands at', async () => {
const completeTravelIfDue = jest.fn().mockResolvedValue({ status: 'IDLE' }); const completeTravelIfDue = jest.fn().mockResolvedValue({ status: 'IDLE' });
const service = createService( const service = createService(

View File

@@ -46,6 +46,8 @@ export interface LocationPointOfInterestContent {
enabled: boolean; enabled: boolean;
resultTitle?: string; resultTitle?: string;
resultText?: string; resultText?: string;
/** Authored portrait shown alongside the result text, e.g. an NPC's face. */
resultImg?: string;
/** /**
* Names a real NPC (NPC spec §24), which turns this hotspot into a doorway * Names a real NPC (NPC spec §24), which turns this hotspot into a doorway
* to that person's screen instead of an authored one-shot text reveal. A * to that person's screen instead of an authored one-shot text reveal. A
@@ -117,6 +119,7 @@ export interface LocationInteractionResultDto {
interactionKey: string; interactionKey: string;
title: string; title: string;
text: string; text: string;
img?: string;
} }
/** Strips server-only result text before a POI is sent to the client. */ /** Strips server-only result text before a POI is sent to the client. */

View File

@@ -156,6 +156,7 @@ export class WorldService {
interactionKey: poi.key, interactionKey: poi.key,
title: poi.resultTitle ?? poi.title, title: poi.resultTitle ?? poi.title,
text: poi.resultText, text: poi.resultText,
...(poi.resultImg === undefined ? {} : { img: poi.resultImg }),
}; };
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -7,6 +7,15 @@
data-interaction-panel data-interaction-panel
> >
@if (result) { @if (result) {
@if (result.img) {
<img
class="interaction-panel__portrait"
data-interaction-portrait
[src]="result.img"
[alt]="result.title"
(error)="($any($event.target).style.visibility = 'hidden')"
/>
}
<h2 class="interaction-panel__title">{{ result.title }}</h2> <h2 class="interaction-panel__title">{{ result.title }}</h2>
<p class="interaction-panel__text">{{ result.text }}</p> <p class="interaction-panel__text">{{ result.text }}</p>
} @else { } @else {

View File

@@ -22,6 +22,15 @@
pointer-events: auto; pointer-events: auto;
} }
.interaction-panel__portrait {
inline-size: 5rem;
block-size: 5rem;
object-fit: contain;
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-md);
background: var(--ar-panel-muted);
}
.interaction-panel__title { .interaction-panel__title {
margin: 0; margin: 0;
color: var(--ar-gold); color: var(--ar-gold);

View File

@@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LocationInteractionPanelComponent } from './location-interaction-panel.component'; import { LocationInteractionPanelComponent } from './location-interaction-panel.component';
async function setup(inputs: { async function setup(inputs: {
result?: { interactionKey: string; title: string; text: string } | null; result?: { interactionKey: string; title: string; text: string; img?: string } | null;
error?: string | null; error?: string | null;
}): Promise<{ }): Promise<{
fixture: ComponentFixture<LocationInteractionPanelComponent>; fixture: ComponentFixture<LocationInteractionPanelComponent>;
@@ -62,6 +62,31 @@ describe('LocationInteractionPanelComponent', () => {
expect(element.querySelectorAll('button')).toHaveLength(1); expect(element.querySelectorAll('button')).toHaveLength(1);
}); });
it("shows the NPC's portrait when the result carries one", async () => {
const { element } = await setup({
result: {
interactionKey: 'gate-watch',
title: 'Gate Watch',
text: 'Beyond the gate, Graufurt\'s protection ends.',
img: '/images/npcs/graufurt-gate-watch.png',
},
});
const portrait = element.querySelector<HTMLImageElement>(
'[data-interaction-portrait]',
);
expect(portrait?.src).toContain('/images/npcs/graufurt-gate-watch.png');
expect(portrait?.alt).toBe('Gate Watch');
});
it('shows no portrait when the result carries none', async () => {
const { element } = await setup({
result: { interactionKey: 'inspect-tracks', title: 'Suspicious Tracks', text: 'X' },
});
expect(element.querySelector('[data-interaction-portrait]')).toBeNull();
});
it('reports a rejected interaction in place instead of navigating away', async () => { it('reports a rejected interaction in place instead of navigating away', async () => {
const { element } = await setup({ error: "There's nothing to discover here." }); const { element } = await setup({ error: "There's nothing to discover here." });

View File

@@ -91,11 +91,11 @@
} }
.world-page__node--south-gate { .world-page__node--south-gate {
inset: 58% auto auto 12%; inset: 35% auto auto 16%;
} }
.world-page__node--burned-road { .world-page__node--burned-road {
inset: 39% auto auto 60%; inset: 49% auto auto 27%;
} }
.world-page__travel-panel { .world-page__travel-panel {