diff --git a/README.md b/README.md index a2da761..8a28ca6 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ for one demo character, with no login required. ## Local setup 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 npm install diff --git a/apps/api/src/database/seeds/item-content.ts b/apps/api/src/database/seeds/item-content.ts index f5dc4da..4115761 100644 --- a/apps/api/src/database/seeds/item-content.ts +++ b/apps/api/src/database/seeds/item-content.ts @@ -278,13 +278,13 @@ function entry( * rather than seeding an item that does nothing. */ 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(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, 'raider-gloves', 3, '0.0800'), - entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-insignia', 4, '1.0000'), - entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'tough-hide', 1, '1.0000'), + entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-insignia', 4, '0.6000'), + 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(CHARRED_LOOTER_LOOT_TABLE_ID, 'charred-raider-insignia', 1, '1.0000'), entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'reinforced-leather-jacket', 2, '0.1000'), diff --git a/apps/api/src/database/seeds/local-location.content.ts b/apps/api/src/database/seeds/local-location.content.ts index 3275c4e..d944291 100644 --- a/apps/api/src/database/seeds/local-location.content.ts +++ b/apps/api/src/database/seeds/local-location.content.ts @@ -171,6 +171,7 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = { resultTitle: 'Gate Watch', 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."', + resultImg: '/images/npcs/graufurt-gate-watch.png', }, // 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 diff --git a/apps/api/src/world/local-location-interaction.spec.ts b/apps/api/src/world/local-location-interaction.spec.ts index ce4a097..0dca51c 100644 --- a/apps/api/src/world/local-location-interaction.spec.ts +++ b/apps/api/src/world/local-location-interaction.spec.ts @@ -63,6 +63,7 @@ const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [ enabled: true, resultTitle: 'Gate Watch', 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 () => { const completeTravelIfDue = jest.fn().mockResolvedValue({ status: 'IDLE' }); const service = createService( diff --git a/apps/api/src/world/local-location.types.ts b/apps/api/src/world/local-location.types.ts index 54dcf2e..f12e817 100644 --- a/apps/api/src/world/local-location.types.ts +++ b/apps/api/src/world/local-location.types.ts @@ -46,6 +46,8 @@ export interface LocationPointOfInterestContent { enabled: boolean; resultTitle?: 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 * to that person's screen instead of an authored one-shot text reveal. A @@ -117,6 +119,7 @@ export interface LocationInteractionResultDto { interactionKey: string; title: string; text: string; + img?: string; } /** Strips server-only result text before a POI is sent to the client. */ diff --git a/apps/api/src/world/world.service.ts b/apps/api/src/world/world.service.ts index 904201e..17a2d14 100644 --- a/apps/api/src/world/world.service.ts +++ b/apps/api/src/world/world.service.ts @@ -156,6 +156,7 @@ export class WorldService { interactionKey: poi.key, title: poi.resultTitle ?? poi.title, text: poi.resultText, + ...(poi.resultImg === undefined ? {} : { img: poi.resultImg }), }; } diff --git a/apps/web/public/images/npcs/borin.png b/apps/web/public/images/npcs/borin.png index b343a91..443d80d 100644 Binary files a/apps/web/public/images/npcs/borin.png and b/apps/web/public/images/npcs/borin.png differ diff --git a/apps/web/public/images/npcs/borin_comic.png b/apps/web/public/images/npcs/borin_comic.png new file mode 100644 index 0000000..b343a91 Binary files /dev/null and b/apps/web/public/images/npcs/borin_comic.png differ diff --git a/apps/web/public/images/npcs/graufurt-gate-watch.png b/apps/web/public/images/npcs/graufurt-gate-watch.png index 8e6282f..39c04e7 100644 Binary files a/apps/web/public/images/npcs/graufurt-gate-watch.png and b/apps/web/public/images/npcs/graufurt-gate-watch.png differ diff --git a/apps/web/public/images/npcs/graufurt-gate-watch_comic.png b/apps/web/public/images/npcs/graufurt-gate-watch_comic.png new file mode 100644 index 0000000..8e6282f Binary files /dev/null and b/apps/web/public/images/npcs/graufurt-gate-watch_comic.png differ diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html index 2781546..9e56835 100644 --- a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html @@ -7,6 +7,15 @@ data-interaction-panel > @if (result) { + @if (result.img) { + + }

{{ result.title }}

{{ result.text }}

} @else { diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss index 7153d64..c11e3b7 100644 --- a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss @@ -22,6 +22,15 @@ 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 { margin: 0; color: var(--ar-gold); diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts index 30fedeb..f874d31 100644 --- a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { LocationInteractionPanelComponent } from './location-interaction-panel.component'; 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; }): Promise<{ fixture: ComponentFixture; @@ -62,6 +62,31 @@ describe('LocationInteractionPanelComponent', () => { 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( + '[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 () => { const { element } = await setup({ error: "There's nothing to discover here." }); diff --git a/apps/web/src/app/features/world/world-page.component.scss b/apps/web/src/app/features/world/world-page.component.scss index 9415233..5a34516 100644 --- a/apps/web/src/app/features/world/world-page.component.scss +++ b/apps/web/src/app/features/world/world-page.component.scss @@ -91,11 +91,11 @@ } .world-page__node--south-gate { - inset: 58% auto auto 12%; + inset: 35% auto auto 16%; } .world-page__node--burned-road { - inset: 39% auto auto 60%; + inset: 49% auto auto 27%; } .world-page__travel-panel {