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>
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/web/public/images/environment/notice-board.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
apps/web/public/images/environment/notice-board_comic.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/web/public/images/items/basic-hide-bag.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
apps/web/public/images/items/basic-trophy-pouch.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.7 MiB |
BIN
apps/web/public/images/npcs/borin_comic.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
apps/web/public/images/npcs/graufurt-gate-watch.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/web/public/images/npcs/graufurt-gate-watch_comic.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -7,8 +7,21 @@
|
|||||||
data-interaction-panel
|
data-interaction-panel
|
||||||
>
|
>
|
||||||
@if (result) {
|
@if (result) {
|
||||||
|
<div class="interaction-panel__body">
|
||||||
|
@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>
|
<h2 class="interaction-panel__title">{{ result.title }}</h2>
|
||||||
<p class="interaction-panel__text">{{ result.text }}</p>
|
<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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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." });
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||