Merge branch 'master' into slice/0.9-first-quest-and-bag-tutorial

master turned the gate watch into painted scenery with its own portrait and
result image; Slice 0.9 turns that same hotspot into a real NPC. Kept the
NPC doorway and dropped the result text and image with it -- a hotspot
carries one or the other, never both.

The warden now uses master's `graufurt-gate-watch.png` instead of the raw
copy this branch added: same figure, already optimized. Borin keeps master's
optimized portrait for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-23 08:58:21 +02:00
25 changed files with 98 additions and 13 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

@@ -158,6 +158,7 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
resultTitle: 'Notice Board', resultTitle: 'Notice Board',
resultText: resultText:
'Weathered notices flutter in the wind. A fresh one warns of raiders on the Burned Road and promises silver for every bandit killed.', 'Weathered notices flutter in the wind. A fresh one warns of raiders on the Burned Road and promises silver for every bandit killed.',
resultImg: '/images/environment/notice-board.png',
}, },
// Was authored scenery with a line of result text until Slice 0.9 made the // Was authored scenery with a line of result text until Slice 0.9 made the
// warden a real NPC with a quest to give. A hotspot carries result text or // warden a real NPC with a quest to give. A hotspot carries result text or

View File

@@ -144,7 +144,9 @@ export const NPC_DEFINITIONS: SeedNpcDefinition[] = [
'They have watched the road long enough to stop expecting good news from it. Whatever comes back through the gate, they want it counted.', 'They have watched the road long enough to stop expecting good news from it. Whatever comes back through the gate, they want it counted.',
locationKey: 'south-gate', locationKey: 'south-gate',
factionKey: 'border-guard', factionKey: 'border-guard',
portraitPath: '/images/npcs/south-gate-warden.png', // The gate watch's own portrait, painted before this slice made them a
// person. Reused rather than duplicated: it is the same figure.
portraitPath: '/images/npcs/graufurt-gate-watch.png',
artworkPath: null, artworkPath: null,
capabilities: [ capabilities: [
NpcCapability.DIALOGUE, NpcCapability.DIALOGUE,

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 }),
}; };
} }

BIN
apps/web/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -88,6 +88,7 @@ export interface LocationInteractionResult {
interactionKey: string; interactionKey: string;
title: string; title: string;
text: string; text: string;
img?: string;
} }
export interface CurrentLocationResponse { export interface CurrentLocationResponse {

View File

@@ -39,7 +39,7 @@
.merchant__portrait { .merchant__portrait {
inline-size: 7rem; inline-size: 7rem;
block-size: 7rem; block-size: 7rem;
object-fit: cover; object-fit: contain;
border: 1px solid var(--ar-border-highlight); border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-md); border-radius: var(--ar-radius-md);
background: var(--ar-panel-muted); background: var(--ar-panel-muted);

View File

@@ -7,8 +7,21 @@
data-interaction-panel data-interaction-panel
> >
@if (result) { @if (result) {
<h2 class="interaction-panel__title">{{ result.title }}</h2> <div class="interaction-panel__body">
<p class="interaction-panel__text">{{ result.text }}</p> @if (result.img) {
<img
class="interaction-panel__portrait"
data-interaction-portrait
[src]="result.img"
[alt]="result.title"
(error)="($any($event.target).style.visibility = 'hidden')"
/>
}
<div class="interaction-panel__copy">
<h2 class="interaction-panel__title">{{ result.title }}</h2>
<p class="interaction-panel__text">{{ result.text }}</p>
</div>
</div>
} @else { } @else {
<h2 class="interaction-panel__title">Nothing to discover</h2> <h2 class="interaction-panel__title">Nothing to discover</h2>
<p class="interaction-panel__text interaction-panel__text--error" role="alert"> <p class="interaction-panel__text interaction-panel__text--error" role="alert">

View File

@@ -22,6 +22,27 @@
pointer-events: auto; pointer-events: auto;
} }
.interaction-panel__body {
display: flex;
align-items: flex-start;
gap: var(--ar-space-4);
}
.interaction-panel__portrait {
flex: 0 0 auto;
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__copy {
display: grid;
gap: var(--ar-space-3);
}
.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

@@ -10,7 +10,7 @@
preserveAspectRatio="none" preserveAspectRatio="none"
aria-hidden="true" aria-hidden="true"
> >
<path d="M 21 62 C 36 64, 51 50, 68 43" pathLength="1" /> <path d="M 21.71 37.61 C 26 39, 29 50, 32.70 51.55" pathLength="1" />
</svg> </svg>
<app-location-node <app-location-node

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 {

View File

@@ -2,10 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Web</title> <title>Ashen Realms</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/png" href="favicon.png">
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>