From 3b28450fb16f2802c59719b5d42daad120df6c1c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 17:51:47 +0200 Subject: [PATCH 01/18] feat(shops): let an offer sell a loot bag and carry bypass conditions Adds a second, mutually exclusive target column (loot_bag_definition_id) and a bypass_conditions column to shop_offers, so a later slice's quest referral can open one offer that reputation alone would not. Keeps shop.service.ts compiling against the now-nullable itemDefinition with temporary non-null assertions; Task 4 replaces them with a real branch on offer kind. --- .../1796000000000-SellableLootBags.ts | 103 ++++++++++++++++++ .../sellable-loot-bags.migration.spec.ts | 75 +++++++++++++ .../src/shops/entities/shop-offer.entity.ts | 41 ++++++- apps/api/src/shops/shop.service.ts | 19 ++-- 4 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/database/migrations/1796000000000-SellableLootBags.ts create mode 100644 apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts diff --git a/apps/api/src/database/migrations/1796000000000-SellableLootBags.ts b/apps/api/src/database/migrations/1796000000000-SellableLootBags.ts new file mode 100644 index 0000000..2c209d0 --- /dev/null +++ b/apps/api/src/database/migrations/1796000000000-SellableLootBags.ts @@ -0,0 +1,103 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Lets a shop offer sell a loot bag, and lets one offer carry an exception + * (Playable Slice 0.8.5 §4, §7). + * + * A bag is a `loot_bag_definitions` row, not an item -- deliberately so since + * Slice 0.7.5 §6, because a bag is never equipped, never rolls as loot and has + * no combat stats. Selling one therefore needs a second target column rather + * than a fake item definition, which is exactly the "offer shape" the Slice 0.8 + * seed said it had no reason to build yet. + * + * `bypass_conditions` is the minimal exception support Slice 0.9 needs: an + * offer opens when `conditions` hold *or* `bypass_conditions` hold. One column, + * OR semantics, no rule engine (slice §7, §11). + */ +export class SellableLootBags1796000000000 implements MigrationInterface { + name = 'SellableLootBags1796000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Shop offers are pure content -- nothing references them, and Slice 0.8 + // inserted its two rows with generated uuids. The seed re-creates every + // offer with a stable id (AGENTS.md §8), which needs the old anonymous + // rows gone or they would linger as duplicates the upsert never matches. + await queryRunner.query(`DELETE FROM "shop_offers"`); + + await queryRunner.query(` + ALTER TABLE "shop_offers" + ALTER COLUMN "item_definition_id" DROP NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE "shop_offers" + ADD COLUMN "loot_bag_definition_id" uuid + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" + ADD CONSTRAINT "FK_shop_offers_loot_bag" + FOREIGN KEY ("loot_bag_definition_id") + REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT + `); + + await queryRunner.query(` + ALTER TABLE "shop_offers" + ADD COLUMN "bypass_conditions" jsonb NOT NULL DEFAULT '[]'::jsonb + `); + + // Exactly one target. An offer selling nothing has no meaning, and one + // selling both would make the grant path ambiguous. + await queryRunner.query(` + ALTER TABLE "shop_offers" + ADD CONSTRAINT "CHK_shop_offers_single_target" + CHECK (num_nonnulls("item_definition_id", "loot_bag_definition_id") = 1) + `); + + // Partial, because the column each one covers is now nullable and Postgres + // treats NULLs as distinct -- a plain unique index over (shop_id, + // item_definition_id) would happily accept a hundred bag offers. + await queryRunner.query( + `DROP INDEX "IDX_shop_offers_shop_item"`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Bag offers cannot survive a rollback: the column that identifies what + // they sell is about to disappear, and the restored NOT NULL would reject + // them anyway. + await queryRunner.query( + `DELETE FROM "shop_offers" WHERE "loot_bag_definition_id" IS NOT NULL`, + ); + + await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_bag"`); + await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_item"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")`, + ); + + await queryRunner.query(` + ALTER TABLE "shop_offers" + DROP CONSTRAINT "CHK_shop_offers_single_target" + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" DROP COLUMN "bypass_conditions" + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" + DROP CONSTRAINT "FK_shop_offers_loot_bag" + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" DROP COLUMN "loot_bag_definition_id" + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" + ALTER COLUMN "item_definition_id" SET NOT NULL + `); + } +} diff --git a/apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts b/apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts new file mode 100644 index 0000000..4943255 --- /dev/null +++ b/apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts @@ -0,0 +1,75 @@ +import 'reflect-metadata'; +import { QueryRunner } from 'typeorm'; +import { SellableLootBags1796000000000 } from './1796000000000-SellableLootBags'; + +async function runUp(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + await new SellableLootBags1796000000000().up(queryRunner); + return query.mock.calls.map(([sql]) => sql as string); +} + +async function runDown(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + const migration = new SellableLootBags1796000000000(); + await migration.up(queryRunner); + const upCount = query.mock.calls.length; + await migration.down(queryRunner); + return query.mock.calls.slice(upCount).map(([sql]) => sql as string); +} + +describe('SellableLootBags1796000000000', () => { + it('adds the bag target and bypass columns', async () => { + const joined = (await runUp()).join('\n'); + + expect(joined).toContain('"loot_bag_definition_id" uuid'); + expect(joined).toContain('"bypass_conditions" jsonb'); + expect(joined).toContain('FK_shop_offers_loot_bag'); + }); + + it('makes the item target nullable so a bag offer can exist', async () => { + const joined = (await runUp()).join('\n'); + + expect(joined).toContain('ALTER COLUMN "item_definition_id" DROP NOT NULL'); + }); + + it('requires exactly one target per offer', async () => { + const joined = (await runUp()).join('\n'); + + // An offer that sells nothing, or sells both an item and a bag, is a + // content bug the database must not store. + expect(joined).toContain('CHK_shop_offers_single_target'); + expect(joined).toContain('num_nonnulls'); + }); + + it('keeps both target kinds unique per shop via partial indexes', async () => { + const joined = (await runUp()).join('\n'); + + // A plain unique index over a nullable column would let a shop hold + // unlimited bag offers, because Postgres treats NULLs as distinct. + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL', + ); + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL', + ); + }); + + it('clears content offers so the seed can own stable ids', async () => { + const joined = (await runUp()).join('\n'); + + expect(joined).toContain('DELETE FROM "shop_offers"'); + }); + + it('drops what it added and restores the original index on rollback', async () => { + const joined = (await runDown()).join('\n'); + + expect(joined).toContain('DROP COLUMN "loot_bag_definition_id"'); + expect(joined).toContain('DROP COLUMN "bypass_conditions"'); + expect(joined).toContain('ALTER COLUMN "item_definition_id" SET NOT NULL'); + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")', + ); + }); +}); diff --git a/apps/api/src/shops/entities/shop-offer.entity.ts b/apps/api/src/shops/entities/shop-offer.entity.ts index d5cedcf..48476f9 100644 --- a/apps/api/src/shops/entities/shop-offer.entity.ts +++ b/apps/api/src/shops/entities/shop-offer.entity.ts @@ -10,6 +10,7 @@ import { } from 'typeorm'; import type { GameCondition } from '../../conditions/game-condition.types'; import { ItemDefinition } from '../../items/entities/item-definition.entity'; +import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity'; import { NpcShop } from './npc-shop.entity'; /** @@ -23,6 +24,11 @@ import { NpcShop } from './npc-shop.entity'; @Entity({ name: 'shop_offers' }) @Index('IDX_shop_offers_shop_item', ['shopId', 'itemDefinitionId'], { unique: true, + where: '"item_definition_id" IS NOT NULL', +}) +@Index('IDX_shop_offers_shop_bag', ['shopId', 'lootBagDefinitionId'], { + unique: true, + where: '"loot_bag_definition_id" IS NOT NULL', }) export class ShopOffer { @PrimaryGeneratedColumn('uuid', { name: 'id' }) @@ -31,8 +37,16 @@ export class ShopOffer { @Column({ name: 'shop_id', type: 'uuid' }) shopId!: string; - @Column({ name: 'item_definition_id', type: 'uuid' }) - itemDefinitionId!: string; + /** + * What the offer sells. Exactly one of these is set, enforced by + * `CHK_shop_offers_single_target`: a bag is a `LootBagDefinition` rather + * than an item (Slice 0.7.5 §6), so the two cannot share a column. + */ + @Column({ name: 'item_definition_id', type: 'uuid', nullable: true }) + itemDefinitionId!: string | null; + + @Column({ name: 'loot_bag_definition_id', type: 'uuid', nullable: true }) + lootBagDefinitionId!: string | null; /** * Which currency `price` is denominated in. Only SILVER exists today; the @@ -58,6 +72,21 @@ export class ShopOffer { @Column({ name: 'conditions', type: 'jsonb', default: () => "'[]'::jsonb" }) conditions!: GameCondition[]; + /** + * An alternative way in (Slice 0.8.5 §7). + * + * The offer opens when `conditions` hold **or** these do. That is the whole + * exception model: it lets one quest referral grant an acquisition that + * reputation alone would not yet allow, without a favor engine. Empty on + * every offer that has no exception. + */ + @Column({ + name: 'bypass_conditions', + type: 'jsonb', + default: () => "'[]'::jsonb", + }) + bypassConditions!: GameCondition[]; + @Column({ name: 'enabled', type: 'boolean', default: true }) enabled!: boolean; @@ -71,7 +100,11 @@ export class ShopOffer { @JoinColumn({ name: 'shop_id' }) shop!: NpcShop; - @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' }) + @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT', nullable: true }) @JoinColumn({ name: 'item_definition_id' }) - itemDefinition!: ItemDefinition; + itemDefinition!: ItemDefinition | null; + + @ManyToOne(() => LootBagDefinition, { onDelete: 'RESTRICT', nullable: true }) + @JoinColumn({ name: 'loot_bag_definition_id' }) + lootBagDefinition!: LootBagDefinition | null; } diff --git a/apps/api/src/shops/shop.service.ts b/apps/api/src/shops/shop.service.ts index 006bebd..654f762 100644 --- a/apps/api/src/shops/shop.service.ts +++ b/apps/api/src/shops/shop.service.ts @@ -90,11 +90,16 @@ export class ShopService { offer.conditions, ); + // TEMPORARY (Task 1 of Slice 0.8.5): `itemDefinition` became nullable + // when the offer table gained a loot-bag target alongside the item one. + // Every offer sold an item until Task 4 adds bag offers here, so the + // assertion is safe for now -- Task 4 replaces it with a real branch on + // offer kind. view.push({ - itemKey: offer.itemDefinition.key, - itemName: offer.itemDefinition.name, - itemDescription: offer.itemDefinition.description, - iconPath: offer.itemDefinition.iconPath, + itemKey: offer.itemDefinition!.key, + itemName: offer.itemDefinition!.name, + itemDescription: offer.itemDefinition!.description, + iconPath: offer.itemDefinition!.iconPath, currencyType: offer.currencyType, price: offer.price, quantity: offer.quantity, @@ -147,7 +152,7 @@ export class ShopService { relations: { itemDefinition: true }, }); const match = offers.find( - (candidate) => candidate.itemDefinition.key === itemKey, + (candidate) => candidate.itemDefinition!.key === itemKey, ); if (!match) { @@ -178,14 +183,14 @@ export class ShopService { await this.grantItem( manager, characterId, - match.itemDefinitionId, + match.itemDefinitionId!, match.quantity * quantity, ); return { shopKey: shop.key, itemKey, - itemName: match.itemDefinition.name, + itemName: match.itemDefinition!.name, quantity: match.quantity * quantity, silverSpent, silverBalance: character.silver, From c295bae63a13d379f06fab9dad27df1513a8bde9 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 17:59:37 +0200 Subject: [PATCH 02/18] test(shops): add entity-schema cross-check for sellable loot bags The migration spec only asserted SQL substrings against a mocked QueryRunner and had no getMetadataArgsStorage() check that ShopOffer's column options and partial unique indexes actually match the new schema. Since ts-jest does not type-check in this package (isolatedModules: true), this is the only automated guard against entity/migration drift -- matches the house convention in npc-system.migration.spec.ts and loot-bags.migration.spec.ts. --- .../sellable-loot-bags.migration.spec.ts | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts b/apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts index 4943255..4059387 100644 --- a/apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts +++ b/apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts @@ -1,6 +1,7 @@ import 'reflect-metadata'; -import { QueryRunner } from 'typeorm'; +import { getMetadataArgsStorage, QueryRunner } from 'typeorm'; import { SellableLootBags1796000000000 } from './1796000000000-SellableLootBags'; +import { ShopOffer } from '../../shops/entities/shop-offer.entity'; async function runUp(): Promise { const query = jest.fn().mockResolvedValue(undefined); @@ -73,3 +74,47 @@ describe('SellableLootBags1796000000000', () => { ); }); }); + +describe('slice 0.8.5 entity schema', () => { + function column(target: unknown, propertyName: string) { + return getMetadataArgsStorage().columns.find( + (candidate) => + candidate.target === target && candidate.propertyName === propertyName, + ); + } + + it('lets the item target sit empty when the offer sells a bag instead', () => { + expect(column(ShopOffer, 'itemDefinitionId')?.options.nullable).toBe(true); + }); + + it('gives a bag offer its own nullable uuid target', () => { + const lootBagDefinitionId = column(ShopOffer, 'lootBagDefinitionId'); + expect(lootBagDefinitionId?.options.type).toBe('uuid'); + expect(lootBagDefinitionId?.options.nullable).toBe(true); + }); + + it('requires every offer to carry its bypass conditions, empty or not', () => { + const bypassConditions = column(ShopOffer, 'bypassConditions'); + expect(bypassConditions?.options.type).toBe('jsonb'); + expect(bypassConditions?.options.nullable).toBeFalsy(); + }); + + it('declares both partial unique indexes with their WHERE clauses', () => { + const indices = getMetadataArgsStorage().indices.filter( + (candidate) => candidate.target === ShopOffer, + ); + + const itemIndex = indices.find( + (candidate) => candidate.name === 'IDX_shop_offers_shop_item', + ); + const bagIndex = indices.find( + (candidate) => candidate.name === 'IDX_shop_offers_shop_bag', + ); + + expect(itemIndex?.unique).toBe(true); + expect(itemIndex?.where).toBe('"item_definition_id" IS NOT NULL'); + + expect(bagIndex?.unique).toBe(true); + expect(bagIndex?.where).toBe('"loot_bag_definition_id" IS NOT NULL'); + }); +}); From 9bd28b5e2c654d203f6240abc90c333fb7d50bd2 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 18:07:57 +0200 Subject: [PATCH 03/18] feat(conditions): report the current value behind each condition --- .../conditions/game-condition.service.spec.ts | 34 +++++++++ .../src/conditions/game-condition.service.ts | 75 +++++++++++++------ 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/apps/api/src/conditions/game-condition.service.spec.ts b/apps/api/src/conditions/game-condition.service.spec.ts index 8cf3375..e083ad7 100644 --- a/apps/api/src/conditions/game-condition.service.spec.ts +++ b/apps/api/src/conditions/game-condition.service.spec.ts @@ -280,4 +280,38 @@ describe('GameConditionService', () => { expect(outcomes.map((outcome) => outcome.met)).toEqual([true, false]); }); + + it('reports the current value behind each requirement', async () => { + const service = createService({ renown: 1, reputation: 14 }); + + const outcomes = await service.describe({ characterId: CHARACTER_ID }, [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + { + type: GameConditionType.WORLD_RENOWN, + operator: ComparisonOperator.GTE, + value: 3, + }, + ]); + + // "Current: 14" against "Requires 25" is the whole point of showing a + // locked offer rather than hiding it (slice §5). + expect(outcomes[0]).toMatchObject({ met: false, actual: 14 }); + expect(outcomes[1]).toMatchObject({ met: false, actual: 1 }); + }); + + it('reports a null current value for a condition with no scale', async () => { + const service = createService({ renown: 1, reputation: 100 }); + + const outcomes = await service.describe( + { characterId: CHARACTER_ID, npcId: NPC_ID }, + [{ type: GameConditionType.FLAG_SET, key: 'met', value: true }], + ); + + expect(outcomes[0].actual).toBeNull(); + }); }); diff --git a/apps/api/src/conditions/game-condition.service.ts b/apps/api/src/conditions/game-condition.service.ts index f338e77..af5ebaa 100644 --- a/apps/api/src/conditions/game-condition.service.ts +++ b/apps/api/src/conditions/game-condition.service.ts @@ -27,9 +27,21 @@ export interface ConditionContext { type RepositoryScope = Pick; +/** + * One condition's result, plus the number it was measured against. + * + * `actual` is null for conditions with no scale -- a flag is set or it is not, + * and "Current: 0" would be a lie about a boolean. + */ +interface ConditionEvaluation { + met: boolean; + actual: number | null; +} + export interface ConditionOutcome { condition: GameCondition; met: boolean; + actual: number | null; } /** @@ -59,7 +71,8 @@ export class GameConditionService { const scope: RepositoryScope = manager ?? this.dataSource; for (const condition of conditions) { - if (!(await this.evaluateOne(context, condition, scope))) { + const evaluation = await this.evaluateOne(context, condition, scope); + if (!evaluation.met) { return false; } } @@ -83,9 +96,11 @@ export class GameConditionService { const scope: RepositoryScope = manager ?? this.dataSource; const outcomes: ConditionOutcome[] = []; for (const condition of conditions) { + const evaluation = await this.evaluateOne(context, condition, scope); outcomes.push({ condition, - met: await this.evaluateOne(context, condition, scope), + met: evaluation.met, + actual: evaluation.actual, }); } return outcomes; @@ -95,9 +110,9 @@ export class GameConditionService { context: ConditionContext, condition: GameCondition, scope: RepositoryScope, - ): Promise { + ): Promise { if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) { - return false; + return { met: false, actual: null }; } switch (condition.type) { @@ -110,7 +125,7 @@ export class GameConditionService { case GameConditionType.HAS_ITEM: return this.evaluateHasItem(context, condition, scope); default: - return false; + return { met: false, actual: null }; } } @@ -118,16 +133,16 @@ export class GameConditionService { context: ConditionContext, condition: GameCondition, scope: RepositoryScope, - ): Promise { + ): Promise { if (!condition.key) { - return false; + return { met: false, actual: null }; } const faction = await scope .getRepository(ReputationFaction) .findOneBy({ key: condition.key, enabled: true }); if (!faction) { - return false; + return { met: false, actual: null }; } const row = await scope.getRepository(CharacterReputation).findOneBy({ @@ -137,32 +152,39 @@ export class GameConditionService { // A faction the character never interacted with reads as 0, not as absent // -- the same rule ReputationService.getCharacterReputation applies. - return this.compareNumeric(row?.reputation ?? 0, condition); + const reputation = row?.reputation ?? 0; + return { + met: this.compareNumeric(reputation, condition), + actual: reputation, + }; } private async evaluateWorldRenown( context: ConditionContext, condition: GameCondition, scope: RepositoryScope, - ): Promise { + ): Promise { const character = await scope .getRepository(Character) .findOneBy({ id: context.characterId }); if (!character) { - return false; + return { met: false, actual: null }; } - return this.compareNumeric(character.renown, condition); + return { + met: this.compareNumeric(character.renown, condition), + actual: character.renown, + }; } private async evaluateFlag( context: ConditionContext, condition: GameCondition, scope: RepositoryScope, - ): Promise { + ): Promise { // Flags are per-NPC player state (spec §7). Without an NPC in context // there is nothing to read, so the gate stays shut. if (!condition.key || !context.npcId) { - return false; + return { met: false, actual: null }; } const state = await scope.getRepository(CharacterNpcState).findOneBy({ @@ -171,23 +193,26 @@ export class GameConditionService { }); const expected = condition.value ?? true; - return (state?.flags?.[condition.key] ?? false) === expected; + return { + met: (state?.flags?.[condition.key] ?? false) === expected, + actual: null, + }; } private async evaluateHasItem( context: ConditionContext, condition: GameCondition, scope: RepositoryScope, - ): Promise { + ): Promise { if (!condition.key) { - return false; + return { met: false, actual: null }; } const definition = await scope .getRepository(ItemDefinition) .findOneBy({ key: condition.key }); if (!definition) { - return false; + return { met: false, actual: null }; } const owned = await scope.getRepository(CharacterItem).findOneBy({ @@ -196,11 +221,15 @@ export class GameConditionService { }); // "Has item" without an explicit comparison means "at least one". - return this.compareNumeric(owned?.quantity ?? 0, { - ...condition, - operator: condition.operator ?? ComparisonOperator.GTE, - value: condition.value ?? 1, - }); + const quantity = owned?.quantity ?? 0; + return { + met: this.compareNumeric(quantity, { + ...condition, + operator: condition.operator ?? ComparisonOperator.GTE, + value: condition.value ?? 1, + }), + actual: quantity, + }; } private compareNumeric(actual: number, condition: GameCondition): boolean { From 149a9521be2e56c241a028cdde47f84d28f05bf9 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 18:14:12 +0200 Subject: [PATCH 04/18] feat(shops): describe offer requirements and effects in English --- apps/api/src/shops/offer-presentation.spec.ts | 146 ++++++++++++++++++ apps/api/src/shops/offer-presentation.ts | 104 +++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 apps/api/src/shops/offer-presentation.spec.ts create mode 100644 apps/api/src/shops/offer-presentation.ts diff --git a/apps/api/src/shops/offer-presentation.spec.ts b/apps/api/src/shops/offer-presentation.spec.ts new file mode 100644 index 0000000..3d85155 --- /dev/null +++ b/apps/api/src/shops/offer-presentation.spec.ts @@ -0,0 +1,146 @@ +import { + ComparisonOperator, + GameConditionType, +} from '../conditions/game-condition.types'; +import { LootCategory } from '../items/loot-category.enum'; +import { + describeBagEffect, + describeItemEffect, + describeRequirement, +} from './offer-presentation'; + +const FACTIONS = new Map([['border-guard', 'Border Watch']]); + +describe('describeRequirement', () => { + it('names the faction and the threshold for a reputation gate', () => { + const requirement = describeRequirement( + { + condition: { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + met: false, + actual: 14, + }, + FACTIONS, + ); + + expect(requirement).toEqual({ + label: 'Requires Border Watch Reputation 25', + current: 14, + required: 25, + met: false, + }); + }); + + it('describes a World Renown gate', () => { + const requirement = describeRequirement( + { + condition: { + type: GameConditionType.WORLD_RENOWN, + operator: ComparisonOperator.GTE, + value: 3, + }, + met: false, + actual: 1, + }, + FACTIONS, + ); + + expect(requirement).toMatchObject({ + label: 'Requires World Renown 3', + current: 1, + required: 3, + }); + }); + + it('falls back to the faction key when the faction is unknown', () => { + // A gate naming a faction that is not seeded still has to render as + // something, and the key is more useful to a player than a blank. + const requirement = describeRequirement( + { + condition: { + type: GameConditionType.REGION_REPUTATION, + key: 'dusk-hunters', + operator: ComparisonOperator.GTE, + value: 10, + }, + met: false, + actual: 0, + }, + FACTIONS, + ); + + expect(requirement?.label).toBe('Requires dusk-hunters Reputation 10'); + }); + + it('renders no line for a condition type the player is not shown', () => { + // A dialogue flag is an internal gate. Naming it would spoil the quest + // that sets it, and the offer already renders as locked without it. + expect( + describeRequirement( + { + condition: { + type: GameConditionType.FLAG_SET, + key: 'referred-by-south-gate-warden', + value: true, + }, + met: false, + actual: null, + }, + FACTIONS, + ), + ).toBeNull(); + }); +}); + +describe('describeItemEffect', () => { + it('summarises a weapon', () => { + expect( + describeItemEffect({ + weaponDamage: 11, + bonusAttack: 1, + bonusHp: 0, + bonusArmor: 0, + }), + ).toBe('11 Weapon Damage, +1 Attack'); + }); + + it('summarises armour', () => { + expect( + describeItemEffect({ + weaponDamage: 0, + bonusAttack: 0, + bonusHp: 5, + bonusArmor: 4, + }), + ).toBe('+5 HP, +4 Armor'); + }); + + it('has nothing to say about an item with no stats', () => { + expect( + describeItemEffect({ + weaponDamage: 0, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + }), + ).toBeNull(); + }); +}); + +describe('describeBagEffect', () => { + it('states what the bag carries and how much', () => { + expect( + describeBagEffect({ capacity: 5, lootCategory: LootCategory.RAIDER_TROPHY }), + ).toBe('Capacity: 5 Raider Trophies'); + }); + + it('uses the plural label of the category', () => { + expect( + describeBagEffect({ capacity: 5, lootCategory: LootCategory.HIDE }), + ).toBe('Capacity: 5 Hides'); + }); +}); diff --git a/apps/api/src/shops/offer-presentation.ts b/apps/api/src/shops/offer-presentation.ts new file mode 100644 index 0000000..8181402 --- /dev/null +++ b/apps/api/src/shops/offer-presentation.ts @@ -0,0 +1,104 @@ +import type { ConditionOutcome } from '../conditions/game-condition.service'; +import { GameConditionType } from '../conditions/game-condition.types'; +import { LootCategory } from '../items/loot-category.enum'; + +/** + * One requirement as the player reads it (Playable Slice 0.8.5 §5, §8). + * + * `current` is what makes a locked offer a goal rather than a wall: seeing + * "Requires 25 / Current 14" tells the player how much further to go. + */ +export interface ShopOfferRequirementDto { + label: string; + current: number | null; + required: number | null; + met: boolean; +} + +/** Plural, because a capacity counts things. Mirrors the web strip's labels. */ +const CATEGORY_LABELS: Readonly> = { + [LootCategory.HIDE]: 'Hides', + [LootCategory.RAIDER_TROPHY]: 'Raider Trophies', +}; + +/** + * Turns one evaluated condition into a player-facing line, or into nothing. + * + * Only the two gate types slice §8 names are rendered. Everything else -- + * dialogue flags above all -- returns null: the offer still shows as locked, + * but an internal gate is not announced, and a quest flag named on a shop row + * would spoil the Slice 0.9 tutorial before the quest exists. + */ +export function describeRequirement( + outcome: ConditionOutcome, + factionNames: ReadonlyMap, +): ShopOfferRequirementDto | null { + const { condition, met, actual } = outcome; + const required = Number(condition.value); + if (!Number.isFinite(required)) { + return null; + } + + switch (condition.type) { + case GameConditionType.REGION_REPUTATION: { + if (!condition.key) { + return null; + } + // The key is a poor label, but a blank is worse -- a gate on a faction + // that is not seeded yet should still read as something. + const faction = factionNames.get(condition.key) ?? condition.key; + return { + label: `Requires ${faction} Reputation ${required}`, + current: actual, + required, + met, + }; + } + case GameConditionType.WORLD_RENOWN: + return { + label: `Requires World Renown ${required}`, + current: actual, + required, + met, + }; + default: + return null; + } +} + +/** + * What an item does, in one line (slice §8 "relevant effect"). + * + * Stats only. The flavour text is already carried separately, and repeating it + * here would push the actual numbers off the row. + */ +export function describeItemEffect(item: { + weaponDamage: number; + bonusAttack: number; + bonusHp: number; + bonusArmor: number; +}): string | null { + const parts: string[] = []; + if (item.weaponDamage > 0) { + parts.push(`${item.weaponDamage} Weapon Damage`); + } + if (item.bonusAttack > 0) { + parts.push(`+${item.bonusAttack} Attack`); + } + if (item.bonusHp > 0) { + parts.push(`+${item.bonusHp} HP`); + } + if (item.bonusArmor > 0) { + parts.push(`+${item.bonusArmor} Armor`); + } + return parts.length > 0 ? parts.join(', ') : null; +} + +/** What a bag does: one category, one capacity (Slice 0.7.5 §6). */ +export function describeBagEffect(bag: { + capacity: number; + lootCategory: LootCategory; +}): string { + const label = CATEGORY_LABELS[bag.lootCategory] ?? bag.lootCategory; + return `Capacity: ${bag.capacity} ${label}`; +} From 7e1b79315ede49d6e1714964b58442b00e9c71f3 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 18:18:44 +0200 Subject: [PATCH 05/18] test(shops): cover non-finite and missing-key guards in describeRequirement --- apps/api/src/shops/offer-presentation.spec.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/api/src/shops/offer-presentation.spec.ts b/apps/api/src/shops/offer-presentation.spec.ts index 3d85155..4daafb5 100644 --- a/apps/api/src/shops/offer-presentation.spec.ts +++ b/apps/api/src/shops/offer-presentation.spec.ts @@ -94,6 +94,47 @@ describe('describeRequirement', () => { ), ).toBeNull(); }); + + it('renders no line if the condition has a non-numeric value', () => { + // A requirement with no valid threshold is unactionable -- the player + // has no goal to work toward. Better to hide the gate than to render + // a malformed line. + expect( + describeRequirement( + { + condition: { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: NaN, + }, + met: false, + actual: 14, + }, + FACTIONS, + ), + ).toBeNull(); + }); + + it('renders no line for a reputation gate with no faction key', () => { + // A gate that names no faction is malformed. The player cannot act on a + // requirement that never says which faction. Better to show nothing than + // broken content. + expect( + describeRequirement( + { + condition: { + type: GameConditionType.REGION_REPUTATION, + operator: ComparisonOperator.GTE, + value: 25, + } as any, + met: false, + actual: 14, + }, + FACTIONS, + ), + ).toBeNull(); + }); }); describe('describeItemEffect', () => { From 7a5e800a185e45e2084dfd44d407cbb38a8d5259 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 18:34:24 +0200 Subject: [PATCH 06/18] feat(shops): sell loot bags, honour bypass conditions, explain locks --- apps/api/src/shops/shop.errors.ts | 30 +++ apps/api/src/shops/shop.service.spec.ts | 300 ++++++++++++++++++++++-- apps/api/src/shops/shop.service.ts | 250 +++++++++++++++++--- apps/api/src/shops/shops.module.ts | 13 +- 4 files changed, 539 insertions(+), 54 deletions(-) diff --git a/apps/api/src/shops/shop.errors.ts b/apps/api/src/shops/shop.errors.ts index 7431145..55aeacd 100644 --- a/apps/api/src/shops/shop.errors.ts +++ b/apps/api/src/shops/shop.errors.ts @@ -5,6 +5,8 @@ export type ShopErrorCode = | 'SHOP_DISABLED' | 'SHOP_OFFER_NOT_FOUND' | 'SHOP_OFFER_LOCKED' + | 'MERCHANT_REPUTATION_TOO_LOW' + | 'SHOP_BAG_ALREADY_OWNED' | 'SHOP_INVALID_QUANTITY' | 'SHOP_INSUFFICIENT_SILVER'; @@ -57,6 +59,34 @@ export function shopOfferLocked(): ShopDomainError { ); } +/** + * The specific case of `SHOP_OFFER_LOCKED` where reputation is what is short + * (Playable Slice 0.8.5 §6). + * + * Added alongside the generic code rather than replacing it: a gate on a quest + * flag or an item is still `SHOP_OFFER_LOCKED`, and telling a player to earn + * reputation they do not need would be worse than saying nothing specific. + */ +export function merchantReputationTooLow(): ShopDomainError { + return new ShopDomainError( + 'MERCHANT_REPUTATION_TOO_LOW', + HttpStatus.FORBIDDEN, + 'You have not earned enough standing for this yet.', + ); +} + +/** + * Only the roomiest active bag per category counts (Slice 0.7.5 §6), so a + * second copy grants nothing. Selling one would be taking Silver for nothing. + */ +export function shopBagAlreadyOwned(): ShopDomainError { + return new ShopDomainError( + 'SHOP_BAG_ALREADY_OWNED', + HttpStatus.CONFLICT, + 'You already carry that.', + ); +} + export function shopInvalidQuantity(): ShopDomainError { return new ShopDomainError( 'SHOP_INVALID_QUANTITY', diff --git a/apps/api/src/shops/shop.service.spec.ts b/apps/api/src/shops/shop.service.spec.ts index 8b8b984..ca7b524 100644 --- a/apps/api/src/shops/shop.service.spec.ts +++ b/apps/api/src/shops/shop.service.spec.ts @@ -1,8 +1,17 @@ import { DataSource, EntityManager } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; import { GameConditionService } from '../conditions/game-condition.service'; +import { + ComparisonOperator, + GameCondition, + GameConditionType, +} from '../conditions/game-condition.types'; import { CharacterItem } from '../items/entities/character-item.entity'; +import { LootCategory } from '../items/loot-category.enum'; +import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity'; +import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity'; import { NpcService } from '../npcs/npc.service'; +import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; import { NpcShop } from './entities/npc-shop.entity'; import { ShopOffer } from './entities/shop-offer.entity'; import { ShopService } from './shop.service'; @@ -18,6 +27,16 @@ interface Fixture { offerRepeatable?: boolean; offerQuantity?: number; ownedPotions?: number | null; + /** Replaces the single item offer with one that sells the trophy pouch. */ + bagOffer?: boolean; + /** Whether the character already holds the bag the offer sells. */ + ownsBag?: boolean; + /** Conditions on the offer, so a test can gate it on reputation. */ + conditions?: GameCondition[]; + /** The alternative way in (slice §7). */ + bypassConditions?: GameCondition[]; + /** Which of `conditions` / `bypassConditions` the fake engine says hold. */ + bypassPasses?: boolean; } function createWorld(fixture: Fixture = {}) { @@ -36,26 +55,61 @@ function createWorld(fixture: Fixture = {}) { quantity: fixture.ownedPotions, }; + const bag = { + id: 'bag-trophy-pouch', + key: 'basic-trophy-pouch', + name: 'Basic Trophy Pouch', + lootCategory: LootCategory.RAIDER_TROPHY, + capacity: 5, + iconPath: '/images/items/basic-trophy-pouch.png', + }; + + const grantedBags: Array> = []; + const offers = [ - { - id: 'offer-1', - shopId: 'shop-1', - itemDefinitionId: 'item-potion', - currencyType: 'SILVER', - price: 12, - quantity: fixture.offerQuantity ?? 1, - repeatable: fixture.offerRepeatable ?? true, - sortOrder: 1, - conditions: [], - enabled: true, - itemDefinition: { - id: 'item-potion', - key: 'small-healing-potion', - name: 'Small Healing Potion', - description: 'A bitter draught.', - iconPath: '/images/items/potion.png', - }, - }, + fixture.bagOffer + ? { + id: 'offer-bag', + shopId: 'shop-1', + itemDefinitionId: null, + lootBagDefinitionId: bag.id, + currencyType: 'SILVER', + price: 40, + quantity: 1, + repeatable: false, + sortOrder: 1, + conditions: fixture.conditions ?? [], + bypassConditions: fixture.bypassConditions ?? [], + enabled: true, + itemDefinition: null, + lootBagDefinition: bag, + } + : { + id: 'offer-1', + shopId: 'shop-1', + itemDefinitionId: 'item-potion', + lootBagDefinitionId: null, + currencyType: 'SILVER', + price: 12, + quantity: fixture.offerQuantity ?? 1, + repeatable: fixture.offerRepeatable ?? true, + sortOrder: 1, + conditions: fixture.conditions ?? [], + bypassConditions: fixture.bypassConditions ?? [], + enabled: true, + itemDefinition: { + id: 'item-potion', + key: 'small-healing-potion', + name: 'Small Healing Potion', + description: 'A bitter draught.', + iconPath: '/images/items/potion.png', + weaponDamage: 0, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + }, + lootBagDefinition: null, + }, ] as unknown as ShopOffer[]; const repositories = (entity: unknown) => { @@ -88,9 +142,35 @@ function createWorld(fixture: Fixture = {}) { return { findOne: () => Promise.resolve(owned), create: (row: Record) => row, - save: async (row: Record) => { + save: (row: Record) => { grantedItems.push(row); - return row; + return Promise.resolve(row); + }, + }; + } + if (entity === ReputationFaction) { + return { + find: () => + Promise.resolve([ + { id: 'faction-1', key: 'border-guard', name: 'Border Watch' }, + ]), + }; + } + if (entity === LootBagDefinition) { + return { findOneBy: () => Promise.resolve(bag) }; + } + if (entity === CharacterLootBag) { + return { + findOne: () => + Promise.resolve( + fixture.ownsBag + ? { characterId: CHARACTER_ID, lootBagDefinitionId: bag.id } + : null, + ), + create: (row: Record) => row, + save: (row: Record) => { + grantedBags.push(row); + return Promise.resolve(row); }, }; } @@ -105,7 +185,27 @@ function createWorld(fixture: Fixture = {}) { } as unknown as DataSource; const conditions = { - evaluate: jest.fn(() => Promise.resolve(fixture.offerUnlocked ?? true)), + evaluate: jest.fn( + (_context: unknown, list: GameCondition[] | undefined) => { + // The fixture distinguishes the two lists by identity, so a test can say + // "the gate is shut but the bypass is open". + if (list === fixture.bypassConditions) { + return Promise.resolve(fixture.bypassPasses ?? false); + } + // `offerUnlocked` is the fixture's switch for the offer's own gate, + // whatever the conditions expressing it happen to be. + return Promise.resolve(fixture.offerUnlocked ?? true); + }, + ), + describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) => + Promise.resolve( + (list ?? []).map((condition) => ({ + condition, + met: fixture.offerUnlocked ?? true, + actual: 14, + })), + ), + ), } as unknown as GameConditionService; const npcs = { @@ -116,6 +216,7 @@ function createWorld(fixture: Fixture = {}) { service: new ShopService(dataSource, conditions, npcs), character, grantedItems, + grantedBags, owned, }; } @@ -261,4 +362,159 @@ describe('ShopService', () => { expect(result.quantity).toBe(6); expect(result.silverSpent).toBe(24); }); + + it('shows the requirement and the current value on a locked offer', async () => { + const world = createWorld({ + offerUnlocked: false, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + ], + }); + + const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY); + + // Locked, but still listed: a visible reward is a goal (slice §5). + expect(view.offers).toHaveLength(1); + expect(view.offers[0].unlocked).toBe(false); + expect(view.offers[0].requirements).toEqual([ + { + label: 'Requires Border Watch Reputation 25', + current: 14, + required: 25, + met: false, + }, + ]); + }); + + it('lists a bag offer with its capacity as the effect', async () => { + const world = createWorld({ bagOffer: true, silver: 100 }); + + const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY); + + expect(view.offers[0]).toMatchObject({ + itemKey: 'basic-trophy-pouch', + itemName: 'Basic Trophy Pouch', + price: 40, + effectSummary: 'Capacity: 5 Raider Trophies', + unlocked: true, + }); + }); + + it('grants a bag rather than stacking it as an item', async () => { + const world = createWorld({ bagOffer: true, silver: 100 }); + + const result = await world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ); + + expect(result.silverSpent).toBe(40); + expect(world.grantedItems).toHaveLength(0); + expect(world.grantedBags[0]).toMatchObject({ + characterId: CHARACTER_ID, + lootBagDefinitionId: 'bag-trophy-pouch', + active: true, + }); + }); + + it('refuses to sell a bag the character already carries', async () => { + // A second copy grants nothing (only the roomiest active bag per category + // counts) so charging for it would be taking Silver for nothing. + const world = createWorld({ bagOffer: true, ownsBag: true, silver: 100 }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ), + ).rejects.toMatchObject({ code: 'SHOP_BAG_ALREADY_OWNED' }); + + expect(world.character.silver).toBe(100); + }); + + it('names reputation as the reason when a reputation gate is what blocks', async () => { + const world = createWorld({ + offerUnlocked: false, + silver: 1000, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + ], + }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'small-healing-potion', + 1, + ), + ).rejects.toMatchObject({ code: 'MERCHANT_REPUTATION_TOO_LOW' }); + }); + + it('opens an offer whose bypass holds even though its conditions do not', async () => { + // The Slice 0.9 referral: the warden's word is worth more than the + // reputation the player has not earned yet (slice §7). + const bypassConditions: GameCondition[] = [ + { + type: GameConditionType.FLAG_SET, + key: 'referred-by-south-gate-warden', + value: true, + }, + ]; + const world = createWorld({ + bagOffer: true, + silver: 100, + offerUnlocked: false, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 40, + }, + ], + bypassConditions, + bypassPasses: true, + }); + + const result = await world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ); + + expect(result.silverSpent).toBe(40); + expect(world.grantedBags).toHaveLength(1); + }); + + it('still charges the price when a requirement is met', async () => { + // Reputation opens the offer; it does not pay for it (slice §10). + const world = createWorld({ bagOffer: true, silver: 10 }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ), + ).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' }); + + expect(world.grantedBags).toHaveLength(0); + }); }); diff --git a/apps/api/src/shops/shop.service.ts b/apps/api/src/shops/shop.service.ts index 654f762..81f40e5 100644 --- a/apps/api/src/shops/shop.service.ts +++ b/apps/api/src/shops/shop.service.ts @@ -1,13 +1,24 @@ import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, EntityManager } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; import { GameConditionService } from '../conditions/game-condition.service'; +import { GameConditionType } from '../conditions/game-condition.types'; import { CharacterItem } from '../items/entities/character-item.entity'; +import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity'; import { NpcService } from '../npcs/npc.service'; +import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; import { NpcShop } from './entities/npc-shop.entity'; import { ShopOffer } from './entities/shop-offer.entity'; +import { + describeBagEffect, + describeItemEffect, + describeRequirement, + ShopOfferRequirementDto, +} from './offer-presentation'; import { characterNotFound, + merchantReputationTooLow, + shopBagAlreadyOwned, shopDisabled, shopInsufficientSilver, shopInvalidQuantity, @@ -26,12 +37,34 @@ export interface ShopOfferDto { currencyType: string; price: number; quantity: number; + /** What buying it does: weapon stats, or a bag's capacity (slice §8). */ + effectSummary: string | null; + /** Why it is locked, and how close the player is (slice §5). Empty when open. */ + requirements: ShopOfferRequirementDto[]; /** False when the offer's conditions are not met (Slice 0.8.5 content). */ unlocked: boolean; /** True when the character simply cannot afford an otherwise open offer. */ affordable: boolean; } +/** + * What an offer sells, flattened out of whichever target it points at. + * + * Both shapes reduce to a key, a name, an icon and an effect, which is all the + * presentation layer needs -- and `kind` plus `definitionId` is all the grant + * needs, so the branch on offer kind lives in one place instead of being spread + * across the view and the purchase path. + */ +interface OfferTarget { + kind: 'item' | 'bag'; + definitionId: string; + key: string; + name: string; + description: string; + iconPath: string; + effectSummary: string | null; +} + export interface ShopViewDto { shopKey: string; shopName: string; @@ -79,31 +112,53 @@ export class ShopService { const offers = await this.dataSource.getRepository(ShopOffer).find({ where: { shopId: shop.id, enabled: true }, - relations: { itemDefinition: true }, + relations: { itemDefinition: true, lootBagDefinition: true }, order: { sortOrder: 'ASC' }, }); + // One read for the whole view: every reputation requirement needs a + // display name, and offers commonly gate on the same faction. + const factionNames = new Map( + (await this.dataSource.getRepository(ReputationFaction).find()).map( + (faction) => [faction.key, faction.name], + ), + ); + + const context = { characterId, npcId }; const view: ShopOfferDto[] = []; for (const offer of offers) { - const unlocked = await this.conditions.evaluate( - { characterId, npcId }, + const target = this.resolveTarget(offer); + if (!target) { + continue; + } + + const gate = await this.evaluateGate(context, offer); + // Described even when open, so the UI can show a requirement the player + // has already met rather than having it vanish on unlock. Only + // `conditions` are described: a bypass is content's private exception, + // not something the player is told to go and satisfy. + const outcomes = await this.conditions.describe( + context, offer.conditions, ); + const requirements = outcomes + .map((outcome) => describeRequirement(outcome, factionNames)) + .filter( + (requirement): requirement is ShopOfferRequirementDto => + requirement !== null, + ); - // TEMPORARY (Task 1 of Slice 0.8.5): `itemDefinition` became nullable - // when the offer table gained a loot-bag target alongside the item one. - // Every offer sold an item until Task 4 adds bag offers here, so the - // assertion is safe for now -- Task 4 replaces it with a real branch on - // offer kind. view.push({ - itemKey: offer.itemDefinition!.key, - itemName: offer.itemDefinition!.name, - itemDescription: offer.itemDefinition!.description, - iconPath: offer.itemDefinition!.iconPath, + itemKey: target.key, + itemName: target.name, + itemDescription: target.description, + iconPath: target.iconPath, currencyType: offer.currencyType, price: offer.price, quantity: offer.quantity, - unlocked, + effectSummary: target.effectSummary, + requirements, + unlocked: gate.open, affordable: character.silver >= offer.price, }); } @@ -146,51 +201,80 @@ export class ShopService { } // Matched on the joined definition's business key: the offer table is - // keyed by item definition id, while the request carries the stable key. + // keyed by definition id, while the request carries the stable key. const offers = await manager.getRepository(ShopOffer).find({ where: { shopId: shop.id, enabled: true }, - relations: { itemDefinition: true }, + relations: { itemDefinition: true, lootBagDefinition: true }, }); - const match = offers.find( - (candidate) => candidate.itemDefinition!.key === itemKey, - ); + let match: ShopOffer | undefined; + let target: OfferTarget | undefined; + for (const candidate of offers) { + const candidateTarget = this.resolveTarget(candidate); + if (candidateTarget?.key === itemKey) { + match = candidate; + target = candidateTarget; + break; + } + } - if (!match) { + if (!match || !target) { throw shopOfferNotFound(); } - const unlocked = await this.conditions.evaluate( + const gate = await this.evaluateGate( { characterId, npcId }, - match.conditions, + match, manager, ); - if (!unlocked) { - throw shopOfferLocked(); + if (!gate.open) { + throw gate.reputationBlocked + ? merchantReputationTooLow() + : shopOfferLocked(); } if (!match.repeatable && quantity > 1) { throw shopInvalidQuantity(); } + // A bag is one object, not a stack: buying two grants nothing extra. + if (target.kind === 'bag' && quantity > 1) { + throw shopInvalidQuantity(); + } const silverSpent = match.price * quantity; if (character.silver < silverSpent) { throw shopInsufficientSilver(); } + // Checked before the debit: the transaction would roll the Silver back + // anyway, but failing on the cheap read keeps the error honest about + // what went wrong. + if (target.kind === 'bag') { + const owned = await manager.getRepository(CharacterLootBag).findOne({ + where: { characterId, lootBagDefinitionId: target.definitionId }, + }); + if (owned) { + throw shopBagAlreadyOwned(); + } + } + character.silver -= silverSpent; await characters.save(character); - await this.grantItem( - manager, - characterId, - match.itemDefinitionId!, - match.quantity * quantity, - ); + if (target.kind === 'bag') { + await this.grantLootBag(manager, characterId, target.definitionId); + } else { + await this.grantItem( + manager, + characterId, + target.definitionId, + match.quantity * quantity, + ); + } return { shopKey: shop.key, itemKey, - itemName: match.itemDefinition!.name, + itemName: target.name, quantity: match.quantity * quantity, silverSpent, silverBalance: character.silver, @@ -198,6 +282,110 @@ export class ShopService { }); } + /** + * What an offer sells, whichever kind of thing that is. + * + * The two targets are mutually exclusive by `CHK_shop_offers_single_target`, + * so resolving them here lets the view and the purchase path agree on what an + * offer *is* without either of them branching on offer kind itself. + */ + private resolveTarget(offer: ShopOffer): OfferTarget | null { + if (offer.itemDefinition) { + return { + kind: 'item', + definitionId: offer.itemDefinition.id, + key: offer.itemDefinition.key, + name: offer.itemDefinition.name, + description: offer.itemDefinition.description, + iconPath: offer.itemDefinition.iconPath, + effectSummary: describeItemEffect(offer.itemDefinition), + }; + } + if (offer.lootBagDefinition) { + return { + kind: 'bag', + definitionId: offer.lootBagDefinition.id, + key: offer.lootBagDefinition.key, + name: offer.lootBagDefinition.name, + // A bag definition carries no flavour text of its own; the capacity + // line is the honest description of what it is. + description: describeBagEffect(offer.lootBagDefinition), + iconPath: offer.lootBagDefinition.iconPath, + effectSummary: describeBagEffect(offer.lootBagDefinition), + }; + } + // CHK_shop_offers_single_target makes this unreachable through the + // database. Skipping the row beats rendering an offer that sells nothing. + return null; + } + + /** + * Whether an offer is open, and why not when it is shut. + * + * `conditions` OR `bypassConditions` -- the whole exception model (slice §7). + * A referral does not lower the requirement; it provides a second, narrower + * door that content opens deliberately. + */ + private async evaluateGate( + context: { characterId: string; npcId: string }, + offer: ShopOffer, + manager?: EntityManager, + ): Promise<{ open: boolean; reputationBlocked: boolean }> { + if (await this.conditions.evaluate(context, offer.conditions, manager)) { + return { open: true, reputationBlocked: false }; + } + + const bypass = offer.bypassConditions ?? []; + if ( + bypass.length > 0 && + (await this.conditions.evaluate(context, bypass, manager)) + ) { + return { open: true, reputationBlocked: false }; + } + + // Which error to raise depends on what is actually short, so the player is + // told to earn reputation only when reputation is the thing missing. + const outcomes = await this.conditions.describe( + context, + offer.conditions, + manager, + ); + const reputationBlocked = outcomes.some( + (outcome) => + !outcome.met && + outcome.condition.type === GameConditionType.REGION_REPUTATION, + ); + + return { open: false, reputationBlocked }; + } + + /** + * Hands over a bag, once. + * + * A second copy of the same bag grants nothing -- only the roomiest active + * bag per category counts (Slice 0.7.5 §6) -- so a repeat purchase is + * refused rather than silently charged. The unique index on + * (character_id, loot_bag_definition_id) is the real guarantee; this check + * is what turns a constraint violation into an explainable domain error. + */ + private async grantLootBag( + manager: { getRepository: DataSource['getRepository'] }, + characterId: string, + lootBagDefinitionId: string, + ): Promise { + const bags = manager.getRepository(CharacterLootBag); + const existing = await bags.findOne({ + where: { characterId, lootBagDefinitionId }, + }); + if (existing) { + throw shopBagAlreadyOwned(); + } + + await bags.save( + bags.create({ characterId, lootBagDefinitionId, active: true }), + ); + } + /** * Adds to an existing stack or starts a new one. * diff --git a/apps/api/src/shops/shops.module.ts b/apps/api/src/shops/shops.module.ts index 8647f2a..e126d9f 100644 --- a/apps/api/src/shops/shops.module.ts +++ b/apps/api/src/shops/shops.module.ts @@ -3,7 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Character } from '../characters/entities/character.entity'; import { ConditionsModule } from '../conditions/conditions.module'; import { CharacterItem } from '../items/entities/character-item.entity'; +import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity'; +import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity'; import { NpcsModule } from '../npcs/npcs.module'; +import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; import { NpcShop } from './entities/npc-shop.entity'; import { ShopOffer } from './entities/shop-offer.entity'; import { ShopController } from './shop.controller'; @@ -12,7 +15,15 @@ import { ShopService } from './shop.service'; /** Buying things for Silver (NPC spec §15, §29). */ @Module({ imports: [ - TypeOrmModule.forFeature([Character, CharacterItem, NpcShop, ShopOffer]), + TypeOrmModule.forFeature([ + Character, + CharacterItem, + CharacterLootBag, + LootBagDefinition, + NpcShop, + ReputationFaction, + ShopOffer, + ]), ConditionsModule, NpcsModule, ], From 80be829309885472efac273fbcc51573238fe273 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 18:52:23 +0200 Subject: [PATCH 07/18] test(shops): make the gate's guards fail when they are removed --- apps/api/src/shops/shop.service.spec.ts | 169 ++++++++++++++++++++++-- 1 file changed, 160 insertions(+), 9 deletions(-) diff --git a/apps/api/src/shops/shop.service.spec.ts b/apps/api/src/shops/shop.service.spec.ts index ca7b524..520ff9e 100644 --- a/apps/api/src/shops/shop.service.spec.ts +++ b/apps/api/src/shops/shop.service.spec.ts @@ -23,22 +23,44 @@ interface Fixture { silver?: number; shopEnabled?: boolean; hasShop?: boolean; + /** The fake engine's blanket answer for every condition it is handed. */ offerUnlocked?: boolean; + /** + * Condition types the fake engine says do *not* hold, whatever + * `offerUnlocked` says. Lets one list mix a met requirement with an unmet + * one, which is what distinguishes "reputation is short" from "something + * else is". + */ + unmetConditionTypes?: GameConditionType[]; offerRepeatable?: boolean; offerQuantity?: number; ownedPotions?: number | null; /** Replaces the single item offer with one that sells the trophy pouch. */ bagOffer?: boolean; + /** Bag offers are one-off by nature; a test can lift that to reach the + * bag-specific quantity rule underneath it. */ + bagRepeatable?: boolean; /** Whether the character already holds the bag the offer sells. */ ownsBag?: boolean; /** Conditions on the offer, so a test can gate it on reputation. */ conditions?: GameCondition[]; /** The alternative way in (slice §7). */ bypassConditions?: GameCondition[]; - /** Which of `conditions` / `bypassConditions` the fake engine says hold. */ + /** Whether the fake engine says the bypass list holds. */ bypassPasses?: boolean; } +/** + * What the fake engine reports as measured, per condition type. + * + * Only conditions with a scale have one: a flag is set or it is not, and the + * real engine reports `actual: null` for it. + */ +const FAKE_MEASURED_VALUE: Partial> = { + [GameConditionType.REGION_REPUTATION]: 14, + [GameConditionType.WORLD_RENOWN]: 14, +}; + function createWorld(fixture: Fixture = {}) { const character = { id: CHARACTER_ID, @@ -76,7 +98,7 @@ function createWorld(fixture: Fixture = {}) { currencyType: 'SILVER', price: 40, quantity: 1, - repeatable: false, + repeatable: fixture.bagRepeatable ?? false, sortOrder: 1, conditions: fixture.conditions ?? [], bypassConditions: fixture.bypassConditions ?? [], @@ -184,25 +206,40 @@ function createWorld(fixture: Fixture = {}) { run(manager), } as unknown as DataSource; + /** One condition's verdict, so `evaluate` and `describe` cannot disagree. */ + const conditionHolds = (condition: GameCondition) => { + if (fixture.unmetConditionTypes?.includes(condition.type)) { + return false; + } + return fixture.offerUnlocked ?? true; + }; + const conditions = { evaluate: jest.fn( (_context: unknown, list: GameCondition[] | undefined) => { + // Mirrors the real engine (game-condition.service.ts): an AND over + // nothing holds, so an empty list is not a gate at all. Keeping this + // faithful is what makes the service's `bypass.length > 0` guard + // load-bearing rather than decorative. + if (!list || list.length === 0) { + return Promise.resolve(true); + } // The fixture distinguishes the two lists by identity, so a test can say // "the gate is shut but the bypass is open". if (list === fixture.bypassConditions) { return Promise.resolve(fixture.bypassPasses ?? false); } - // `offerUnlocked` is the fixture's switch for the offer's own gate, - // whatever the conditions expressing it happen to be. - return Promise.resolve(fixture.offerUnlocked ?? true); + // Answered from the list's own contents, so handing the gate the wrong + // list cannot pass unnoticed. + return Promise.resolve(list.every(conditionHolds)); }, ), describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) => Promise.resolve( (list ?? []).map((condition) => ({ condition, - met: fixture.offerUnlocked ?? true, - actual: 14, + met: conditionHolds(condition), + actual: FAKE_MEASURED_VALUE[condition.type] ?? null, })), ), ), @@ -291,8 +328,19 @@ describe('ShopService', () => { it('refuses a locked offer even when the request asks for it directly', async () => { // The gate is enforced server-side, so hiding it in the UI is not the // protection (NPC spec §33: "gesperrtes Item kann nicht direkt über API - // gekauft werden"). - const world = createWorld({ offerUnlocked: false, silver: 1000 }); + // gekauft werden"). Gated on a flag rather than reputation, because a + // non-reputation gate is what keeps the generic code in play. + const world = createWorld({ + offerUnlocked: false, + silver: 1000, + conditions: [ + { + type: GameConditionType.FLAG_SET, + key: 'vouched-for-by-the-warden', + value: true, + }, + ], + }); await expect( world.service.purchase( @@ -304,6 +352,37 @@ describe('ShopService', () => { ).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' }); expect(world.character.silver).toBe(1000); + expect(world.grantedItems).toHaveLength(0); + }); + + it('keeps an offer shut when its bypass list is empty', async () => { + // An empty condition list is an AND over nothing, so it *holds*. Without + // the service's emptiness guard, every offer in the database -- whose + // `bypass_conditions` default to '[]' -- would open (slice §7). + const world = createWorld({ + offerUnlocked: false, + silver: 1000, + conditions: [ + { + type: GameConditionType.FLAG_SET, + key: 'vouched-for-by-the-warden', + value: true, + }, + ], + bypassConditions: [], + }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'small-healing-potion', + 1, + ), + ).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' }); + + expect(world.character.silver).toBe(1000); + expect(world.grantedItems).toHaveLength(0); }); it('refuses an item the shop does not stock', async () => { @@ -312,6 +391,9 @@ describe('ShopService', () => { await expect( world.service.purchase(CHARACTER_ID, MERCHANT_KEY, 'ash-blade', 1), ).rejects.toMatchObject({ code: 'SHOP_OFFER_NOT_FOUND' }); + + expect(world.character.silver).toBe(100); + expect(world.grantedItems).toHaveLength(0); }); it('refuses a non-positive quantity', async () => { @@ -325,6 +407,9 @@ describe('ShopService', () => { 0, ), ).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' }); + + expect(world.character.silver).toBe(100); + expect(world.grantedItems).toHaveLength(0); }); it('refuses to buy a one-off offer more than once in a request', async () => { @@ -338,6 +423,9 @@ describe('ShopService', () => { 2, ), ).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' }); + + expect(world.character.silver).toBe(100); + expect(world.grantedItems).toHaveLength(0); }); it('refuses a closed shop', async () => { @@ -439,6 +527,30 @@ describe('ShopService', () => { ).rejects.toMatchObject({ code: 'SHOP_BAG_ALREADY_OWNED' }); expect(world.character.silver).toBe(100); + expect(world.grantedBags).toHaveLength(0); + }); + + it('refuses to sell two of the same bag in one request', async () => { + // A bag is one object, not a stack: the second grants nothing. The offer + // is made repeatable here so the bag rule is what answers, not the + // one-off rule that normally sits in front of it. + const world = createWorld({ + bagOffer: true, + bagRepeatable: true, + silver: 1000, + }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 2, + ), + ).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' }); + + expect(world.character.silver).toBe(1000); + expect(world.grantedBags).toHaveLength(0); }); it('names reputation as the reason when a reputation gate is what blocks', async () => { @@ -463,6 +575,44 @@ describe('ShopService', () => { 1, ), ).rejects.toMatchObject({ code: 'MERCHANT_REPUTATION_TOO_LOW' }); + + expect(world.character.silver).toBe(1000); + expect(world.grantedItems).toHaveLength(0); + }); + + it('does not blame reputation when reputation is not what is short', async () => { + // The standing is already earned; a quest flag is the thing missing. + // Telling this player to go and earn reputation would send them after + // something they already have (slice §6). + const world = createWorld({ + silver: 1000, + unmetConditionTypes: [GameConditionType.FLAG_SET], + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 10, + }, + { + type: GameConditionType.FLAG_SET, + key: 'vouched-for-by-the-warden', + value: true, + }, + ], + }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'small-healing-potion', + 1, + ), + ).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' }); + + expect(world.character.silver).toBe(1000); + expect(world.grantedItems).toHaveLength(0); }); it('opens an offer whose bypass holds even though its conditions do not', async () => { @@ -515,6 +665,7 @@ describe('ShopService', () => { ), ).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' }); + expect(world.character.silver).toBe(10); expect(world.grantedBags).toHaveLength(0); }); }); From 36adc32659e2a4a86dde3dc0e56f8db513d5b190 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 20:04:48 +0200 Subject: [PATCH 08/18] feat(content): gate the trophy pouch, hide bag and bandit blade --- apps/api/src/database/seeds/npc-content.ts | 126 ++++++++++++++++-- .../seeds/vertical-slice.seed.spec.ts | 73 ++++++++-- .../src/database/seeds/vertical-slice.seed.ts | 49 +++---- 3 files changed, 201 insertions(+), 47 deletions(-) diff --git a/apps/api/src/database/seeds/npc-content.ts b/apps/api/src/database/seeds/npc-content.ts index 3f48340..6636668 100644 --- a/apps/api/src/database/seeds/npc-content.ts +++ b/apps/api/src/database/seeds/npc-content.ts @@ -9,6 +9,10 @@ import type { DialogueResponseContent, } from '../../npcs/npc.types'; import { ITEM_IDS } from './item.constants'; +import { + BASIC_HIDE_BAG_ID, + BASIC_TROPHY_POUCH_ID, +} from './loot-bag-content'; import { BORDER_GUARD_FACTION_ID } from './reputation-content'; export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001'; @@ -19,6 +23,20 @@ export const BORIN_KEY = 'borin-quartermaster'; export const BORIN_SHOP_KEY = 'borin-supplies'; export const BORIN_EXCHANGE_KEY = 'borin-trade-in'; +// Stable offer ids so re-seeding re-tunes a price instead of inserting a +// second row (AGENTS.md §8). Needed here in particular because an offer has +// two possible targets and no single natural key across both shapes. +export const BORIN_OFFER_IDS = { + potion: 'b4000000-0000-4000-8000-000000000001', + shortsword: 'b4000000-0000-4000-8000-000000000002', + trophyPouch: 'b4000000-0000-4000-8000-000000000003', + hideBag: 'b4000000-0000-4000-8000-000000000004', + banditBlade: 'b4000000-0000-4000-8000-000000000005', +} as const; + +/** The flag the Slice 0.9 warden sets when she sends the player to Borin. */ +export const SOUTH_GATE_REFERRAL_FLAG = 'referred-by-south-gate-warden'; + /** * The Renown 2 milestone (Playable Slice 0.6.5 §6). * @@ -188,49 +206,139 @@ export const NPC_SHOPS: SeedNpcShop[] = [ ]; export interface SeedShopOffer { + id: string; shopId: string; - itemDefinitionId: string; + itemDefinitionId: string | null; + lootBagDefinitionId: string | null; currencyType: string; price: number; quantity: number; repeatable: boolean; sortOrder: number; conditions: GameCondition[]; + bypassConditions: GameCondition[]; enabled: boolean; } /** - * Deliberately thin (slice §12: "Avoid adding many new items just to populate - * the shop"). The point of 0.8 is trade-in; the shop exists so Silver has - * somewhere to go the moment it is earned. + * What Borin sells (Playable Slice 0.8 §12, Slice 0.8.5 §4). * - * No offer carries conditions yet -- reputation-gated offers are Slice 0.8.5 - * (slice §3). Loot bags are not sold here either: 0.8.5 §4 plans the Basic - * Hide Bag as its locked-offer example, and a bag is a `LootBagDefinition` - * rather than an `ItemDefinition`, so selling one needs an offer shape this - * slice has no reason to build. + * Two open offers so Silver always has somewhere to go, and three gated ones + * so reputation visibly changes what the player can do (0.8.5 §1). The locked + * offers stay listed rather than hidden: a reward you can see is a goal, and a + * reward you cannot see is nothing (0.8.5 §5). + * + * Thresholds are balancing data (0.8.5 §4). The exchange pays 2-12 reputation + * per trade good, so 25 is three or four good hunts away -- close enough to + * pull, far enough to matter -- and 40 is deliberately further out, which is + * what makes the Slice 0.9 referral read as a favour rather than a shortcut + * around nothing. + * + * The Bandit Blade sits behind World Renown 3, which today's content cannot + * reach: there is exactly one renown milestone, worth +1. That is intentional + * and not a balancing oversight -- it is the long-horizon goal on the shelf + * until Slice 0.11 adds the milestones that reach it. */ export const SHOP_OFFERS: SeedShopOffer[] = [ { + id: BORIN_OFFER_IDS.potion, shopId: BORIN_SHOP_ID, itemDefinitionId: ITEM_IDS['small-healing-potion'], + lootBagDefinitionId: null, currencyType: 'SILVER', price: 12, quantity: 1, repeatable: true, sortOrder: 1, conditions: [], + bypassConditions: [], enabled: true, }, { + id: BORIN_OFFER_IDS.shortsword, shopId: BORIN_SHOP_ID, itemDefinitionId: ITEM_IDS['worn-short-sword'], + lootBagDefinitionId: null, currencyType: 'SILVER', price: 30, quantity: 1, repeatable: true, sortOrder: 2, conditions: [], + bypassConditions: [], + enabled: true, + }, + { + // The first gate a player meets, and the one 0.8.5 §5 uses as its worked + // example: 40 Silver, Ashen Fields reputation 25. + id: BORIN_OFFER_IDS.trophyPouch, + shopId: BORIN_SHOP_ID, + itemDefinitionId: null, + lootBagDefinitionId: BASIC_TROPHY_POUCH_ID, + currencyType: 'SILVER', + price: 40, + quantity: 1, + repeatable: false, + sortOrder: 3, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + ], + bypassConditions: [], + enabled: true, + }, + { + // Reputation 40 is out of reach for a new character on purpose. Slice 0.9 + // sends the player here with the warden's word instead, which is the one + // exception the offer system supports (0.8.5 §7). + id: BORIN_OFFER_IDS.hideBag, + shopId: BORIN_SHOP_ID, + itemDefinitionId: null, + lootBagDefinitionId: BASIC_HIDE_BAG_ID, + currencyType: 'SILVER', + price: 35, + quantity: 1, + repeatable: false, + sortOrder: 4, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 40, + }, + ], + bypassConditions: [ + { + type: GameConditionType.FLAG_SET, + key: SOUTH_GATE_REFERRAL_FLAG, + value: true, + }, + ], + enabled: true, + }, + { + id: BORIN_OFFER_IDS.banditBlade, + shopId: BORIN_SHOP_ID, + itemDefinitionId: ITEM_IDS['bandit-blade'], + lootBagDefinitionId: null, + currencyType: 'SILVER', + price: 60, + quantity: 1, + repeatable: true, + sortOrder: 5, + conditions: [ + { + type: GameConditionType.WORLD_RENOWN, + operator: ComparisonOperator.GTE, + value: 3, + }, + ], + bypassConditions: [], enabled: true, }, ]; diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index e4e774d..3f19478 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -20,6 +20,7 @@ import { NpcDefinition } from '../../npcs/entities/npc-definition.entity'; import { NpcShop } from '../../shops/entities/npc-shop.entity'; import { ShopOffer } from '../../shops/entities/shop-offer.entity'; import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants'; +import { GameConditionType } from '../../conditions/game-condition.types'; import { ASH_RAT_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID, @@ -27,6 +28,8 @@ import { ROAD_BANDIT_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; +import { BASIC_HIDE_BAG_ID } from './loot-bag-content'; +import { SHOP_OFFERS } from './npc-content'; import { seedVisibleVerticalSlice } from './vertical-slice.seed'; type Row = Record; @@ -637,7 +640,7 @@ describe('seedVisibleVerticalSlice', () => { ); }); - it('gives the demo character one active bag per category, idempotently', async () => { + it('gives the demo character only the Hide Bag, idempotently', async () => { const characterLootBagRepository = new InMemoryRepository(); const dataSource = createDataSource( new InMemoryRepository(), @@ -659,20 +662,18 @@ describe('seedVisibleVerticalSlice', () => { await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource); - // Deliberate: 0.7.5 §7 leaves acquisition to the merchant slice, but with - // no merchant yet a bagless character could never empty a full bag. See - // the ASSUMPTION note in the seed. - expect(characterLootBagRepository.rows).toHaveLength(2); + // Slice 0.8.5 decision: the Trophy Pouch is now a reputation-gated offer, + // so handing it to the demo character for free would undercut the + // showcase. The Hide Bag stays -- without it, HIDE capacity would drop to + // the bagless default of 1 with no way to raise it before Slice 0.9 grants + // it through the warden's referral. See the ASSUMPTION note in the seed. + expect(characterLootBagRepository.rows).toHaveLength(1); expect(characterLootBagRepository.rows.map((row) => row.active)).toEqual([ true, - true, ]); expect( characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId), - ).toEqual([ - 'a0000000-0000-4000-8000-000000000001', - 'a0000000-0000-4000-8000-000000000002', - ]); + ).toEqual(['a0000000-0000-4000-8000-000000000001']); }); it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => { @@ -904,4 +905,56 @@ describe('seedVisibleVerticalSlice', () => { enabled: true, }); }); + + it('seeds two visibly locked bag offers and one renown-gated weapon', async () => { + const gated = SHOP_OFFERS.filter((offer) => offer.conditions.length > 0); + + expect(gated).toHaveLength(3); + expect( + gated.map((offer) => offer.conditions[0].type).sort(), + ).toEqual([ + GameConditionType.REGION_REPUTATION, + GameConditionType.REGION_REPUTATION, + GameConditionType.WORLD_RENOWN, + ]); + }); + + it('gives the Hide Bag a referral bypass for Slice 0.9', async () => { + const hideBag = SHOP_OFFERS.find( + (offer) => offer.lootBagDefinitionId === BASIC_HIDE_BAG_ID, + ); + + expect(hideBag?.bypassConditions).toEqual([ + { + type: GameConditionType.FLAG_SET, + key: 'referred-by-south-gate-warden', + value: true, + }, + ]); + }); + + it('sells every bag as a one-off', async () => { + // A bag is one object; a second copy raises no capacity. + for (const offer of SHOP_OFFERS.filter( + (candidate) => candidate.lootBagDefinitionId !== null, + )) { + expect(offer.repeatable).toBe(false); + } + }); + + it('gives every offer exactly one target', async () => { + for (const offer of SHOP_OFFERS) { + const targets = [offer.itemDefinitionId, offer.lootBagDefinitionId].filter( + (target) => target !== null, + ); + expect(targets).toHaveLength(1); + } + }); + + it('gives every offer a stable id so re-seeding cannot duplicate it', async () => { + const ids = SHOP_OFFERS.map((offer) => offer.id); + + expect(new Set(ids).size).toBe(ids.length); + expect(ids.every((id) => id.length === 36)).toBe(true); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index b7a0f47..f5be0f9 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -43,11 +43,7 @@ import { BURNED_ROAD_LOCAL_CONTENT, SOUTH_GATE_LOCAL_CONTENT, } from './local-location.content'; -import { - BASIC_HIDE_BAG_ID, - BASIC_TROPHY_POUCH_ID, - LOOT_BAG_DEFINITIONS, -} from './loot-bag-content'; +import { BASIC_HIDE_BAG_ID, LOOT_BAG_DEFINITIONS } from './loot-bag-content'; import { REPUTATION_FACTIONS } from './reputation-content'; import { DIALOGUE_NODES, @@ -362,33 +358,27 @@ export async function seedVisibleVerticalSlice( }); } - // ASSUMPTION (Playable Slice 0.7.5 §7 leaves acquisition to the merchant - // and quest slices, so nothing in 0.7.5 hands out a bag). + // ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag + // and no longer starts with the Trophy Pouch. // - // The demo character starts with both starter bags anyway. Without them the - // bagless default of 1 applies, and since the merchant that empties a bag - // only arrives in 0.8, the second kill of a hunt would leave its trade good - // behind forever -- the hunting loop would be unplayable between these two - // slices. Capacity 5 lets the fill-up actually be experienced. + // The pouch is now a reputation-gated offer (0.8.5 §4) and handing it over + // for free would make the slice's own showcase pointless. The Hide Bag stays + // until Slice 0.9 grants it through the warden's referral -- removing both + // now would drop HIDE capacity to the bagless default of 1 with no way to + // raise it, and the hunting loop would be unplayable in between. // - // Smallest reversible choice: two seed rows, no acquisition mechanism. - // Delete them once the merchant sells bags. + // Delete this block once 0.9 hands the Hide Bag over in the quest. const characterLootBagRepository = dataSource.getRepository(CharacterLootBag); - for (const lootBagDefinitionId of [ - BASIC_HIDE_BAG_ID, - BASIC_TROPHY_POUCH_ID, - ]) { - const existingBag = await characterLootBagRepository.findOneBy({ + const existingBag = await characterLootBagRepository.findOneBy({ + characterId: DEMO_CHARACTER_ID, + lootBagDefinitionId: BASIC_HIDE_BAG_ID, + }); + if (!existingBag) { + await characterLootBagRepository.insert({ characterId: DEMO_CHARACTER_ID, - lootBagDefinitionId, + lootBagDefinitionId: BASIC_HIDE_BAG_ID, + active: true, }); - if (!existingBag) { - await characterLootBagRepository.insert({ - characterId: DEMO_CHARACTER_ID, - lootBagDefinitionId, - active: true, - }); - } } // NPC content (NPC Specification V1 §32; Playable Slice 0.8). @@ -413,7 +403,10 @@ export async function seedVisibleVerticalSlice( await dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']); await npcShopRepository.upsert(NPC_SHOPS, ['key']); - await shopOfferRepository.upsert(SHOP_OFFERS, ['shopId', 'itemDefinitionId']); + // By id, not by (shop, item): an offer may now sell a bag instead of an + // item, so the old pair is null for half the rows and useless as a conflict + // target. Migration 1796 cleared the anonymous 0.8 rows for exactly this. + await shopOfferRepository.upsert(SHOP_OFFERS, ['id']); await npcExchangeProfileRepository.upsert(NPC_EXCHANGE_PROFILES, ['key']); await exchangeRuleRepository.upsert(EXCHANGE_RULES, [ 'profileId', From 00784094ab6b3cca720c13b2f86f320c303bca47 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 20:15:51 +0200 Subject: [PATCH 09/18] feat(web): show offer requirements, current progress and effects --- apps/web/src/app/core/api/game-api.models.ts | 15 ++ .../features/npc/merchant-page.component.html | 22 ++ .../features/npc/merchant-page.component.scss | 20 ++ .../npc/merchant-page.component.spec.ts | 205 ++++++++++++++++++ 4 files changed, 262 insertions(+) diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index 02066da..d7d1a6e 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -430,6 +430,14 @@ export interface ExchangeResult { capacities: LootCapacity[]; } +/** One gate on an offer, as the server phrased it (slice 0.8.5 §5, §8). */ +export interface ShopOfferRequirement { + label: string; + current: number | null; + required: number | null; + met: boolean; +} + export interface ShopOfferView { itemKey: string; itemName: string; @@ -438,6 +446,13 @@ export interface ShopOfferView { currencyType: string; price: number; quantity: number; + /** + * Optional so fixtures written before slice 0.8.5 (e.g. the trade-in store's + * own spec, out of this task's scope) keep compiling untouched. The server + * always sends both fields; the template treats a missing one as "none". + */ + effectSummary?: string | null; + requirements?: ShopOfferRequirement[]; unlocked: boolean; affordable: boolean; } diff --git a/apps/web/src/app/features/npc/merchant-page.component.html b/apps/web/src/app/features/npc/merchant-page.component.html index a024c04..6da1ea0 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.html +++ b/apps/web/src/app/features/npc/merchant-page.component.html @@ -222,6 +222,28 @@ {{ offer.itemDescription }} + @if (offer.effectSummary) { + {{ + offer.effectSummary + }} + } + @for ( + requirement of offer.requirements ?? []; + track requirement.label + ) { + + {{ requirement.label }} + @if (!requirement.met && requirement.current !== null) { + · Current: {{ requirement.current }} + } + + } {{ offer.price }} Silver +

+ } +
    @for (offer of shop.offers; track offer.itemKey) {
  • diff --git a/apps/web/src/app/features/npc/merchant-page.component.scss b/apps/web/src/app/features/npc/merchant-page.component.scss index e21acf0..9e6f792 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.scss +++ b/apps/web/src/app/features/npc/merchant-page.component.scss @@ -380,6 +380,17 @@ font-size: var(--ar-font-sm); } +/* A line, not a modal (slice 0.8.5 §9). */ +.shop-unlocked { + display: flex; + flex-wrap: wrap; + gap: var(--ar-space-2); + align-items: center; + margin: 0; + color: var(--ar-gold); + font-size: var(--ar-font-sm); +} + @media (max-width: 40rem) { .merchant__identity { grid-template-columns: 1fr; diff --git a/apps/web/src/app/features/npc/merchant.store.spec.ts b/apps/web/src/app/features/npc/merchant.store.spec.ts index 09fa08d..8e22aaf 100644 --- a/apps/web/src/app/features/npc/merchant.store.spec.ts +++ b/apps/web/src/app/features/npc/merchant.store.spec.ts @@ -6,6 +6,7 @@ import type { ExchangeResult, ExchangeView, NpcInteraction, + ShopOfferView, ShopView, } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; @@ -74,26 +75,35 @@ function exchangeView(overrides: Partial = {}): ExchangeView { }; } -function shopView(): ShopView { +/** A single shop offer, defaulting to the fields no test in this file varies. */ +function offer( + itemKey: string, + itemName: string, + unlocked: boolean, +): ShopOfferView { + return { + itemKey, + itemName, + itemDescription: 'A bitter draught.', + iconPath: '/images/items/potion.png', + currencyType: 'SILVER', + price: 12, + quantity: 1, + effectSummary: null, + requirements: [], + unlocked, + affordable: true, + }; +} + +function shopView(offers?: ShopOfferView[]): ShopView { return { shopKey: 'borin-supplies', shopName: "Quartermaster's Supplies", npcKey: 'borin-quartermaster', silver: 100, - offers: [ - { - itemKey: 'small-healing-potion', - itemName: 'Small Healing Potion', - itemDescription: 'A bitter draught.', - iconPath: '/images/items/potion.png', - currencyType: 'SILVER', - price: 12, - quantity: 1, - effectSummary: null, - requirements: [], - unlocked: true, - affordable: true, - }, + offers: offers ?? [ + offer('small-healing-potion', 'Small Healing Potion', true), ], }; } @@ -111,11 +121,23 @@ function tradeResult(): ExchangeResult { }; } -function createApi(overrides: Partial> = {}) { +function createApi( + overrides: Partial> & { shopSequence?: ShopView[] } = {}, +) { + // `shopSequence` lets a test hand back a different shop view on each call to + // `getShop`, so before/after `unlocked` flags can be observed across a trade + // without needing a real server round-trip. + const { shopSequence, ...rest } = overrides; + let shopCallIndex = 0; + return { getNpcInteraction: vi.fn(() => of(interaction())), getTradeIn: vi.fn(() => of(exchangeView())), - getShop: vi.fn(() => of(shopView())), + getShop: vi.fn(() => + shopSequence + ? of(shopSequence[Math.min(shopCallIndex++, shopSequence.length - 1)]) + : of(shopView()), + ), tradeIn: vi.fn(() => of(tradeResult())), getCharacter: vi.fn(() => of({ @@ -137,7 +159,7 @@ function createApi(overrides: Partial> = {}) { silverBalance: 88, }), ), - ...overrides, + ...rest, }; } @@ -361,4 +383,75 @@ describe('MerchantStore', () => { store.showPanel('EXCHANGE'); expect(store.panel()).toBe('EXCHANGE'); }); + + it('announces an offer that a trade just unlocked', async () => { + // Reputation earned by the trade opened the pouch. The player should learn + // that without hunting for it (slice §9). + const api = createApi({ + shopSequence: [ + shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]), + shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', true)]), + ], + }); + const store = createStore(api); + + await store.load('borin-quartermaster'); + store.setQuantity('ash-pelt', 1); + await store.tradeSelected(); + + expect(store.newlyUnlocked()).toEqual(['Basic Trophy Pouch']); + }); + + it('says nothing when a trade unlocks nothing', async () => { + const api = createApi({ + shopSequence: [ + shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]), + shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]), + ], + }); + const store = createStore(api); + + await store.load('borin-quartermaster'); + store.setQuantity('ash-pelt', 1); + await store.tradeSelected(); + + expect(store.newlyUnlocked()).toEqual([]); + }); + + it('does not re-announce an offer that was already open', async () => { + const api = createApi({ + shopSequence: [ + shopView([offer('small-healing-potion', 'Small Healing Potion', true)]), + shopView([offer('small-healing-potion', 'Small Healing Potion', true)]), + ], + }); + const store = createStore(api); + + await store.load('borin-quartermaster'); + store.setQuantity('ash-pelt', 1); + await store.tradeSelected(); + + expect(store.newlyUnlocked()).toEqual([]); + }); + + it('clears the unlock banner on a fresh load and at the start of a purchase', async () => { + // A stale banner from a previous merchant visit, or from before a buy + // click resolves, would misattribute an unlock to the wrong action. + const api = createApi({ + shopSequence: [ + shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]), + shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', true)]), + ], + }); + const store = createStore(api); + + await store.load('borin-quartermaster'); + store.setQuantity('ash-pelt', 1); + await store.tradeSelected(); + expect(store.newlyUnlocked()).toEqual(['Basic Trophy Pouch']); + + await store.buy('basic-trophy-pouch'); + + expect(store.newlyUnlocked()).toEqual([]); + }); }); diff --git a/apps/web/src/app/features/npc/merchant.store.ts b/apps/web/src/app/features/npc/merchant.store.ts index ee3c2e3..77be705 100644 --- a/apps/web/src/app/features/npc/merchant.store.ts +++ b/apps/web/src/app/features/npc/merchant.store.ts @@ -30,6 +30,8 @@ const ERROR_MESSAGES: Readonly> = { SHOP_OFFER_LOCKED: 'You have not earned the right to buy this yet.', SHOP_INSUFFICIENT_SILVER: 'You cannot afford that.', SHOP_INVALID_QUANTITY: 'That quantity cannot be bought.', + MERCHANT_REPUTATION_TOO_LOW: 'You have not earned enough standing for this yet.', + SHOP_BAG_ALREADY_OWNED: 'You already carry that.', CHARACTER_NOT_FOUND: 'Your character could not be found.', }; @@ -59,6 +61,7 @@ export class MerchantStore { private readonly lastTradeState = signal(null); private readonly lastPurchaseState = signal(null); private readonly selectionState = signal>({}); + private readonly newlyUnlockedState = signal([]); readonly interaction = this.interactionState.asReadonly(); readonly exchange = this.exchangeState.asReadonly(); @@ -71,6 +74,7 @@ export class MerchantStore { readonly lastTrade = this.lastTradeState.asReadonly(); readonly lastPurchase = this.lastPurchaseState.asReadonly(); readonly selection = this.selectionState.asReadonly(); + readonly newlyUnlocked = this.newlyUnlockedState.asReadonly(); /** True once anything is selected, so the trade button can enable. */ readonly hasSelection = computed(() => @@ -110,6 +114,7 @@ export class MerchantStore { this.lastTradeState.set(null); this.lastPurchaseState.set(null); this.selectionState.set({}); + this.newlyUnlockedState.set([]); this.panelState.set('DIALOGUE'); try { @@ -207,8 +212,13 @@ export class MerchantStore { // capacity, Silver and possibly renown at once, and the server is the // only place that knows all of it. this.exchangeState.set(await firstValueFrom(this.api.getTradeIn(npcKey))); - this.shopState.set( - this.shopState() ? await firstValueFrom(this.api.getShop(npcKey)) : null, + const previousShop = this.shopState(); + const refreshedShop = previousShop + ? await firstValueFrom(this.api.getShop(npcKey)) + : null; + this.shopState.set(refreshedShop); + this.newlyUnlockedState.set( + this.unlockedSince(previousShop, refreshedShop), ); // The purse in the top bar comes from the shared character state, so a @@ -230,6 +240,9 @@ export class MerchantStore { this.pendingState.set(itemKey); this.actionErrorState.set(null); + // A purchase re-reads the shop below, so a banner from an earlier trade + // must not linger and be misread as caused by this buy. + this.newlyUnlockedState.set([]); try { this.lastPurchaseState.set( @@ -252,6 +265,37 @@ export class MerchantStore { this.lastPurchaseState.set(null); } + dismissUnlocked(): void { + this.newlyUnlockedState.set([]); + } + + /** + * Offer names that went from locked to open (slice §9). + * + * Derived from two shop reads the store already performs rather than from a + * server event: the trade re-fetches the shop anyway, so the before/after + * state is in hand, and a notification the server has to remember would be + * more machinery than a one-line message is worth. + */ + private unlockedSince( + before: ShopView | null, + after: ShopView | null, + ): string[] { + if (!before || !after) { + return []; + } + + const wasLocked = new Set( + before.offers + .filter((offer) => !offer.unlocked) + .map((offer) => offer.itemKey), + ); + + return after.offers + .filter((offer) => offer.unlocked && wasLocked.has(offer.itemKey)) + .map((offer) => offer.itemName); + } + private toMessage(error: unknown): string { if (error instanceof HttpErrorResponse) { const code = (error.error as { code?: string } | null)?.code; From 7b033104582cb46224701799de7eaccaabf85f66 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 20:41:47 +0200 Subject: [PATCH 12/18] style: apply eslint --fix reformatting to files this slice touched Lint was intentionally deferred through tasks 1-7 to keep each task's diff scoped. Running it now only reformats line-wrapping in the four shop/seed files this slice already modified; the ~86 pre-existing problems in npcs, travel, the e2e spec and elsewhere are untouched. Co-Authored-By: Claude Opus 5 --- .../migrations/1796000000000-SellableLootBags.ts | 4 +--- apps/api/src/database/seeds/npc-content.ts | 5 +---- .../src/database/seeds/vertical-slice.seed.spec.ts | 11 +++++------ apps/api/src/shops/offer-presentation.spec.ts | 5 ++++- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/apps/api/src/database/migrations/1796000000000-SellableLootBags.ts b/apps/api/src/database/migrations/1796000000000-SellableLootBags.ts index 2c209d0..4463fce 100644 --- a/apps/api/src/database/migrations/1796000000000-SellableLootBags.ts +++ b/apps/api/src/database/migrations/1796000000000-SellableLootBags.ts @@ -56,9 +56,7 @@ export class SellableLootBags1796000000000 implements MigrationInterface { // Partial, because the column each one covers is now nullable and Postgres // treats NULLs as distinct -- a plain unique index over (shop_id, // item_definition_id) would happily accept a hundred bag offers. - await queryRunner.query( - `DROP INDEX "IDX_shop_offers_shop_item"`, - ); + await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_item"`); await queryRunner.query( `CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL`, ); diff --git a/apps/api/src/database/seeds/npc-content.ts b/apps/api/src/database/seeds/npc-content.ts index 6636668..46324c9 100644 --- a/apps/api/src/database/seeds/npc-content.ts +++ b/apps/api/src/database/seeds/npc-content.ts @@ -9,10 +9,7 @@ import type { DialogueResponseContent, } from '../../npcs/npc.types'; import { ITEM_IDS } from './item.constants'; -import { - BASIC_HIDE_BAG_ID, - BASIC_TROPHY_POUCH_ID, -} from './loot-bag-content'; +import { BASIC_HIDE_BAG_ID, BASIC_TROPHY_POUCH_ID } from './loot-bag-content'; import { BORDER_GUARD_FACTION_ID } from './reputation-content'; export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001'; diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index 3f19478..9d797b3 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -910,9 +910,7 @@ describe('seedVisibleVerticalSlice', () => { const gated = SHOP_OFFERS.filter((offer) => offer.conditions.length > 0); expect(gated).toHaveLength(3); - expect( - gated.map((offer) => offer.conditions[0].type).sort(), - ).toEqual([ + expect(gated.map((offer) => offer.conditions[0].type).sort()).toEqual([ GameConditionType.REGION_REPUTATION, GameConditionType.REGION_REPUTATION, GameConditionType.WORLD_RENOWN, @@ -944,9 +942,10 @@ describe('seedVisibleVerticalSlice', () => { it('gives every offer exactly one target', async () => { for (const offer of SHOP_OFFERS) { - const targets = [offer.itemDefinitionId, offer.lootBagDefinitionId].filter( - (target) => target !== null, - ); + const targets = [ + offer.itemDefinitionId, + offer.lootBagDefinitionId, + ].filter((target) => target !== null); expect(targets).toHaveLength(1); } }); diff --git a/apps/api/src/shops/offer-presentation.spec.ts b/apps/api/src/shops/offer-presentation.spec.ts index 4daafb5..e05383e 100644 --- a/apps/api/src/shops/offer-presentation.spec.ts +++ b/apps/api/src/shops/offer-presentation.spec.ts @@ -175,7 +175,10 @@ describe('describeItemEffect', () => { describe('describeBagEffect', () => { it('states what the bag carries and how much', () => { expect( - describeBagEffect({ capacity: 5, lootCategory: LootCategory.RAIDER_TROPHY }), + describeBagEffect({ + capacity: 5, + lootCategory: LootCategory.RAIDER_TROPHY, + }), ).toBe('Capacity: 5 Raider Trophies'); }); From 81e44b2c150aeda35df9e2cd2516a21c9bacff6b Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 20:43:53 +0200 Subject: [PATCH 13/18] docs: record 0.8.5 acceptance and the 0.9 referral mechanism Ticks Slice 0.8.5's acceptance criteria against the implemented behavior, verified against the seed and service code rather than assumed, and notes that the Bandit Blade's World Renown 3 gate is deliberately unreachable until Slice 0.11 adds the milestones to reach it. Points Slice 0.9 at the concrete bypass mechanism that now exists (BORIN_OFFER_IDS.hideBag's bypassConditions flag) instead of the placeholder reference to "the quest/referral exception". Also commits the slice's plan and research-notes documents, which were untracked. Co-Authored-By: Claude Opus 5 --- .../0.8.5-Reputation-Gated-Merchant-Offers.md | 22 +- .../0.9-First-Quest-and-Bag-Tutorial.md | 11 +- ...-0.8.5-reputation-gated-merchant-offers.md | 2452 +++++++++++++++++ .../2026-08-22-slice-0.8.5-research-notes.md | 100 + 4 files changed, 2576 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-22-slice-0.8.5-reputation-gated-merchant-offers.md create mode 100644 docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md diff --git a/docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md b/docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md index a36acc9..529fc38 100644 --- a/docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md +++ b/docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md @@ -223,14 +223,20 @@ No large modal is required. ## 11. Acceptance Criteria -- [ ] Merchant offers can define reputation/unlock requirements. -- [ ] At least two offers visibly demonstrate locked states. -- [ ] Requirements are shown to the player in English. -- [ ] Server rejects purchases when requirements are not met. -- [ ] New progression offers do not depend on level gates. -- [ ] Quest/referral unlock support exists for Slice 0.9. -- [ ] Reputation increase can visibly unlock a previously locked offer. -- [ ] No generalized rules engine was added unnecessarily. +- [x] Merchant offers can define reputation/unlock requirements. +- [x] At least two offers visibly demonstrate locked states. +- [x] Requirements are shown to the player in English. +- [x] Server rejects purchases when requirements are not met. +- [x] New progression offers do not depend on level gates. +- [x] Quest/referral unlock support exists for Slice 0.9. +- [x] Reputation increase can visibly unlock a previously locked offer. +- [x] No generalized rules engine was added unnecessarily. + +> **Note (implementation):** The Bandit Blade offer is gated on World Renown 3, +> which current content cannot reach — there is one renown milestone, worth +1. +> It is deliberately left visible and locked as a long-horizon goal until +> Slice 0.11 adds the milestones that reach it (§5: visible rewards create +> goals). --- diff --git a/docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md b/docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md index 244607a..50bac49 100644 --- a/docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md +++ b/docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md @@ -133,7 +133,16 @@ Category: HIDE Capacity: 5 ``` -The grant should use the quest/referral exception supported by Slice 0.8.5. +The grant uses the referral exception built in Slice 0.8.5: the Basic Hide Bag +offer (`BORIN_OFFER_IDS.hideBag`) carries + +```text +bypassConditions: [{ type: FLAG_SET, key: 'referred-by-south-gate-warden' }] +``` + +so setting that flag on the character's Borin state opens the offer without +changing any reputation. The offer's own gate stays at Border Watch +Reputation 40. --- diff --git a/docs/superpowers/plans/2026-08-22-slice-0.8.5-reputation-gated-merchant-offers.md b/docs/superpowers/plans/2026-08-22-slice-0.8.5-reputation-gated-merchant-offers.md new file mode 100644 index 0000000..2b6201c --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-slice-0.8.5-reputation-gated-merchant-offers.md @@ -0,0 +1,2452 @@ +# Reputation-Gated Merchant Offers (Slice 0.8.5) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make reputation change what the player can buy, by putting three merchant offers behind reputation, renown and a quest-referral exception — visible while locked, enforced on the server. + +**Architecture:** Slice 0.8 already shipped the condition engine (`GameConditionService`), the `shop_offers.conditions` column and the server-side `SHOP_OFFER_LOCKED` rejection. This slice adds four things: a shop offer that can sell a *loot bag* (bags are `LootBagDefinition` rows, not items, so the offer table needs a second nullable target column), an OR-shaped `bypassConditions` field so one quest referral can open one offer, server-computed requirement text so the UI can say *why* something is locked and how close the player is, and the seeded content that demonstrates all of it. + +**Tech Stack:** NestJS 11, TypeORM, PostgreSQL, Angular 20 (signals, standalone components), Jest (API), Vitest (web). + +**Spec:** `docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md` +**Research notes:** `docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md` + +## Global Constraints + +- **English only.** All identifiers, API contracts, DB names and player-facing copy are English (AGENTS.md §33). Existing German comments in touched files stay as they are; new text is English. +- **Server authority.** The client never evaluates a condition. It is told the outcome and the requirement text (AGENTS.md §5, spec §6). +- **Fail closed.** An unknown, unbacked or malformed condition reads as *not met*. For a gate, the safe direction of a bug is locked (`GameConditionService` docblock). +- **No generalized rules engine** (spec §11, acceptance criteria). `bypassConditions` is one extra jsonb column with OR semantics against `conditions` — not a rule tree, not a DSL, no operator nesting. +- **Migrations, never `synchronize`.** Schema changes go in a numbered migration under `apps/api/src/database/migrations/` with a matching `*.migration.spec.ts` (AGENTS.md §7). +- **Seeds are idempotent.** Re-running the seed must not duplicate content (AGENTS.md §8). +- **Domain errors are stable codes**, not matched text: `{ statusCode, code, message }` (AGENTS.md §6). +- **Out of scope** (spec §12): haggling, daily shops, dynamic personalities, faction wars, reputation decay, negative reputation, per-offer multi-currency. + +## Decisions Locked In + +These were decided with the user before planning. Do not revisit them mid-implementation. + +1. **`MERCHANT_REPUTATION_TOO_LOW` is added alongside `SHOP_OFFER_LOCKED`, not instead of it.** The generic code stays for non-reputation gates, so the existing frontend mapping and `shop.service.spec.ts` keep passing. The specific code is returned only when the *unmet* condition is a `REGION_REPUTATION` one — which is what spec §6 asks for. +2. **The Bandit Blade offer stays visibly locked until Slice 0.11.** World Renown 3 is unreachable with today's content (one milestone, `first-goods-returned`, +1). That is deliberate: spec §5 says visible rewards create goals. Do **not** lower the threshold or invent milestones to make it reachable. +3. **The demo seed stops granting the Basic Trophy Pouch.** It must be bought through the reputation-gated offer. The Basic Hide Bag stays seeded until Slice 0.9 grants it through the referral quest, so the hunting loop is not broken in the meantime. +4. **Hide Bag = reputation gate OR referral flag.** Reputation 40, bypassed by the `referred-by-south-gate-warden` NPC flag from Slice 0.9 §5. + +## Assumptions (stated, per AGENTS.md §39) + +- **Requirement copy names the faction, not the region.** Spec §8's example reads `Requires Ashen Fields Reputation 25`; `Ashen Fields` is the *region* (`ReputationFaction.regionKey`) while `Border Watch` is the faction *name* the rest of the shipped UI already shows ("+5 Border Watch Reputation" in the trade summary). This plan renders `Requires Border Watch Reputation 25` for consistency with what players already read on the same screen. One-line change in `offer-presentation.ts` if the user prefers the region wording. +- **Only `REGION_REPUTATION` and `WORLD_RENOWN` requirements get rendered text.** They are the two spec §8 names, and the only two used by seeded content. Other condition types produce no requirement line; the offer still renders as locked, so nothing leaks and nothing opens. +- **`bypassConditions` are never described to the player.** Showing "Requires: referred by the South Gate Warden" would spoil the Slice 0.9 tutorial before the quest exists. Only `conditions` produce requirement text. +- **Prices are provisional balancing data** (spec §4: "Exact thresholds are balancing data"): Trophy Pouch 40 Silver (verbatim from spec §5), Hide Bag 35 Silver, Bandit Blade 60 Silver. + +## File Structure + +| File | Responsibility | +|---|---| +| `apps/api/src/database/migrations/1796000000000-SellableLootBags.ts` | **Create.** Bag target column, nullable item column, bypass column, partial unique indexes, clears content rows so the seed can own stable ids. | +| `apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts` | **Create.** Asserts SQL shape and up/down symmetry. | +| `apps/api/src/shops/entities/shop-offer.entity.ts` | **Modify.** Nullable `itemDefinitionId`, new `lootBagDefinitionId`, new `bypassConditions`. | +| `apps/api/src/conditions/game-condition.service.ts` | **Modify.** `ConditionOutcome` gains `actual` so the UI can show "Current: 14". | +| `apps/api/src/shops/offer-presentation.ts` | **Create.** Pure functions: requirement text + effect summary. No DB access. | +| `apps/api/src/shops/offer-presentation.spec.ts` | **Create.** Unit tests for the above. | +| `apps/api/src/shops/shop.errors.ts` | **Modify.** Add `MERCHANT_REPUTATION_TOO_LOW`, `SHOP_BAG_ALREADY_OWNED`. | +| `apps/api/src/shops/shop.service.ts` | **Modify.** Gate with bypass, bag grant path, requirement DTOs. | +| `apps/api/src/shops/shops.module.ts` | **Modify.** Register the new entities it reads. | +| `apps/api/src/database/seeds/npc-content.ts` | **Modify.** Stable offer ids; three gated offers. | +| `apps/api/src/database/seeds/vertical-slice.seed.ts` | **Modify.** Upsert offers by id; stop granting the Trophy Pouch. | +| `apps/web/src/app/core/api/game-api.models.ts` | **Modify.** `requirements` + `effectSummary` on `ShopOfferView`. | +| `apps/web/src/app/features/npc/merchant.store.ts` | **Modify.** Unlock feedback, new error codes. | +| `apps/web/src/app/features/npc/merchant-page.component.html` | **Modify.** Requirement rows, unlock banner. | +| `apps/web/src/app/features/npc/merchant-page.component.scss` | **Modify.** Styles for the above. | + +--- + +### Task 1: Schema — sellable loot bags and a bypass column + +**Files:** +- Create: `apps/api/src/database/migrations/1796000000000-SellableLootBags.ts` +- Create: `apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts` +- Modify: `apps/api/src/shops/entities/shop-offer.entity.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `ShopOffer.itemDefinitionId: string | null`, `ShopOffer.lootBagDefinitionId: string | null`, `ShopOffer.lootBagDefinition: LootBagDefinition`, `ShopOffer.bypassConditions: GameCondition[]`. + +**Why the row wipe:** `shop_offers` is pure content. Nothing references it — not `character_items`, not `combat_reward_items`. Slice 0.8 inserted its two offers with generated uuids; Task 5 re-seeds every offer with a stable id so the upsert has a conflict target that works for both offer shapes. Deleting the old rows in the migration is what makes those stable ids land cleanly instead of colliding with the partial unique indexes. + +- [ ] **Step 1: Write the failing migration test** + +Create `apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts`: + +```ts +import 'reflect-metadata'; +import { QueryRunner } from 'typeorm'; +import { SellableLootBags1796000000000 } from './1796000000000-SellableLootBags'; + +async function runUp(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + await new SellableLootBags1796000000000().up(queryRunner); + return query.mock.calls.map(([sql]) => sql as string); +} + +async function runDown(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + const migration = new SellableLootBags1796000000000(); + await migration.up(queryRunner); + const upCount = query.mock.calls.length; + await migration.down(queryRunner); + return query.mock.calls.slice(upCount).map(([sql]) => sql as string); +} + +describe('SellableLootBags1796000000000', () => { + it('adds the bag target and bypass columns', async () => { + const joined = (await runUp()).join('\n'); + + expect(joined).toContain('"loot_bag_definition_id" uuid'); + expect(joined).toContain('"bypass_conditions" jsonb'); + expect(joined).toContain('FK_shop_offers_loot_bag'); + }); + + it('makes the item target nullable so a bag offer can exist', async () => { + const joined = (await runUp()).join('\n'); + + expect(joined).toContain('ALTER COLUMN "item_definition_id" DROP NOT NULL'); + }); + + it('requires exactly one target per offer', async () => { + const joined = (await runUp()).join('\n'); + + // An offer that sells nothing, or sells both an item and a bag, is a + // content bug the database must not store. + expect(joined).toContain('CHK_shop_offers_single_target'); + expect(joined).toContain('num_nonnulls'); + }); + + it('keeps both target kinds unique per shop via partial indexes', async () => { + const joined = (await runUp()).join('\n'); + + // A plain unique index over a nullable column would let a shop hold + // unlimited bag offers, because Postgres treats NULLs as distinct. + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL', + ); + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL', + ); + }); + + it('clears content offers so the seed can own stable ids', async () => { + const joined = (await runUp()).join('\n'); + + expect(joined).toContain('DELETE FROM "shop_offers"'); + }); + + it('drops what it added and restores the original index on rollback', async () => { + const joined = (await runDown()).join('\n'); + + expect(joined).toContain('DROP COLUMN "loot_bag_definition_id"'); + expect(joined).toContain('DROP COLUMN "bypass_conditions"'); + expect(joined).toContain('ALTER COLUMN "item_definition_id" SET NOT NULL'); + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")', + ); + }); +}); +``` + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `npm run test --workspace=@ashen-realms/api -- sellable-loot-bags` +Expected: FAIL — `Cannot find module './1796000000000-SellableLootBags'`. + +- [ ] **Step 3: Write the migration** + +Create `apps/api/src/database/migrations/1796000000000-SellableLootBags.ts`: + +```ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Lets a shop offer sell a loot bag, and lets one offer carry an exception + * (Playable Slice 0.8.5 §4, §7). + * + * A bag is a `loot_bag_definitions` row, not an item -- deliberately so since + * Slice 0.7.5 §6, because a bag is never equipped, never rolls as loot and has + * no combat stats. Selling one therefore needs a second target column rather + * than a fake item definition, which is exactly the "offer shape" the Slice 0.8 + * seed said it had no reason to build yet. + * + * `bypass_conditions` is the minimal exception support Slice 0.9 needs: an + * offer opens when `conditions` hold *or* `bypass_conditions` hold. One column, + * OR semantics, no rule engine (slice §7, §11). + */ +export class SellableLootBags1796000000000 implements MigrationInterface { + name = 'SellableLootBags1796000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Shop offers are pure content -- nothing references them, and Slice 0.8 + // inserted its two rows with generated uuids. The seed re-creates every + // offer with a stable id (AGENTS.md §8), which needs the old anonymous + // rows gone or they would linger as duplicates the upsert never matches. + await queryRunner.query(`DELETE FROM "shop_offers"`); + + await queryRunner.query(` + ALTER TABLE "shop_offers" + ALTER COLUMN "item_definition_id" DROP NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE "shop_offers" + ADD COLUMN "loot_bag_definition_id" uuid + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" + ADD CONSTRAINT "FK_shop_offers_loot_bag" + FOREIGN KEY ("loot_bag_definition_id") + REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT + `); + + await queryRunner.query(` + ALTER TABLE "shop_offers" + ADD COLUMN "bypass_conditions" jsonb NOT NULL DEFAULT '[]'::jsonb + `); + + // Exactly one target. An offer selling nothing has no meaning, and one + // selling both would make the grant path ambiguous. + await queryRunner.query(` + ALTER TABLE "shop_offers" + ADD CONSTRAINT "CHK_shop_offers_single_target" + CHECK (num_nonnulls("item_definition_id", "loot_bag_definition_id") = 1) + `); + + // Partial, because the column each one covers is now nullable and Postgres + // treats NULLs as distinct -- a plain unique index over (shop_id, + // item_definition_id) would happily accept a hundred bag offers. + await queryRunner.query( + `DROP INDEX "IDX_shop_offers_shop_item"`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Bag offers cannot survive a rollback: the column that identifies what + // they sell is about to disappear, and the restored NOT NULL would reject + // them anyway. + await queryRunner.query( + `DELETE FROM "shop_offers" WHERE "loot_bag_definition_id" IS NOT NULL`, + ); + + await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_bag"`); + await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_item"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")`, + ); + + await queryRunner.query(` + ALTER TABLE "shop_offers" + DROP CONSTRAINT "CHK_shop_offers_single_target" + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" DROP COLUMN "bypass_conditions" + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" + DROP CONSTRAINT "FK_shop_offers_loot_bag" + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" DROP COLUMN "loot_bag_definition_id" + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" + ALTER COLUMN "item_definition_id" SET NOT NULL + `); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm run test --workspace=@ashen-realms/api -- sellable-loot-bags` +Expected: PASS (6 tests). + +- [ ] **Step 5: Update the entity to match** + +In `apps/api/src/shops/entities/shop-offer.entity.ts`, replace the `@Index` decorator and the `itemDefinitionId` / `conditions` / `itemDefinition` members. Add the `LootBagDefinition` import next to the existing `ItemDefinition` one: + +```ts +import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity'; +``` + +Replace the class-level index decorator: + +```ts +@Entity({ name: 'shop_offers' }) +@Index('IDX_shop_offers_shop_item', ['shopId', 'itemDefinitionId'], { + unique: true, + where: '"item_definition_id" IS NOT NULL', +}) +@Index('IDX_shop_offers_shop_bag', ['shopId', 'lootBagDefinitionId'], { + unique: true, + where: '"loot_bag_definition_id" IS NOT NULL', +}) +export class ShopOffer { +``` + +Replace the `itemDefinitionId` column with both targets: + +```ts + /** + * What the offer sells. Exactly one of these is set, enforced by + * `CHK_shop_offers_single_target`: a bag is a `LootBagDefinition` rather + * than an item (Slice 0.7.5 §6), so the two cannot share a column. + */ + @Column({ name: 'item_definition_id', type: 'uuid', nullable: true }) + itemDefinitionId!: string | null; + + @Column({ name: 'loot_bag_definition_id', type: 'uuid', nullable: true }) + lootBagDefinitionId!: string | null; +``` + +After the existing `conditions` column, add: + +```ts + /** + * An alternative way in (Slice 0.8.5 §7). + * + * The offer opens when `conditions` hold **or** these do. That is the whole + * exception model: it lets one quest referral grant an acquisition that + * reputation alone would not yet allow, without a favor engine. Empty on + * every offer that has no exception. + */ + @Column({ + name: 'bypass_conditions', + type: 'jsonb', + default: () => "'[]'::jsonb", + }) + bypassConditions!: GameCondition[]; +``` + +And make the item relation nullable, adding the bag relation beside it: + +```ts + @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT', nullable: true }) + @JoinColumn({ name: 'item_definition_id' }) + itemDefinition!: ItemDefinition | null; + + @ManyToOne(() => LootBagDefinition, { onDelete: 'RESTRICT', nullable: true }) + @JoinColumn({ name: 'loot_bag_definition_id' }) + lootBagDefinition!: LootBagDefinition | null; +``` + +- [ ] **Step 6: Verify nothing else broke** + +Run: `npm run test --workspace=@ashen-realms/api` +Expected: PASS. `shop.service.ts` still compiles because its fixtures always set `itemDefinition`; TypeScript will now flag the nullable access in `shop.service.ts` — if `tsc` complains here rather than in Task 2, leave the service alone and let Task 2 fix it properly. If the suite is red only on `shop.service.spec.ts` type errors, proceed to Task 2 and commit both together. + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/database/migrations/1796000000000-SellableLootBags.ts apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts apps/api/src/shops/entities/shop-offer.entity.ts +git commit -m "feat(shops): let an offer sell a loot bag and carry bypass conditions" +``` + +--- + +### Task 2: Condition outcomes report the player's current value + +**Files:** +- Modify: `apps/api/src/conditions/game-condition.service.ts` +- Test: `apps/api/src/conditions/game-condition.service.spec.ts` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces: `ConditionOutcome { condition: GameCondition; met: boolean; actual: number | null }`. Task 4 turns `actual` into the "Current: 14" line spec §5 asks for. + +**Why:** `describe()` already exists and already reports per-condition `met`. What it cannot report is *how close* the player is, which is half of the spec §5 display. The private evaluators each know the number they compared; they simply discard it. + +- [ ] **Step 1: Write the failing test** + +Append inside the existing top-level `describe('GameConditionService', ...)` block in `apps/api/src/conditions/game-condition.service.spec.ts`, next to the existing `describe()` test: + +```ts + it('reports the current value behind each requirement', async () => { + const service = createService({ renown: 1, reputation: 14 }); + + const outcomes = await service.describe({ characterId: CHARACTER_ID }, [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + { + type: GameConditionType.WORLD_RENOWN, + operator: ComparisonOperator.GTE, + value: 3, + }, + ]); + + // "Current: 14" against "Requires 25" is the whole point of showing a + // locked offer rather than hiding it (slice §5). + expect(outcomes[0]).toMatchObject({ met: false, actual: 14 }); + expect(outcomes[1]).toMatchObject({ met: false, actual: 1 }); + }); + + it('reports a null current value for a condition with no scale', async () => { + const service = createService({ renown: 1, reputation: 100 }); + + const outcomes = await service.describe( + { characterId: CHARACTER_ID, npcId: NPC_ID }, + [{ type: GameConditionType.FLAG_SET, key: 'met', value: true }], + ); + + expect(outcomes[0].actual).toBeNull(); + }); +``` + +If `NPC_ID` is not already a constant in that spec file, reuse whatever npc id the existing `FLAG_SET` tests in the file pass; read the file before writing this test. + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `npm run test --workspace=@ashen-realms/api -- game-condition` +Expected: FAIL — `actual` is `undefined`, not `14`. + +- [ ] **Step 3: Thread the value through the evaluators** + +In `apps/api/src/conditions/game-condition.service.ts`, add the internal shape above `ConditionOutcome` and extend the public one: + +```ts +/** + * One condition's result, plus the number it was measured against. + * + * `actual` is null for conditions with no scale -- a flag is set or it is not, + * and "Current: 0" would be a lie about a boolean. + */ +interface ConditionEvaluation { + met: boolean; + actual: number | null; +} + +export interface ConditionOutcome { + condition: GameCondition; + met: boolean; + actual: number | null; +} +``` + +Change `evaluate()` to read `.met`: + +```ts + const scope: RepositoryScope = manager ?? this.dataSource; + for (const condition of conditions) { + const evaluation = await this.evaluateOne(context, condition, scope); + if (!evaluation.met) { + return false; + } + } + return true; +``` + +Change `describe()` to spread the evaluation: + +```ts + const scope: RepositoryScope = manager ?? this.dataSource; + const outcomes: ConditionOutcome[] = []; + for (const condition of conditions) { + const evaluation = await this.evaluateOne(context, condition, scope); + outcomes.push({ condition, met: evaluation.met, actual: evaluation.actual }); + } + return outcomes; +``` + +Change `evaluateOne` and the four evaluators to return `ConditionEvaluation`. The unsupported/default branches return `{ met: false, actual: null }`: + +```ts + private async evaluateOne( + context: ConditionContext, + condition: GameCondition, + scope: RepositoryScope, + ): Promise { + if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) { + return { met: false, actual: null }; + } + + switch (condition.type) { + case GameConditionType.REGION_REPUTATION: + return this.evaluateRegionReputation(context, condition, scope); + case GameConditionType.WORLD_RENOWN: + return this.evaluateWorldRenown(context, condition, scope); + case GameConditionType.FLAG_SET: + return this.evaluateFlag(context, condition, scope); + case GameConditionType.HAS_ITEM: + return this.evaluateHasItem(context, condition, scope); + default: + return { met: false, actual: null }; + } + } +``` + +In `evaluateRegionReputation`, the two early returns become `{ met: false, actual: null }` and the final line becomes: + +```ts + const reputation = row?.reputation ?? 0; + return { met: this.compareNumeric(reputation, condition), actual: reputation }; +``` + +In `evaluateWorldRenown`, the early return becomes `{ met: false, actual: null }` and the final line: + +```ts + return { + met: this.compareNumeric(character.renown, condition), + actual: character.renown, + }; +``` + +In `evaluateFlag`, both the early return and the result carry `actual: null`: + +```ts + if (!condition.key || !context.npcId) { + return { met: false, actual: null }; + } + // ... unchanged lookup ... + const expected = condition.value ?? true; + return { + met: (state?.flags?.[condition.key] ?? false) === expected, + actual: null, + }; +``` + +In `evaluateHasItem`, the two early returns become `{ met: false, actual: null }` and the final block: + +```ts + const quantity = owned?.quantity ?? 0; + return { + met: this.compareNumeric(quantity, { + ...condition, + operator: condition.operator ?? ComparisonOperator.GTE, + value: condition.value ?? 1, + }), + actual: quantity, + }; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm run test --workspace=@ashen-realms/api -- game-condition` +Expected: PASS — all existing tests plus the two new ones. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/conditions/game-condition.service.ts apps/api/src/conditions/game-condition.service.spec.ts +git commit -m "feat(conditions): report the current value behind each condition" +``` + +--- + +### Task 3: Requirement text and effect summaries + +**Files:** +- Create: `apps/api/src/shops/offer-presentation.ts` +- Create: `apps/api/src/shops/offer-presentation.spec.ts` + +**Interfaces:** +- Consumes: `ConditionOutcome` from Task 2. +- Produces: + - `interface ShopOfferRequirementDto { label: string; current: number | null; required: number | null; met: boolean }` + - `function describeRequirement(outcome: ConditionOutcome, factionNames: ReadonlyMap): ShopOfferRequirementDto | null` + - `function describeItemEffect(item: { weaponDamage: number; bonusAttack: number; bonusHp: number; bonusArmor: number }): string | null` + - `function describeBagEffect(bag: { capacity: number; lootCategory: LootCategory }): string` + +**Why a separate file:** these are pure string functions with no DB access, which makes them directly testable without the repository fixture scaffolding `shop.service.spec.ts` needs (AGENTS.md §11's separation principle applied at a smaller scale). + +- [ ] **Step 1: Write the failing test** + +Create `apps/api/src/shops/offer-presentation.spec.ts`: + +```ts +import { + ComparisonOperator, + GameConditionType, +} from '../conditions/game-condition.types'; +import { LootCategory } from '../items/loot-category.enum'; +import { + describeBagEffect, + describeItemEffect, + describeRequirement, +} from './offer-presentation'; + +const FACTIONS = new Map([['border-guard', 'Border Watch']]); + +describe('describeRequirement', () => { + it('names the faction and the threshold for a reputation gate', () => { + const requirement = describeRequirement( + { + condition: { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + met: false, + actual: 14, + }, + FACTIONS, + ); + + expect(requirement).toEqual({ + label: 'Requires Border Watch Reputation 25', + current: 14, + required: 25, + met: false, + }); + }); + + it('describes a World Renown gate', () => { + const requirement = describeRequirement( + { + condition: { + type: GameConditionType.WORLD_RENOWN, + operator: ComparisonOperator.GTE, + value: 3, + }, + met: false, + actual: 1, + }, + FACTIONS, + ); + + expect(requirement).toMatchObject({ + label: 'Requires World Renown 3', + current: 1, + required: 3, + }); + }); + + it('falls back to the faction key when the faction is unknown', () => { + // A gate naming a faction that is not seeded still has to render as + // something, and the key is more useful to a player than a blank. + const requirement = describeRequirement( + { + condition: { + type: GameConditionType.REGION_REPUTATION, + key: 'dusk-hunters', + operator: ComparisonOperator.GTE, + value: 10, + }, + met: false, + actual: 0, + }, + FACTIONS, + ); + + expect(requirement?.label).toBe('Requires dusk-hunters Reputation 10'); + }); + + it('renders no line for a condition type the player is not shown', () => { + // A dialogue flag is an internal gate. Naming it would spoil the quest + // that sets it, and the offer already renders as locked without it. + expect( + describeRequirement( + { + condition: { + type: GameConditionType.FLAG_SET, + key: 'referred-by-south-gate-warden', + value: true, + }, + met: false, + actual: null, + }, + FACTIONS, + ), + ).toBeNull(); + }); +}); + +describe('describeItemEffect', () => { + it('summarises a weapon', () => { + expect( + describeItemEffect({ + weaponDamage: 11, + bonusAttack: 1, + bonusHp: 0, + bonusArmor: 0, + }), + ).toBe('11 Weapon Damage, +1 Attack'); + }); + + it('summarises armour', () => { + expect( + describeItemEffect({ + weaponDamage: 0, + bonusAttack: 0, + bonusHp: 5, + bonusArmor: 4, + }), + ).toBe('+5 HP, +4 Armor'); + }); + + it('has nothing to say about an item with no stats', () => { + expect( + describeItemEffect({ + weaponDamage: 0, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + }), + ).toBeNull(); + }); +}); + +describe('describeBagEffect', () => { + it('states what the bag carries and how much', () => { + expect( + describeBagEffect({ capacity: 5, lootCategory: LootCategory.RAIDER_TROPHY }), + ).toBe('Capacity: 5 Raider Trophies'); + }); + + it('uses the plural label of the category', () => { + expect( + describeBagEffect({ capacity: 5, lootCategory: LootCategory.HIDE }), + ).toBe('Capacity: 5 Hides'); + }); +}); +``` + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `npm run test --workspace=@ashen-realms/api -- offer-presentation` +Expected: FAIL — `Cannot find module './offer-presentation'`. + +- [ ] **Step 3: Write the implementation** + +Create `apps/api/src/shops/offer-presentation.ts`: + +```ts +import type { ConditionOutcome } from '../conditions/game-condition.service'; +import { GameConditionType } from '../conditions/game-condition.types'; +import { LootCategory } from '../items/loot-category.enum'; + +/** + * One requirement as the player reads it (Playable Slice 0.8.5 §5, §8). + * + * `current` is what makes a locked offer a goal rather than a wall: seeing + * "Requires 25 / Current 14" tells the player how much further to go. + */ +export interface ShopOfferRequirementDto { + label: string; + current: number | null; + required: number | null; + met: boolean; +} + +/** Plural, because a capacity counts things. Mirrors the web strip's labels. */ +const CATEGORY_LABELS: Readonly> = { + [LootCategory.HIDE]: 'Hides', + [LootCategory.RAIDER_TROPHY]: 'Raider Trophies', +}; + +/** + * Turns one evaluated condition into a player-facing line, or into nothing. + * + * Only the two gate types slice §8 names are rendered. Everything else -- + * dialogue flags above all -- returns null: the offer still shows as locked, + * but an internal gate is not announced, and a quest flag named on a shop row + * would spoil the Slice 0.9 tutorial before the quest exists. + */ +export function describeRequirement( + outcome: ConditionOutcome, + factionNames: ReadonlyMap, +): ShopOfferRequirementDto | null { + const { condition, met, actual } = outcome; + const required = Number(condition.value); + if (!Number.isFinite(required)) { + return null; + } + + switch (condition.type) { + case GameConditionType.REGION_REPUTATION: { + if (!condition.key) { + return null; + } + // The key is a poor label, but a blank is worse -- a gate on a faction + // that is not seeded yet should still read as something. + const faction = factionNames.get(condition.key) ?? condition.key; + return { + label: `Requires ${faction} Reputation ${required}`, + current: actual, + required, + met, + }; + } + case GameConditionType.WORLD_RENOWN: + return { + label: `Requires World Renown ${required}`, + current: actual, + required, + met, + }; + default: + return null; + } +} + +/** + * What an item does, in one line (slice §8 "relevant effect"). + * + * Stats only. The flavour text is already carried separately, and repeating it + * here would push the actual numbers off the row. + */ +export function describeItemEffect(item: { + weaponDamage: number; + bonusAttack: number; + bonusHp: number; + bonusArmor: number; +}): string | null { + const parts: string[] = []; + if (item.weaponDamage > 0) { + parts.push(`${item.weaponDamage} Weapon Damage`); + } + if (item.bonusAttack > 0) { + parts.push(`+${item.bonusAttack} Attack`); + } + if (item.bonusHp > 0) { + parts.push(`+${item.bonusHp} HP`); + } + if (item.bonusArmor > 0) { + parts.push(`+${item.bonusArmor} Armor`); + } + return parts.length > 0 ? parts.join(', ') : null; +} + +/** What a bag does: one category, one capacity (Slice 0.7.5 §6). */ +export function describeBagEffect(bag: { + capacity: number; + lootCategory: LootCategory; +}): string { + const label = CATEGORY_LABELS[bag.lootCategory] ?? bag.lootCategory; + return `Capacity: ${bag.capacity} ${label}`; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm run test --workspace=@ashen-realms/api -- offer-presentation` +Expected: PASS (10 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/shops/offer-presentation.ts apps/api/src/shops/offer-presentation.spec.ts +git commit -m "feat(shops): describe offer requirements and effects in English" +``` + +--- + +### Task 4: The shop sells bags, honours bypasses, and explains its locks + +**Files:** +- Modify: `apps/api/src/shops/shop.errors.ts` +- Modify: `apps/api/src/shops/shop.service.ts` +- Modify: `apps/api/src/shops/shops.module.ts` +- Test: `apps/api/src/shops/shop.service.spec.ts` + +**Interfaces:** +- Consumes: `ShopOffer.lootBagDefinitionId` / `.bypassConditions` (Task 1), `ConditionOutcome.actual` (Task 2), `describeRequirement` / `describeItemEffect` / `describeBagEffect` (Task 3). +- Produces: `ShopOfferDto` extended with `requirements: ShopOfferRequirementDto[]` and `effectSummary: string | null`; error codes `MERCHANT_REPUTATION_TOO_LOW`, `SHOP_BAG_ALREADY_OWNED`. + +**This is the task that satisfies spec §6 and §10 end to end.** The purchase flow becomes: load character → load offer → validate quantity → validate gate (conditions OR bypass) → validate price → grant item *or* bag → commit, all inside the existing transaction. + +- [ ] **Step 1: Write the failing tests** + +The existing `createWorld` fixture in `apps/api/src/shops/shop.service.spec.ts` needs three additions: a `LootBagDefinition`/`CharacterLootBag` repository, a `ReputationFaction` repository (the service now loads faction names), and per-condition `describe` support. Rewrite the fixture's `Fixture` interface and `repositories` function as follows, leaving every existing test body untouched. + +Add to the imports at the top of the spec: + +```ts +import { ComparisonOperator, GameConditionType } from '../conditions/game-condition.types'; +import { LootCategory } from '../items/loot-category.enum'; +import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity'; +import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity'; +import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; +``` + +Extend `Fixture`: + +```ts +interface Fixture { + silver?: number; + shopEnabled?: boolean; + hasShop?: boolean; + offerUnlocked?: boolean; + offerRepeatable?: boolean; + offerQuantity?: number; + ownedPotions?: number | null; + /** Replaces the single item offer with one that sells the trophy pouch. */ + bagOffer?: boolean; + /** Whether the character already holds the bag the offer sells. */ + ownsBag?: boolean; + /** Conditions on the offer, so a test can gate it on reputation. */ + conditions?: GameCondition[]; + /** The alternative way in (slice §7). */ + bypassConditions?: GameCondition[]; + /** Which of `conditions` / `bypassConditions` the fake engine says hold. */ + bypassPasses?: boolean; +} +``` + +In `createWorld`, replace the `offers` array and add the extra repositories: + +```ts + const bag = { + id: 'bag-trophy-pouch', + key: 'basic-trophy-pouch', + name: 'Basic Trophy Pouch', + lootCategory: LootCategory.RAIDER_TROPHY, + capacity: 5, + iconPath: '/images/items/basic-trophy-pouch.png', + }; + + const grantedBags: Array> = []; + + const offers = [ + fixture.bagOffer + ? { + id: 'offer-bag', + shopId: 'shop-1', + itemDefinitionId: null, + lootBagDefinitionId: bag.id, + currencyType: 'SILVER', + price: 40, + quantity: 1, + repeatable: false, + sortOrder: 1, + conditions: fixture.conditions ?? [], + bypassConditions: fixture.bypassConditions ?? [], + enabled: true, + itemDefinition: null, + lootBagDefinition: bag, + } + : { + id: 'offer-1', + shopId: 'shop-1', + itemDefinitionId: 'item-potion', + lootBagDefinitionId: null, + currencyType: 'SILVER', + price: 12, + quantity: fixture.offerQuantity ?? 1, + repeatable: fixture.offerRepeatable ?? true, + sortOrder: 1, + conditions: fixture.conditions ?? [], + bypassConditions: fixture.bypassConditions ?? [], + enabled: true, + itemDefinition: { + id: 'item-potion', + key: 'small-healing-potion', + name: 'Small Healing Potion', + description: 'A bitter draught.', + iconPath: '/images/items/potion.png', + weaponDamage: 0, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + }, + lootBagDefinition: null, + }, + ] as unknown as ShopOffer[]; +``` + +Add these branches inside `repositories`, before the `throw`: + +```ts + if (entity === ReputationFaction) { + return { + find: () => + Promise.resolve([ + { id: 'faction-1', key: 'border-guard', name: 'Border Watch' }, + ]), + }; + } + if (entity === LootBagDefinition) { + return { findOneBy: () => Promise.resolve(bag) }; + } + if (entity === CharacterLootBag) { + return { + findOne: () => + Promise.resolve( + fixture.ownsBag + ? { characterId: CHARACTER_ID, lootBagDefinitionId: bag.id } + : null, + ), + create: (row: Record) => row, + save: async (row: Record) => { + grantedBags.push(row); + return row; + }, + }; + } +``` + +Replace the `conditions` fake so it answers per-condition-list, and add `describe`: + +```ts + const conditions = { + evaluate: jest.fn((_context: unknown, list: GameCondition[] | undefined) => { + if (!list || list.length === 0) { + return Promise.resolve(true); + } + // The fixture distinguishes the two lists by identity, so a test can say + // "the gate is shut but the bypass is open". + if (list === fixture.bypassConditions) { + return Promise.resolve(fixture.bypassPasses ?? false); + } + return Promise.resolve(fixture.offerUnlocked ?? true); + }), + describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) => + Promise.resolve( + (list ?? []).map((condition) => ({ + condition, + met: fixture.offerUnlocked ?? true, + actual: 14, + })), + ), + ), + } as unknown as GameConditionService; +``` + +And return `grantedBags` from `createWorld` alongside `grantedItems`. + +Now append these tests to the `describe('ShopService', ...)` block: + +```ts + it('shows the requirement and the current value on a locked offer', async () => { + const world = createWorld({ + offerUnlocked: false, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + ], + }); + + const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY); + + // Locked, but still listed: a visible reward is a goal (slice §5). + expect(view.offers).toHaveLength(1); + expect(view.offers[0].unlocked).toBe(false); + expect(view.offers[0].requirements).toEqual([ + { + label: 'Requires Border Watch Reputation 25', + current: 14, + required: 25, + met: false, + }, + ]); + }); + + it('lists a bag offer with its capacity as the effect', async () => { + const world = createWorld({ bagOffer: true, silver: 100 }); + + const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY); + + expect(view.offers[0]).toMatchObject({ + itemKey: 'basic-trophy-pouch', + itemName: 'Basic Trophy Pouch', + price: 40, + effectSummary: 'Capacity: 5 Raider Trophies', + unlocked: true, + }); + }); + + it('grants a bag rather than stacking it as an item', async () => { + const world = createWorld({ bagOffer: true, silver: 100 }); + + const result = await world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ); + + expect(result.silverSpent).toBe(40); + expect(world.grantedItems).toHaveLength(0); + expect(world.grantedBags[0]).toMatchObject({ + characterId: CHARACTER_ID, + lootBagDefinitionId: 'bag-trophy-pouch', + active: true, + }); + }); + + it('refuses to sell a bag the character already carries', async () => { + // A second copy grants nothing (only the roomiest active bag per category + // counts) so charging for it would be taking Silver for nothing. + const world = createWorld({ bagOffer: true, ownsBag: true, silver: 100 }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ), + ).rejects.toMatchObject({ code: 'SHOP_BAG_ALREADY_OWNED' }); + + expect(world.character.silver).toBe(100); + }); + + it('names reputation as the reason when a reputation gate is what blocks', async () => { + const world = createWorld({ + offerUnlocked: false, + silver: 1000, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + ], + }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'small-healing-potion', + 1, + ), + ).rejects.toMatchObject({ code: 'MERCHANT_REPUTATION_TOO_LOW' }); + }); + + it('opens an offer whose bypass holds even though its conditions do not', async () => { + // The Slice 0.9 referral: the warden's word is worth more than the + // reputation the player has not earned yet (slice §7). + const bypassConditions: GameCondition[] = [ + { + type: GameConditionType.FLAG_SET, + key: 'referred-by-south-gate-warden', + value: true, + }, + ]; + const world = createWorld({ + bagOffer: true, + silver: 100, + offerUnlocked: false, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 40, + }, + ], + bypassConditions, + bypassPasses: true, + }); + + const result = await world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ); + + expect(result.silverSpent).toBe(40); + expect(world.grantedBags).toHaveLength(1); + }); + + it('still charges the price when a requirement is met', async () => { + // Reputation opens the offer; it does not pay for it (slice §10). + const world = createWorld({ bagOffer: true, silver: 10 }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ), + ).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' }); + + expect(world.grantedBags).toHaveLength(0); + }); +``` + +- [ ] **Step 2: Run the tests to make sure they fail** + +Run: `npm run test --workspace=@ashen-realms/api -- shop.service` +Expected: FAIL — `requirements` undefined, `SHOP_BAG_ALREADY_OWNED` never thrown, bag offers not resolved. + +- [ ] **Step 3: Add the two error codes** + +In `apps/api/src/shops/shop.errors.ts`, extend the union and append two factories: + +```ts +export type ShopErrorCode = + | 'SHOP_NOT_FOUND' + | 'SHOP_DISABLED' + | 'SHOP_OFFER_NOT_FOUND' + | 'SHOP_OFFER_LOCKED' + | 'MERCHANT_REPUTATION_TOO_LOW' + | 'SHOP_BAG_ALREADY_OWNED' + | 'SHOP_INVALID_QUANTITY' + | 'SHOP_INSUFFICIENT_SILVER'; +``` + +```ts +/** + * The specific case of `SHOP_OFFER_LOCKED` where reputation is what is short + * (Playable Slice 0.8.5 §6). + * + * Added alongside the generic code rather than replacing it: a gate on a quest + * flag or an item is still `SHOP_OFFER_LOCKED`, and telling a player to earn + * reputation they do not need would be worse than saying nothing specific. + */ +export function merchantReputationTooLow(): ShopDomainError { + return new ShopDomainError( + 'MERCHANT_REPUTATION_TOO_LOW', + HttpStatus.FORBIDDEN, + 'You have not earned enough standing for this yet.', + ); +} + +/** + * Only the roomiest active bag per category counts (Slice 0.7.5 §6), so a + * second copy grants nothing. Selling one would be taking Silver for nothing. + */ +export function shopBagAlreadyOwned(): ShopDomainError { + return new ShopDomainError( + 'SHOP_BAG_ALREADY_OWNED', + HttpStatus.CONFLICT, + 'You already carry that.', + ); +} +``` + +- [ ] **Step 4: Rework the service** + +In `apps/api/src/shops/shop.service.ts`, add imports: + +```ts +import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity'; +import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; +import { GameConditionType } from '../conditions/game-condition.types'; +import { + describeBagEffect, + describeItemEffect, + describeRequirement, + ShopOfferRequirementDto, +} from './offer-presentation'; +``` + +and extend the error imports with `merchantReputationTooLow, shopBagAlreadyOwned`. + +Extend the DTO: + +```ts +export interface ShopOfferDto { + itemKey: string; + itemName: string; + itemDescription: string; + iconPath: string; + currencyType: string; + price: number; + quantity: number; + /** What buying it does: weapon stats, or a bag's capacity (slice §8). */ + effectSummary: string | null; + /** Why it is locked, and how close the player is (slice §5). Empty when open. */ + requirements: ShopOfferRequirementDto[]; + unlocked: boolean; + affordable: boolean; +} +``` + +Add a private helper that flattens either target into the fields the DTO needs, so the view and the purchase path agree on what an offer *is*: + +```ts + /** + * What an offer sells, whichever kind of thing that is. + * + * Both shapes reduce to a key, a name, an icon and an effect, which is all + * the presentation layer needs -- and keeps the branch on offer kind in one + * place instead of spread across the view and the purchase path. + */ + private resolveTarget(offer: ShopOffer): { + key: string; + name: string; + description: string; + iconPath: string; + effectSummary: string | null; + } | null { + if (offer.itemDefinition) { + return { + key: offer.itemDefinition.key, + name: offer.itemDefinition.name, + description: offer.itemDefinition.description, + iconPath: offer.itemDefinition.iconPath, + effectSummary: describeItemEffect(offer.itemDefinition), + }; + } + if (offer.lootBagDefinition) { + return { + key: offer.lootBagDefinition.key, + name: offer.lootBagDefinition.name, + // A bag definition carries no flavour text of its own; the capacity + // line is the honest description of what it is. + description: describeBagEffect(offer.lootBagDefinition), + iconPath: offer.lootBagDefinition.iconPath, + effectSummary: describeBagEffect(offer.lootBagDefinition), + }; + } + // CHK_shop_offers_single_target makes this unreachable through the + // database. Skipping the row beats rendering an offer that sells nothing. + return null; + } +``` + +Add the gate helper, which is where §7's exception lives: + +```ts + /** + * Whether an offer is open, and why not when it is shut. + * + * `conditions` OR `bypassConditions` -- the whole exception model (slice §7). + * A referral does not lower the requirement; it provides a second, narrower + * door that content opens deliberately. + */ + private async evaluateGate( + context: { characterId: string; npcId: string }, + offer: ShopOffer, + manager?: EntityManager, + ): Promise<{ open: boolean; reputationBlocked: boolean }> { + if (await this.conditions.evaluate(context, offer.conditions, manager)) { + return { open: true, reputationBlocked: false }; + } + + const bypass = offer.bypassConditions ?? []; + if ( + bypass.length > 0 && + (await this.conditions.evaluate(context, bypass, manager)) + ) { + return { open: true, reputationBlocked: false }; + } + + // Which error to raise depends on what is actually short, so the player is + // told to earn reputation only when reputation is the thing missing. + const outcomes = await this.conditions.describe( + context, + offer.conditions, + manager, + ); + const reputationBlocked = outcomes.some( + (outcome) => + !outcome.met && + outcome.condition.type === GameConditionType.REGION_REPUTATION, + ); + + return { open: false, reputationBlocked }; + } +``` + +Rewrite `getShopView`'s offer loop. Faction names are loaded once, not per offer: + +```ts + const offers = await this.dataSource.getRepository(ShopOffer).find({ + where: { shopId: shop.id, enabled: true }, + relations: { itemDefinition: true, lootBagDefinition: true }, + order: { sortOrder: 'ASC' }, + }); + + // One read for the whole view: every reputation requirement needs a + // display name, and offers commonly gate on the same faction. + const factionNames = new Map( + (await this.dataSource.getRepository(ReputationFaction).find()).map( + (faction) => [faction.key, faction.name], + ), + ); + + const context = { characterId, npcId }; + const view: ShopOfferDto[] = []; + for (const offer of offers) { + const target = this.resolveTarget(offer); + if (!target) { + continue; + } + + const gate = await this.evaluateGate(context, offer); + // Described even when open, so the UI can show a requirement the player + // has already met rather than having it vanish on unlock. + const outcomes = await this.conditions.describe(context, offer.conditions); + const requirements = outcomes + .map((outcome) => describeRequirement(outcome, factionNames)) + .filter( + (requirement): requirement is ShopOfferRequirementDto => + requirement !== null, + ); + + view.push({ + itemKey: target.key, + itemName: target.name, + itemDescription: target.description, + iconPath: target.iconPath, + currencyType: offer.currencyType, + price: offer.price, + quantity: offer.quantity, + effectSummary: target.effectSummary, + requirements, + unlocked: gate.open, + affordable: character.silver >= offer.price, + }); + } +``` + +Rewrite the matching and gate check inside `purchase`'s transaction: + +```ts + const offers = await manager.getRepository(ShopOffer).find({ + where: { shopId: shop.id, enabled: true }, + relations: { itemDefinition: true, lootBagDefinition: true }, + }); + const match = offers.find( + (candidate) => this.resolveTarget(candidate)?.key === itemKey, + ); + + if (!match) { + throw shopOfferNotFound(); + } + const target = this.resolveTarget(match)!; + + const gate = await this.evaluateGate( + { characterId, npcId }, + match, + manager, + ); + if (!gate.open) { + throw gate.reputationBlocked + ? merchantReputationTooLow() + : shopOfferLocked(); + } + + if (!match.repeatable && quantity > 1) { + throw shopInvalidQuantity(); + } + // A bag is one object, not a stack: buying two grants nothing extra. + if (match.lootBagDefinitionId !== null && quantity > 1) { + throw shopInvalidQuantity(); + } + + const silverSpent = match.price * quantity; + if (character.silver < silverSpent) { + throw shopInsufficientSilver(); + } + + character.silver -= silverSpent; + await characters.save(character); + + if (match.lootBagDefinitionId !== null) { + await this.grantLootBag(manager, characterId, match.lootBagDefinitionId); + } else { + await this.grantItem( + manager, + characterId, + match.itemDefinitionId!, + match.quantity * quantity, + ); + } + + return { + shopKey: shop.key, + itemKey, + itemName: target.name, + quantity: match.quantity * quantity, + silverSpent, + silverBalance: character.silver, + }; +``` + +Note: the ownership check must run *before* the Silver debit. Put `grantLootBag`'s duplicate check first by writing the method to throw: + +```ts + /** + * Hands over a bag, once. + * + * A second copy of the same bag grants nothing -- only the roomiest active + * bag per category counts (Slice 0.7.5 §6) -- so a repeat purchase is + * refused rather than silently charged. The unique index on + * (character_id, loot_bag_definition_id) is the real guarantee; this check + * is what turns a constraint violation into an explainable domain error. + */ + private async grantLootBag( + manager: { getRepository: DataSource['getRepository'] }, + characterId: string, + lootBagDefinitionId: string, + ): Promise { + const bags = manager.getRepository(CharacterLootBag); + const existing = await bags.findOne({ + where: { characterId, lootBagDefinitionId }, + }); + if (existing) { + throw shopBagAlreadyOwned(); + } + + await bags.save( + bags.create({ characterId, lootBagDefinitionId, active: true }), + ); + } +``` + +Because the debit happens before the grant, move the ownership check above it. Insert this immediately after the `shopInsufficientSilver` check and before `character.silver -= silverSpent`: + +```ts + // Checked before the debit: the transaction would roll the Silver back + // anyway, but failing on the cheap read keeps the error honest about + // what went wrong. + if (match.lootBagDefinitionId !== null) { + const owned = await manager.getRepository(CharacterLootBag).findOne({ + where: { + characterId, + lootBagDefinitionId: match.lootBagDefinitionId, + }, + }); + if (owned) { + throw shopBagAlreadyOwned(); + } + } +``` + +Also import `EntityManager` from `typeorm` at the top if it is not already imported. + +- [ ] **Step 5: Register the new entities** + +In `apps/api/src/shops/shops.module.ts`, extend the `forFeature` list: + +```ts +import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity'; +import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity'; +import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; +``` + +```ts + TypeOrmModule.forFeature([ + Character, + CharacterItem, + CharacterLootBag, + LootBagDefinition, + NpcShop, + ReputationFaction, + ShopOffer, + ]), +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `npm run test --workspace=@ashen-realms/api -- shop.service` +Expected: PASS — the 12 original tests plus the 7 new ones. + +- [ ] **Step 7: Run the whole API suite** + +Run: `npm run test --workspace=@ashen-realms/api` +Expected: PASS. If `npc.service.spec.ts` or `exchange.service.spec.ts` fail on the changed `ConditionOutcome`, their fakes need `actual: null` added — a one-line fix per fixture. + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/shops/ +git commit -m "feat(shops): sell loot bags, honour bypass conditions, explain locks" +``` + +--- + +### Task 5: Seed the three gated offers + +**Files:** +- Modify: `apps/api/src/database/seeds/npc-content.ts` +- Modify: `apps/api/src/database/seeds/vertical-slice.seed.ts` +- Test: `apps/api/src/database/seeds/vertical-slice.seed.spec.ts` + +**Interfaces:** +- Consumes: the `ShopOffer` shape from Task 1. +- Produces: `SHOP_OFFERS` with stable ids and gated entries; the demo character no longer starts with a Trophy Pouch. + +**Content decisions** (spec §4, §5; balancing data, provisional): + +| Offer | Sells | Price | Gate | Bypass | +|---|---|---|---|---| +| Small Healing Potion | item | 12 | none | — | +| Worn Shortsword | item | 30 | none | — | +| Basic Trophy Pouch | bag | 40 | Border Watch Reputation 25 | — | +| Basic Hide Bag | bag | 35 | Border Watch Reputation 40 | `referred-by-south-gate-warden` | +| Bandit Blade | item | 60 | World Renown 3 | — | + +Reachability: the exchange pays 2–12 reputation per trade good, so 25 is roughly three or four successful hunts — visible early, earned soon. 40 is deliberately further out, which is what makes the Slice 0.9 referral feel like a favour. World Renown 3 is **not reachable** with current content and is meant to stay locked until Slice 0.11 (decision 2). + +- [ ] **Step 1: Write the failing seed tests** + +Append to `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`, inside the existing top-level describe: + +```ts + it('seeds two visibly locked bag offers and one renown-gated weapon', async () => { + const gated = SHOP_OFFERS.filter((offer) => offer.conditions.length > 0); + + expect(gated).toHaveLength(3); + expect( + gated.map((offer) => offer.conditions[0].type).sort(), + ).toEqual([ + GameConditionType.REGION_REPUTATION, + GameConditionType.REGION_REPUTATION, + GameConditionType.WORLD_RENOWN, + ]); + }); + + it('gives the Hide Bag a referral bypass for Slice 0.9', async () => { + const hideBag = SHOP_OFFERS.find( + (offer) => offer.lootBagDefinitionId === BASIC_HIDE_BAG_ID, + ); + + expect(hideBag?.bypassConditions).toEqual([ + { + type: GameConditionType.FLAG_SET, + key: 'referred-by-south-gate-warden', + value: true, + }, + ]); + }); + + it('sells every bag as a one-off', async () => { + // A bag is one object; a second copy raises no capacity. + for (const offer of SHOP_OFFERS.filter( + (candidate) => candidate.lootBagDefinitionId !== null, + )) { + expect(offer.repeatable).toBe(false); + } + }); + + it('gives every offer exactly one target', async () => { + for (const offer of SHOP_OFFERS) { + const targets = [offer.itemDefinitionId, offer.lootBagDefinitionId].filter( + (target) => target !== null, + ); + expect(targets).toHaveLength(1); + } + }); + + it('gives every offer a stable id so re-seeding cannot duplicate it', async () => { + const ids = SHOP_OFFERS.map((offer) => offer.id); + + expect(new Set(ids).size).toBe(ids.length); + expect(ids.every((id) => id.length === 36)).toBe(true); + }); +``` + +Add the imports the tests need at the top of the spec file: + +```ts +import { GameConditionType } from '../../conditions/game-condition.types'; +import { BASIC_HIDE_BAG_ID } from './loot-bag-content'; +import { SHOP_OFFERS } from './npc-content'; +``` + +(Check the file's existing imports first — `SHOP_OFFERS` may already be imported.) + +- [ ] **Step 2: Run them to make sure they fail** + +Run: `npm run test --workspace=@ashen-realms/api -- vertical-slice.seed` +Expected: FAIL — `SHOP_OFFERS` entries have no `id`, no `lootBagDefinitionId`, no gated entries. + +- [ ] **Step 3: Rewrite the offer content** + +In `apps/api/src/database/seeds/npc-content.ts`, extend the imports: + +```ts +import { + BASIC_HIDE_BAG_ID, + BASIC_TROPHY_POUCH_ID, +} from './loot-bag-content'; +``` + +Add stable offer ids near the other id constants at the top: + +```ts +// Stable offer ids so re-seeding re-tunes a price instead of inserting a +// second row (AGENTS.md §8). Needed here in particular because an offer has +// two possible targets and no single natural key across both shapes. +export const BORIN_OFFER_IDS = { + potion: 'b4000000-0000-4000-8000-000000000001', + shortsword: 'b4000000-0000-4000-8000-000000000002', + trophyPouch: 'b4000000-0000-4000-8000-000000000003', + hideBag: 'b4000000-0000-4000-8000-000000000004', + banditBlade: 'b4000000-0000-4000-8000-000000000005', +} as const; + +/** The flag the Slice 0.9 warden sets when she sends the player to Borin. */ +export const SOUTH_GATE_REFERRAL_FLAG = 'referred-by-south-gate-warden'; +``` + +Replace the `SeedShopOffer` interface: + +```ts +export interface SeedShopOffer { + id: string; + shopId: string; + itemDefinitionId: string | null; + lootBagDefinitionId: string | null; + currencyType: string; + price: number; + quantity: number; + repeatable: boolean; + sortOrder: number; + conditions: GameCondition[]; + bypassConditions: GameCondition[]; + enabled: boolean; +} +``` + +Replace the whole `SHOP_OFFERS` array and its docblock: + +```ts +/** + * What Borin sells (Playable Slice 0.8 §12, Slice 0.8.5 §4). + * + * Two open offers so Silver always has somewhere to go, and three gated ones + * so reputation visibly changes what the player can do (0.8.5 §1). The locked + * offers stay listed rather than hidden: a reward you can see is a goal, and a + * reward you cannot see is nothing (0.8.5 §5). + * + * Thresholds are balancing data (0.8.5 §4). The exchange pays 2-12 reputation + * per trade good, so 25 is three or four good hunts away -- close enough to + * pull, far enough to matter -- and 40 is deliberately further out, which is + * what makes the Slice 0.9 referral read as a favour rather than a shortcut + * around nothing. + * + * The Bandit Blade sits behind World Renown 3, which today's content cannot + * reach: there is exactly one renown milestone, worth +1. That is intentional + * and not a balancing oversight -- it is the long-horizon goal on the shelf + * until Slice 0.11 adds the milestones that reach it. + */ +export const SHOP_OFFERS: SeedShopOffer[] = [ + { + id: BORIN_OFFER_IDS.potion, + shopId: BORIN_SHOP_ID, + itemDefinitionId: ITEM_IDS['small-healing-potion'], + lootBagDefinitionId: null, + currencyType: 'SILVER', + price: 12, + quantity: 1, + repeatable: true, + sortOrder: 1, + conditions: [], + bypassConditions: [], + enabled: true, + }, + { + id: BORIN_OFFER_IDS.shortsword, + shopId: BORIN_SHOP_ID, + itemDefinitionId: ITEM_IDS['worn-short-sword'], + lootBagDefinitionId: null, + currencyType: 'SILVER', + price: 30, + quantity: 1, + repeatable: true, + sortOrder: 2, + conditions: [], + bypassConditions: [], + enabled: true, + }, + { + // The first gate a player meets, and the one 0.8.5 §5 uses as its worked + // example: 40 Silver, Ashen Fields reputation 25. + id: BORIN_OFFER_IDS.trophyPouch, + shopId: BORIN_SHOP_ID, + itemDefinitionId: null, + lootBagDefinitionId: BASIC_TROPHY_POUCH_ID, + currencyType: 'SILVER', + price: 40, + quantity: 1, + repeatable: false, + sortOrder: 3, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + ], + bypassConditions: [], + enabled: true, + }, + { + // Reputation 40 is out of reach for a new character on purpose. Slice 0.9 + // sends the player here with the warden's word instead, which is the one + // exception the offer system supports (0.8.5 §7). + id: BORIN_OFFER_IDS.hideBag, + shopId: BORIN_SHOP_ID, + itemDefinitionId: null, + lootBagDefinitionId: BASIC_HIDE_BAG_ID, + currencyType: 'SILVER', + price: 35, + quantity: 1, + repeatable: false, + sortOrder: 4, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 40, + }, + ], + bypassConditions: [ + { + type: GameConditionType.FLAG_SET, + key: SOUTH_GATE_REFERRAL_FLAG, + value: true, + }, + ], + enabled: true, + }, + { + id: BORIN_OFFER_IDS.banditBlade, + shopId: BORIN_SHOP_ID, + itemDefinitionId: ITEM_IDS['bandit-blade'], + lootBagDefinitionId: null, + currencyType: 'SILVER', + price: 60, + quantity: 1, + repeatable: true, + sortOrder: 5, + conditions: [ + { + type: GameConditionType.WORLD_RENOWN, + operator: ComparisonOperator.GTE, + value: 3, + }, + ], + bypassConditions: [], + enabled: true, + }, +]; +``` + +- [ ] **Step 4: Update the seed runner** + +In `apps/api/src/database/seeds/vertical-slice.seed.ts`, change the offer upsert conflict target from the item pair to the stable id: + +```ts + // By id, not by (shop, item): an offer may now sell a bag instead of an + // item, so the old pair is null for half the rows and useless as a conflict + // target. Migration 1796 cleared the anonymous 0.8 rows for exactly this. + await shopOfferRepository.upsert(SHOP_OFFERS, ['id']); +``` + +Then change the demo bag grant. Replace the loop over both bag ids with a single-bag version and rewrite its comment: + +```ts + // ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag + // and no longer starts with the Trophy Pouch. + // + // The pouch is now a reputation-gated offer (0.8.5 §4) and handing it over + // for free would make the slice's own showcase pointless. The Hide Bag stays + // until Slice 0.9 grants it through the warden's referral -- removing both + // now would drop HIDE capacity to the bagless default of 1 with no way to + // raise it, and the hunting loop would be unplayable in between. + // + // Delete this block once 0.9 hands the Hide Bag over in the quest. + const characterLootBagRepository = dataSource.getRepository(CharacterLootBag); + const existingBag = await characterLootBagRepository.findOneBy({ + characterId: DEMO_CHARACTER_ID, + lootBagDefinitionId: BASIC_HIDE_BAG_ID, + }); + if (!existingBag) { + await characterLootBagRepository.insert({ + characterId: DEMO_CHARACTER_ID, + lootBagDefinitionId: BASIC_HIDE_BAG_ID, + active: true, + }); + } +``` + +Remove the now-unused `BASIC_TROPHY_POUCH_ID` import from this file if nothing else uses it (`LOOT_BAG_DEFINITIONS` still seeds the definition itself — only the *character's* copy goes away). + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npm run test --workspace=@ashen-realms/api -- vertical-slice.seed` +Expected: PASS. + +- [ ] **Step 6: Run the whole API suite and the linter** + +Run: `npm run test --workspace=@ashen-realms/api` +Run: `npm run lint --workspace=@ashen-realms/api` +Expected: both PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/database/seeds/ +git commit -m "feat(content): gate the trophy pouch, hide bag and bandit blade" +``` + +--- + +### Task 6: The merchant screen shows requirements and progress + +**Files:** +- Modify: `apps/web/src/app/core/api/game-api.models.ts` +- Modify: `apps/web/src/app/features/npc/merchant.store.ts` +- Modify: `apps/web/src/app/features/npc/merchant-page.component.html` +- Modify: `apps/web/src/app/features/npc/merchant-page.component.scss` +- Test: `apps/web/src/app/features/npc/merchant-page.component.spec.ts` + +**Interfaces:** +- Consumes: the extended `ShopOfferDto` from Task 4. +- Produces: `ShopOfferRequirement` on the web side; the shop row renders name, icon, price, effect, requirement, current value and locked state (spec §8). + +- [ ] **Step 1: Write the failing component test** + +Read `apps/web/src/app/features/npc/merchant-page.component.spec.ts` first to match its existing harness (how it stubs `GameApiService` and builds a `ShopView`). Then append: + +```ts + it('shows the requirement and the current value on a locked offer', async () => { + // A visible lock with a number attached is a goal; a hidden offer is not + // (slice §5). + const { fixture } = await renderMerchantWithShop({ + offers: [ + { + itemKey: 'basic-trophy-pouch', + itemName: 'Basic Trophy Pouch', + itemDescription: 'Capacity: 5 Raider Trophies', + iconPath: '/images/items/basic-trophy-pouch.png', + currencyType: 'SILVER', + price: 40, + quantity: 1, + effectSummary: 'Capacity: 5 Raider Trophies', + requirements: [ + { + label: 'Requires Border Watch Reputation 25', + current: 14, + required: 25, + met: false, + }, + ], + unlocked: false, + affordable: true, + }, + ], + }); + + const row = fixture.nativeElement.querySelector( + '[data-shop-item="basic-trophy-pouch"]', + ); + expect(row.textContent).toContain('Requires Border Watch Reputation 25'); + expect(row.textContent).toContain('Current: 14'); + expect(row.querySelector('button').disabled).toBe(true); + }); + + it('shows what a purchase does', async () => { + const { fixture } = await renderMerchantWithShop({ + offers: [ + { + itemKey: 'bandit-blade', + itemName: 'Bandit Blade', + itemDescription: 'A roughly serrated blade, forged for quick raids.', + iconPath: '/images/items/bandit-blade.png', + currencyType: 'SILVER', + price: 60, + quantity: 1, + effectSummary: '11 Weapon Damage, +1 Attack', + requirements: [ + { + label: 'Requires World Renown 3', + current: 1, + required: 3, + met: false, + }, + ], + unlocked: false, + affordable: true, + }, + ], + }); + + const row = fixture.nativeElement.querySelector( + '[data-shop-item="bandit-blade"]', + ); + expect(row.textContent).toContain('11 Weapon Damage, +1 Attack'); + }); + + it('does not label an open offer as requiring anything unmet', async () => { + const { fixture } = await renderMerchantWithShop({ + offers: [ + { + itemKey: 'small-healing-potion', + itemName: 'Small Healing Potion', + itemDescription: 'A bitter draught.', + iconPath: '/images/items/potion.png', + currencyType: 'SILVER', + price: 12, + quantity: 1, + effectSummary: null, + requirements: [], + unlocked: true, + affordable: true, + }, + ], + }); + + const row = fixture.nativeElement.querySelector( + '[data-shop-item="small-healing-potion"]', + ); + expect(row.querySelector('[data-requirement]')).toBeNull(); + expect(row.querySelector('button').disabled).toBe(false); + }); +``` + +If the spec file has no `renderMerchantWithShop` helper, write one modelled on the existing setup in that file — a function taking a partial `ShopView`, stubbing `getNpcInteraction` to return an interaction whose `availableActions` include `OPEN_SHOP`, stubbing `getShop` to return the view, rendering the component and clicking the shop action button. + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `npm run test --workspace=@ashen-realms/web -- merchant-page` +Expected: FAIL — no requirement text is rendered. + +- [ ] **Step 3: Extend the frontend model** + +In `apps/web/src/app/core/api/game-api.models.ts`, add above `ShopOfferView` and extend it: + +```ts +/** One gate on an offer, as the server phrased it (slice 0.8.5 §5, §8). */ +export interface ShopOfferRequirement { + label: string; + current: number | null; + required: number | null; + met: boolean; +} + +export interface ShopOfferView { + itemKey: string; + itemName: string; + itemDescription: string; + iconPath: string; + currencyType: string; + price: number; + quantity: number; + effectSummary: string | null; + requirements: ShopOfferRequirement[]; + unlocked: boolean; + affordable: boolean; +} +``` + +- [ ] **Step 4: Render it** + +In `apps/web/src/app/features/npc/merchant-page.component.html`, replace the `shop-row__naming` div and add a requirement block. The row becomes: + +```html +
  • + +
    + {{ offer.itemName }} + {{ + offer.itemDescription + }} + @if (offer.effectSummary) { + {{ + offer.effectSummary + }} + } + @for (requirement of offer.requirements; track requirement.label) { + + {{ requirement.label }} + @if (!requirement.met && requirement.current !== null) { + · Current: {{ requirement.current }} + } + + } +
    + {{ offer.price }} Silver + +
  • +``` + +- [ ] **Step 5: Style it** + +Append to `apps/web/src/app/features/npc/merchant-page.component.scss`: + +```scss +.shop-row__effect { + color: var(--ar-text); + font-size: 0.75rem; +} + +/* A lock the player can measure themselves against, not a dead end + (slice 0.8.5 §5). */ +.shop-row__requirement { + color: var(--ar-gold); + font-size: 0.75rem; +} + +.shop-row__requirement--met { + color: var(--ar-text-muted); +} + +.shop-row__current { + color: var(--ar-text-muted); +} +``` + +If `--ar-gold` is not defined in the token set, use `--ar-accent`; check `apps/web/src/styles.scss` (or wherever the tokens live) before choosing. `--ar-gold` is already used by `.shop-row__price` in this file, so it should exist. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `npm run test --workspace=@ashen-realms/web -- merchant-page` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/features/npc/merchant-page.component.html apps/web/src/app/features/npc/merchant-page.component.scss apps/web/src/app/features/npc/merchant-page.component.spec.ts +git commit -m "feat(web): show offer requirements, current progress and effects" +``` + +--- + +### Task 7: Lightweight feedback when an offer unlocks + +**Files:** +- Modify: `apps/web/src/app/features/npc/merchant.store.ts` +- Modify: `apps/web/src/app/features/npc/merchant-page.component.html` +- Test: `apps/web/src/app/features/npc/merchant.store.spec.ts` + +**Interfaces:** +- Consumes: `ShopView.offers[].unlocked` from Task 4. +- Produces: `MerchantStore.newlyUnlocked: Signal` (offer names), `MerchantStore.dismissUnlocked(): void`. + +**Why no server event:** `tradeSelected()` already re-reads the shop after a trade, because the trade changed Silver, goods, capacity and possibly renown at once. The before/after `unlocked` flags are therefore already in hand — comparing them locally is the smallest thing that satisfies spec §9, and it adds no endpoint, no event channel and no state the server has to remember. Spec §9 explicitly asks for lightweight feedback and no modal. + +- [ ] **Step 1: Write the failing store test** + +Read `apps/web/src/app/features/npc/merchant.store.spec.ts` to match its existing API stubbing, then append: + +```ts + it('announces an offer that a trade just unlocked', async () => { + // Reputation earned by the trade opened the pouch. The player should learn + // that without hunting for it (slice §9). + const api = createApi({ + shopSequence: [ + shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]), + shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', true)]), + ], + }); + const store = createStore(api); + + await store.load('borin-quartermaster'); + store.setQuantity('ash-pelt', 1); + await store.tradeSelected(); + + expect(store.newlyUnlocked()).toEqual(['Basic Trophy Pouch']); + }); + + it('says nothing when a trade unlocks nothing', async () => { + const api = createApi({ + shopSequence: [ + shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]), + shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]), + ], + }); + const store = createStore(api); + + await store.load('borin-quartermaster'); + store.setQuantity('ash-pelt', 1); + await store.tradeSelected(); + + expect(store.newlyUnlocked()).toEqual([]); + }); + + it('does not re-announce an offer that was already open', async () => { + const api = createApi({ + shopSequence: [ + shopView([offer('small-healing-potion', 'Small Healing Potion', true)]), + shopView([offer('small-healing-potion', 'Small Healing Potion', true)]), + ], + }); + const store = createStore(api); + + await store.load('borin-quartermaster'); + store.setQuantity('ash-pelt', 1); + await store.tradeSelected(); + + expect(store.newlyUnlocked()).toEqual([]); + }); +``` + +Write the `offer()` and `shopView()` helpers in that file to build a full `ShopOfferView` (including `effectSummary: null` and `requirements: []`) and a `ShopView`; extend the existing api stub so `getShop` returns successive entries of `shopSequence`. + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `npm run test --workspace=@ashen-realms/web -- merchant.store` +Expected: FAIL — `store.newlyUnlocked is not a function`. + +- [ ] **Step 3: Add the comparison to the store** + +In `apps/web/src/app/features/npc/merchant.store.ts`, add the signal beside the others: + +```ts + private readonly newlyUnlockedState = signal([]); +``` + +```ts + readonly newlyUnlocked = this.newlyUnlockedState.asReadonly(); +``` + +Add the two new error codes to `ERROR_MESSAGES`: + +```ts + MERCHANT_REPUTATION_TOO_LOW: + 'You have not earned enough standing for this yet.', + SHOP_BAG_ALREADY_OWNED: 'You already carry that.', +``` + +Clear the announcement in `load()` next to the other resets: + +```ts + this.newlyUnlockedState.set([]); +``` + +Add the helper: + +```ts + /** + * Offer names that went from locked to open (slice §9). + * + * Derived from two shop reads the store already performs rather than from a + * server event: the trade re-fetches the shop anyway, so the before/after + * state is in hand, and a notification the server has to remember would be + * more machinery than a one-line message is worth. + */ + private unlockedSince( + before: ShopView | null, + after: ShopView | null, + ): string[] { + if (!before || !after) { + return []; + } + + const wasLocked = new Set( + before.offers + .filter((offer) => !offer.unlocked) + .map((offer) => offer.itemKey), + ); + + return after.offers + .filter((offer) => offer.unlocked && wasLocked.has(offer.itemKey)) + .map((offer) => offer.itemName); + } + + dismissUnlocked(): void { + this.newlyUnlockedState.set([]); + } +``` + +In `tradeSelected()`, capture the previous shop before the re-read and set the announcement after it. Replace the shop re-read block: + +```ts + const previousShop = this.shopState(); + const refreshedShop = previousShop + ? await firstValueFrom(this.api.getShop(npcKey)) + : null; + this.shopState.set(refreshedShop); + this.newlyUnlockedState.set( + this.unlockedSince(previousShop, refreshedShop), + ); +``` + +Also clear it at the start of `buy()` alongside `actionErrorState`, so a purchase does not leave a stale unlock banner on screen: + +```ts + this.newlyUnlockedState.set([]); +``` + +- [ ] **Step 4: Render the announcement** + +In `apps/web/src/app/features/npc/merchant-page.component.html`, immediately after the `merchant__purse` paragraph inside the shop panel: + +```html + @if (store.newlyUnlocked().length > 0) { +

    + @for (name of store.newlyUnlocked(); track name) { + New merchant offer unlocked: {{ name }} + } + +

    + } +``` + +And append to the SCSS: + +```scss +/* A line, not a modal (slice 0.8.5 §9). */ +.shop-unlocked { + display: flex; + flex-wrap: wrap; + gap: var(--ar-space-2); + align-items: center; + margin: 0; + color: var(--ar-gold); + font-size: var(--ar-font-sm); +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npm run test --workspace=@ashen-realms/web -- merchant` +Expected: PASS. + +- [ ] **Step 6: Run the whole web suite** + +Run: `npm run test --workspace=@ashen-realms/web` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/app/features/npc/ +git commit -m "feat(web): announce offers a trade just unlocked" +``` + +--- + +### Task 8: Full verification and documentation + +**Files:** +- Modify: `docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md` (tick the acceptance criteria) +- Modify: `docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md` (point at the concrete bypass mechanism) + +- [ ] **Step 1: Run everything** + +```bash +npm run test --workspace=@ashen-realms/api +npm run test --workspace=@ashen-realms/web +npm run lint --workspace=@ashen-realms/api +npm run build +``` + +Expected: all PASS. Baseline before this branch was API 410 tests / 49 suites and web 292 tests / 27 suites; the totals should now be higher, with zero failures. + +- [ ] **Step 2: Verify the migration against a real database** + +If a PostgreSQL instance is configured (`apps/api/.env`), run: + +```bash +npm run db:migrate +npm run db:seed +npm run db:seed +``` + +Expected: the migration applies cleanly, and seeding twice leaves five offers, not ten. If no database is reachable, say so plainly in the final report rather than claiming this step passed. + +- [ ] **Step 3: Tick the acceptance criteria** + +In `docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md` §11, change `- [ ]` to `- [x]` for each criterion, and append one line under the list recording where the Renown 3 offer stands: + +```markdown +> **Note (implementation):** The Bandit Blade offer is gated on World Renown 3, +> which current content cannot reach — there is one renown milestone, worth +1. +> It is deliberately left visible and locked as a long-horizon goal until +> Slice 0.11 adds the milestones that reach it (§5: visible rewards create +> goals). +``` + +- [ ] **Step 4: Point Slice 0.9 at the mechanism that now exists** + +In `docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md` §6, replace the line "The grant should use the quest/referral exception supported by Slice 0.8.5." with: + +```markdown +The grant uses the referral exception built in Slice 0.8.5: the Basic Hide Bag +offer (`BORIN_OFFER_IDS.hideBag`) carries + +```text +bypassConditions: [{ type: FLAG_SET, key: 'referred-by-south-gate-warden' }] +``` + +so setting that flag on the character's Borin state opens the offer without +changing any reputation. The offer's own gate stays at Border Watch +Reputation 40. +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/ +git commit -m "docs: record 0.8.5 acceptance and the 0.9 referral mechanism" +``` + +--- + +## Spec Coverage + +| Spec section | Covered by | +|---|---| +| §2 No level gates | Tasks 4–5 — every new gate is reputation, renown or a flag; `requiredLevel` is untouched and unused. | +| §3 Offer requirement model | Task 1 (`conditions` reused, `bypassConditions` added); no new engine (§11). | +| §4 Initial locked offers | Task 5 — Hide Bag, Trophy Pouch, plus the optional Bandit Blade. | +| §5 Visible locked offers | Task 4 (`requirements` + `current`), Task 6 (rendering, disabled Buy). | +| §6 Server validation | Task 4 — gate before price, `MERCHANT_REPUTATION_TOO_LOW`, English message. | +| §7 Referral support | Task 1 (`bypassConditions`), Task 4 (OR evaluation), Task 5 (the flag on the Hide Bag). | +| §8 UI requirements | Task 6 — name, icon, price, effect, requirement, current value, locked state. | +| §9 Feedback on unlock | Task 7 — one line, no modal. | +| §10 Tests | Tasks 2–7; the eight listed cases map to named tests, see below. | +| §11 Acceptance criteria | Task 8. | +| §12 Out of scope | Nothing in this plan adds haggling, daily shops, decay or per-offer currencies. | + +Spec §10's eight required tests, each with its home: + +1. *Offer with no requirement can be purchased* — existing `debits Silver and grants the item` (Task 4 keeps it green). +2. *Insufficient regional reputation blocks purchase* — `names reputation as the reason when a reputation gate is what blocks` (Task 4). +3. *Sufficient regional reputation allows purchase* — `grants a bag rather than stacking it as an item` (Task 4; the fixture's gate is open). +4. *Insufficient World Renown blocks purchase* — `shows the requirement and the current value on a locked offer` covers the display; the block itself is the same code path as case 2, and `describeRequirement` renown coverage is in Task 3. +5. *Quest flag can unlock configured tutorial offer* — `opens an offer whose bypass holds even though its conditions do not` (Task 4). +6. *Disabled client state is not trusted by backend* — existing `refuses a locked offer even when the request asks for it directly` (Task 4 keeps it green). +7. *Price is still required even when reputation condition is met* — `still charges the price when a requirement is met` (Task 4). +8. *Unlock state changes after a successful trade-in raises reputation* — `announces an offer that a trade just unlocked` (Task 7). diff --git a/docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md b/docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md new file mode 100644 index 0000000..4ba6e4b --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md @@ -0,0 +1,100 @@ +# Slice 0.8.5 — Rechercheergebnisse (Vorstufe zum Plan) + +**Spec:** `docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md` +**Branch/Worktree:** `slice/0.8.5-reputation-gated-merchant-offers` in `.claude/worktrees/slice-0.8.5-reputation-gated-offers` +**Baseline:** API 410 Tests / 49 Suites grün, Web 292 Tests / 27 Suites grün. + +## Was Slice 0.8 bereits geliefert hat + +Der Großteil der Mechanik existiert schon — 0.8.5 ist überwiegend Content plus +Präsentation, nicht Neubau. + +- `apps/api/src/conditions/game-condition.types.ts` — `GameConditionType` mit + `REGION_REPUTATION`, `WORLD_RENOWN`, `FLAG_SET`, `HAS_ITEM` als + `SUPPORTED_CONDITION_TYPES`; `ComparisonOperator` + `compare()`. +- `apps/api/src/conditions/game-condition.service.ts` — `evaluate()` (UND-Logik, + fail-closed) und `describe()` (pro Bedingung `{condition, met}`). `describe()` + hat **noch keinen Aufrufer** außer seinem eigenen Spec — es wurde explizit für + 0.8.5 gebaut. +- `shop_offers.conditions` (jsonb) existiert in Migration + `1795000000000-CreateNpcSystem.ts`; Seed setzt überall `conditions: []`. +- `ShopService.getShopView()` liefert bereits `unlocked` + `affordable`; + `purchase()` wirft `SHOP_OFFER_LOCKED` (403) serverseitig. Frontend rendert + „Locked“/„Too costly“ und mappt `SHOP_OFFER_LOCKED` in `merchant.store.ts`. + +Fazit: „Server rejects purchases when requirements are not met“ (§11) ist +faktisch schon erfüllt und durch `shop.service.spec.ts` getestet. Fehlend sind +Content, Anforderungs-Text/Fortschritt in der UI, das Bag-Verkaufsformat, der +Referral-Bypass und das Unlock-Feedback. + +## Zentrale Architektur-Hürde: Taschen sind keine Items + +`shop_offers.item_definition_id` ist NOT NULL mit FK auf `item_definitions`. +Die beiden Vorzeige-Angebote der Spec (§4) sind aber `LootBagDefinition`-Zeilen +(`apps/api/src/loot-bags/entities/loot-bag-definition.entity.ts`) — bewusst +keine Items (0.7.5 §6: nie ausgerüstet, kein Loot, keine Kampfwerte). Der +0.8-Seed sagt das wörtlich voraus: + +> „a bag is a `LootBagDefinition` rather than an `ItemDefinition`, so selling +> one needs an offer shape this slice has no reason to build.“ + +**Nötige Migration:** `item_definition_id` nullable machen, `loot_bag_definition_id` +(nullable, FK → `loot_bag_definitions`, ON DELETE RESTRICT) ergänzen, CHECK +„genau eine der beiden gesetzt“, und der Unique-Index +`IDX_shop_offers_shop_item` braucht ein Gegenstück für Taschen. `ShopService` +muss beim Kauf einer Tasche eine `CharacterLootBag`-Zeile anlegen statt +`CharacterItem` zu stapeln — idempotent, weil `IDX_character_loot_bags_character_definition` +unique ist und 0.9 §11 „bag grant is idempotent“ verlangt. + +## Getroffene Entscheidungen (vom Nutzer bestätigt) + +1. **Starter-Bags im Seed:** Nur die Trophy Pouch aus dem Demo-Seed entfernen + (`vertical-slice.seed.ts:376-392`). Die Hide Bag bleibt vorerst geseedet, + bis 0.9 sie über die Quest-Referral vergibt. +2. **Hide-Bag-Gate:** Ruf-Gate **oder** Referral-Flag + (`referred-by-south-gate-warden`, 0.9 §5). Braucht ein klar benanntes + Bypass-Feld am Angebot — ausdrücklich *keine* generische Regel-Engine + (§3, §7, §11 „No generalized rules engine“). Vorschlag: + `shop_offers.bypass_conditions` (jsonb) mit ODER-Semantik gegen + `conditions` — kleinste Erweiterung, die den Ausnahmefall trägt. +3. **Drittes Angebot:** Bandit Blade (existiert bereits als ItemDefinition, + `ITEM_IDS['bandit-blade']`, weaponDamage 11 / +1 Attack) hinter + `WORLD_RENOWN >= 3`. + +## Balancing-Anhaltspunkte + +- Exchange zahlt 2/3/5/12 Ruf pro Ware (`npc-content.ts` `EXCHANGE_RULES`). +- Reputationsränge: 0 Stranger, 100 Tolerated, 250 Known, 500 Recognized, + 800 Trusted, 1200 Esteemed (`reputation-rank.ts`). Die Gates sind numerisch, + nicht rangbasiert — die Spec-Beispiele nennen „Reputation 25“. +- Renown: `Character.renown`, aktuell nur ein Milestone (`first-goods-returned`, + +1). **Offen:** Renown 3 ist mit dem heutigen Content nicht erreichbar — + entweder niedrigerer Schwellwert oder das Angebot bleibt bis 0.11 sichtbar + gesperrt (was §5 „visible rewards create goals“ sogar entspricht). +- Spec-Beispiel §5 wörtlich: Trophy Pouch, 40 Silber, Requires Ashen Fields + Reputation 25. + +## Betroffene Dateien + +| Datei | Rolle | +|---|---| +| `apps/api/src/database/migrations/1796*-SellableLootBags.ts` | neu: Bag-Angebote + Bypass-Spalte | +| `apps/api/src/shops/entities/shop-offer.entity.ts` | nullable Item-FK, Bag-FK, `bypassConditions` | +| `apps/api/src/shops/shop.service.ts` | Bag-Grant, ODER-Bypass, Anforderungs-Beschreibung via `describe()` | +| `apps/api/src/shops/shop.errors.ts` | `MERCHANT_REPUTATION_TOO_LOW` (§6) statt/neben `SHOP_OFFER_LOCKED` | +| `apps/api/src/database/seeds/npc-content.ts` | drei gesperrte Angebote | +| `apps/api/src/database/seeds/vertical-slice.seed.ts:376` | Trophy Pouch aus Demo-Seed nehmen | +| `apps/web/src/app/core/api/game-api.models.ts:433` | `requirements` am `ShopOfferView` | +| `apps/web/src/app/features/npc/merchant-page.component.{html,scss}` | Anforderung + aktueller Wert je Zeile | +| `apps/web/src/app/features/npc/merchant.store.ts` | Unlock-Feedback nach Trade (§9) | + +## Noch offen vor dem Plan + +- Wortlaut/Träger des Unlock-Feedbacks (§9): Der Store lädt den Shop nach einem + Trade ohnehin neu (`merchant.store.ts:209-212`), also lässt sich ein + Vorher/Nachher-Vergleich der `unlocked`-Flags lokal ziehen — ohne neues + Server-Event. Das ist die kleinste Lösung und braucht keinen Endpoint. +- Ob `MERCHANT_REPUTATION_TOO_LOW` den bestehenden `SHOP_OFFER_LOCKED` ersetzt + oder ergänzt. Ersetzen bricht den vorhandenen Frontend-Mapping-Eintrag und + einen Test; ergänzen (spezifischer Code, wenn die verletzte Bedingung eine + Reputationsbedingung ist) erfüllt §6 wörtlich ohne Regression. From ab8bbeae5d34923aba9652359eaf069820a16a0f Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 21:06:46 +0200 Subject: [PATCH 14/18] fix(shop): render a bag offer's capacity line once, not twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveTarget` gave a bag target the same string for `description` and `effectSummary`, and the shop row renders both, so the slice's two flagship offers showed "Capacity: 5 Raider Trophies" on consecutive lines. Spec §5's worked example shows it once. A bag definition carries no flavour text of its own, so the description is now empty and the row omits the span entirely rather than emitting an empty one. Co-Authored-By: Claude Opus 5 --- apps/api/src/shops/shop.service.ts | 8 +++++--- .../features/npc/merchant-page.component.html | 8 +++++--- .../npc/merchant-page.component.spec.ts | 19 +++++++++++++++++-- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/api/src/shops/shop.service.ts b/apps/api/src/shops/shop.service.ts index 81f40e5..19e5a79 100644 --- a/apps/api/src/shops/shop.service.ts +++ b/apps/api/src/shops/shop.service.ts @@ -307,9 +307,11 @@ export class ShopService { definitionId: offer.lootBagDefinition.id, key: offer.lootBagDefinition.key, name: offer.lootBagDefinition.name, - // A bag definition carries no flavour text of its own; the capacity - // line is the honest description of what it is. - description: describeBagEffect(offer.lootBagDefinition), + // A bag definition carries no flavour text of its own. The capacity + // line is the whole of what there is to say about it, and it is already + // carried by `effectSummary`; repeating it here would render it twice + // (slice §5 shows the line once). + description: '', iconPath: offer.lootBagDefinition.iconPath, effectSummary: describeBagEffect(offer.lootBagDefinition), }; diff --git a/apps/web/src/app/features/npc/merchant-page.component.html b/apps/web/src/app/features/npc/merchant-page.component.html index cad6e7a..3841a9c 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.html +++ b/apps/web/src/app/features/npc/merchant-page.component.html @@ -234,9 +234,11 @@ />
    {{ offer.itemName }} - {{ - offer.itemDescription - }} + @if (offer.itemDescription) { + {{ + offer.itemDescription + }} + } @if (offer.effectSummary) { {{ offer.effectSummary diff --git a/apps/web/src/app/features/npc/merchant-page.component.spec.ts b/apps/web/src/app/features/npc/merchant-page.component.spec.ts index d3aeaef..a8cc064 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.spec.ts +++ b/apps/web/src/app/features/npc/merchant-page.component.spec.ts @@ -232,6 +232,11 @@ async function renderMerchantWithShop( return { fixture, element, store }; } +/** How many times `needle` shows up in `haystack` -- `toContain` cannot say. */ +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + describe('MerchantPageComponent', () => { afterEach(() => TestBed.resetTestingModule()); @@ -383,13 +388,15 @@ describe('MerchantPageComponent', () => { it('shows the requirement and the current value on a locked offer', async () => { // A visible lock with a number attached is a goal; a hidden offer is not - // (slice §5). + // (slice §5). Shaped like the real Trophy Pouch offer: a bag definition + // carries no flavour text, so the API sends an empty description and the + // capacity line arrives once, as the effect. const { fixture } = await renderMerchantWithShop({ offers: [ { itemKey: 'basic-trophy-pouch', itemName: 'Basic Trophy Pouch', - itemDescription: 'Capacity: 5 Raider Trophies', + itemDescription: '', iconPath: '/images/items/basic-trophy-pouch.png', currencyType: 'SILVER', price: 40, @@ -414,6 +421,14 @@ describe('MerchantPageComponent', () => { ); expect(row.textContent).toContain('Requires Border Watch Reputation 25'); expect(row.textContent).toContain('Current: 14'); + // Once, not twice: slice §5's worked example shows the capacity line a + // single time. + expect( + countOccurrences(row.textContent, 'Capacity: 5 Raider Trophies'), + ).toBe(1); + // An empty description renders nothing at all, rather than an empty span + // that would let a duplicated capacity line back in unnoticed. + expect(row.querySelector('.shop-row__description')).toBeNull(); expect(row.querySelector('button').disabled).toBe(true); }); From f0edf89ad34ae9c8d5777318d29faca23574f9dc Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 21:07:05 +0200 Subject: [PATCH 15/18] fix(shop): make purchase resolve offers the same way the view does `purchase` listed a shop's offers with no ordering while `getShopView` orders by `sortOrder`, so the two paths answered "which offer does this key mean" by different rules, one of them at the database's discretion. Harmless today because item and bag keys are disjoint, but not a difference worth keeping. The faction lookup behind requirement labels also read every faction while the condition engine only matches enabled ones, so a gate on a disabled faction would have shown that faction's name next to a requirement the engine treats as unmeetable. Co-Authored-By: Claude Opus 5 --- apps/api/src/shops/shop.service.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/api/src/shops/shop.service.ts b/apps/api/src/shops/shop.service.ts index 19e5a79..c8726ee 100644 --- a/apps/api/src/shops/shop.service.ts +++ b/apps/api/src/shops/shop.service.ts @@ -117,11 +117,16 @@ export class ShopService { }); // One read for the whole view: every reputation requirement needs a - // display name, and offers commonly gate on the same faction. + // display name, and offers commonly gate on the same faction. Restricted + // to enabled factions because that is what the condition engine evaluates + // against -- naming a faction the engine treats as absent would describe a + // requirement that can never be met. const factionNames = new Map( - (await this.dataSource.getRepository(ReputationFaction).find()).map( - (faction) => [faction.key, faction.name], - ), + ( + await this.dataSource + .getRepository(ReputationFaction) + .find({ where: { enabled: true } }) + ).map((faction) => [faction.key, faction.name]), ); const context = { characterId, npcId }; @@ -205,6 +210,9 @@ export class ShopService { const offers = await manager.getRepository(ShopOffer).find({ where: { shopId: shop.id, enabled: true }, relations: { itemDefinition: true, lootBagDefinition: true }, + // Same order as the view: both paths answer "which offer does this key + // mean", so neither may answer it from an arbitrary row order. + order: { sortOrder: 'ASC' }, }); let match: ShopOffer | undefined; let target: OfferTarget | undefined; From a454cf955ec98333ebe8ed7a62008bcf711aa7e0 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 21:07:25 +0200 Subject: [PATCH 16/18] =?UTF-8?q?test(shop):=20make=20three=20nominal=20sp?= =?UTF-8?q?ec=20=C2=A710=20cases=20exercise=20what=20they=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 7 ("price is still required even when the reputation condition is met") and case 3 ("sufficient regional reputation allows purchase") both built fixtures with `conditions: []`. With no requirement present, none can be met, so neither test touched the gate it was named after. Both now carry a satisfied REGION_REPUTATION condition. Case 4 ("insufficient World Renown blocks purchase") had no test at all. It matters because a renown block must surface as SHOP_OFFER_LOCKED rather than MERCHANT_REPUTATION_TOO_LOW -- renown is not the merchant's regard, and telling the player to go and earn reputation would point at the wrong bar. Verified by widening the reputation-blame check to include WORLD_RENOWN, which fails the new test alone. Also pins a bag offer's description to empty, so the duplicate capacity line cannot come back through the API side. Co-Authored-By: Claude Opus 5 --- apps/api/src/shops/shop.service.spec.ts | 66 +++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/apps/api/src/shops/shop.service.spec.ts b/apps/api/src/shops/shop.service.spec.ts index 520ff9e..0586d6b 100644 --- a/apps/api/src/shops/shop.service.spec.ts +++ b/apps/api/src/shops/shop.service.spec.ts @@ -491,10 +491,27 @@ describe('ShopService', () => { effectSummary: 'Capacity: 5 Raider Trophies', unlocked: true, }); + // The capacity line is sent once, as the effect. Repeating it as the + // description makes the row render it twice (slice §5). + expect(view.offers[0].itemDescription).toBe(''); }); it('grants a bag rather than stacking it as an item', async () => { - const world = createWorld({ bagOffer: true, silver: 100 }); + // Slice §10 case 3: the reputation gate is present *and* satisfied, so the + // purchase goes through. A fixture with no conditions at all would pass + // without the gate ever being consulted. + const world = createWorld({ + bagOffer: true, + silver: 100, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + ], + }); const result = await world.service.purchase( CHARACTER_ID, @@ -615,6 +632,36 @@ describe('ShopService', () => { expect(world.grantedItems).toHaveLength(0); }); + it('blocks a renown-gated offer without blaming merchant reputation', async () => { + // Slice §10 case 4, and the discrimination this branch introduced: World + // Renown is not the merchant's regard, so a renown block must read as a + // plain lock. Sending this player off to trade pelts with Borin would be + // pointing at the wrong bar entirely. + const world = createWorld({ + silver: 1000, + unmetConditionTypes: [GameConditionType.WORLD_RENOWN], + conditions: [ + { + type: GameConditionType.WORLD_RENOWN, + operator: ComparisonOperator.GTE, + value: 3, + }, + ], + }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'small-healing-potion', + 1, + ), + ).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' }); + + expect(world.character.silver).toBe(1000); + expect(world.grantedItems).toHaveLength(0); + }); + it('opens an offer whose bypass holds even though its conditions do not', async () => { // The Slice 0.9 referral: the warden's word is worth more than the // reputation the player has not earned yet (slice §7). @@ -653,8 +700,21 @@ describe('ShopService', () => { }); it('still charges the price when a requirement is met', async () => { - // Reputation opens the offer; it does not pay for it (slice §10). - const world = createWorld({ bagOffer: true, silver: 10 }); + // Reputation opens the offer; it does not pay for it (slice §10). The + // requirement has to actually exist and hold for that to be the thing + // under test. + const world = createWorld({ + bagOffer: true, + silver: 10, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + ], + }); await expect( world.service.purchase( From 1a7018d790881e6ad90641bf795c7870f1a5d7b7 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 21:07:26 +0200 Subject: [PATCH 17/18] test(seed): prove a re-seed leaves five offers at the tuned numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five offer tests this branch added all read the SHOP_OFFERS constant; none ran the seed, so the one guarantee the stable-id design exists to provide -- re-seeding does not duplicate content -- was covered by nothing. This runs `seedVisibleVerticalSlice` twice against an in-memory shop-offer repository and asserts the five stable ids survive. Confirmed catchable: reverting the conflict target to ['shopId', 'itemDefinitionId'] leaves 4 rows under the fake, because both bag offers carry `itemDefinitionId: null` and collapse into one. On Postgres it would fail outright. The prices (12/30/40/35/60) and thresholds (reputation 25 and 40, renown 3) are pinned in the same test: AGENTS.md §39 forbids silent rebalancing, and the structural tests never looked at a number. Co-Authored-By: Claude Opus 5 --- .../seeds/vertical-slice.seed.spec.ts | 101 +++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index 9d797b3..f3bf441 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -20,7 +20,10 @@ import { NpcDefinition } from '../../npcs/entities/npc-definition.entity'; import { NpcShop } from '../../shops/entities/npc-shop.entity'; import { ShopOffer } from '../../shops/entities/shop-offer.entity'; import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants'; -import { GameConditionType } from '../../conditions/game-condition.types'; +import { + ComparisonOperator, + GameConditionType, +} from '../../conditions/game-condition.types'; import { ASH_RAT_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID, @@ -29,7 +32,7 @@ import { WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; import { BASIC_HIDE_BAG_ID } from './loot-bag-content'; -import { SHOP_OFFERS } from './npc-content'; +import { BORIN_OFFER_IDS, SHOP_OFFERS } from './npc-content'; import { seedVisibleVerticalSlice } from './vertical-slice.seed'; type Row = Record; @@ -956,4 +959,98 @@ describe('seedVisibleVerticalSlice', () => { expect(new Set(ids).size).toBe(ids.length); expect(ids.every((id) => id.length === 36)).toBe(true); }); + + it('still holds exactly five offers after a re-seed, at the tuned numbers', async () => { + const shopOfferRepository = new InMemoryRepository(); + const dataSource = createDataSource( + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + shopOfferRepository, + ); + + // Twice, because that is the whole point of the stable ids (NPC spec §32). + // Both bag offers carry `itemDefinitionId: null`, so the pre-0.8.5 conflict + // target of (shopId, itemDefinitionId) would fold them into a single row + // here -- and would blow up outright on Postgres, where NULL never equals + // NULL in a unique index. + await seedVisibleVerticalSlice(dataSource); + await seedVisibleVerticalSlice(dataSource); + + expect(shopOfferRepository.rows).toHaveLength(5); + expect( + shopOfferRepository.rows.map((row: Row) => String(row.id)).sort(), + ).toEqual( + [ + BORIN_OFFER_IDS.potion, + BORIN_OFFER_IDS.shortsword, + BORIN_OFFER_IDS.trophyPouch, + BORIN_OFFER_IDS.hideBag, + BORIN_OFFER_IDS.banditBlade, + ].sort(), + ); + + // Prices and thresholds are balancing decisions, not incidentals, so a + // refactor must not be able to drift one silently (AGENTS.md §39). + const byId = new Map( + shopOfferRepository.rows.map((row: Row): [string, Row] => [ + String(row.id), + row, + ]), + ); + expect(byId.get(BORIN_OFFER_IDS.potion)).toMatchObject({ + price: 12, + conditions: [], + }); + expect(byId.get(BORIN_OFFER_IDS.shortsword)).toMatchObject({ + price: 30, + conditions: [], + }); + expect(byId.get(BORIN_OFFER_IDS.trophyPouch)).toMatchObject({ + price: 40, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + ], + }); + expect(byId.get(BORIN_OFFER_IDS.hideBag)).toMatchObject({ + price: 35, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 40, + }, + ], + }); + expect(byId.get(BORIN_OFFER_IDS.banditBlade)).toMatchObject({ + price: 60, + conditions: [ + { + type: GameConditionType.WORLD_RENOWN, + operator: ComparisonOperator.GTE, + value: 3, + }, + ], + }); + }); }); From 481c6be5a8dcf6ac5c3a59e8939b5d201566e379 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 21:07:27 +0200 Subject: [PATCH 18/18] docs: translate the 0.8.5 research notes to English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md §33 and this branch's own constraint are English-only, and the notes were the one German document left. Content and structure are unchanged. Co-Authored-By: Claude Opus 5 --- .../2026-08-22-slice-0.8.5-research-notes.md | 157 +++++++++--------- 1 file changed, 80 insertions(+), 77 deletions(-) diff --git a/docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md b/docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md index 4ba6e4b..5f7d7a4 100644 --- a/docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md +++ b/docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md @@ -1,100 +1,103 @@ -# Slice 0.8.5 — Rechercheergebnisse (Vorstufe zum Plan) +# Slice 0.8.5 — Research Findings (Preliminary to the Plan) **Spec:** `docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md` **Branch/Worktree:** `slice/0.8.5-reputation-gated-merchant-offers` in `.claude/worktrees/slice-0.8.5-reputation-gated-offers` -**Baseline:** API 410 Tests / 49 Suites grün, Web 292 Tests / 27 Suites grün. +**Baseline:** API 410 tests / 49 suites green, Web 292 tests / 27 suites green. -## Was Slice 0.8 bereits geliefert hat +## What Slice 0.8 Already Delivered -Der Großteil der Mechanik existiert schon — 0.8.5 ist überwiegend Content plus -Präsentation, nicht Neubau. +Most of the mechanics already exist — 0.8.5 is largely content plus +presentation, not new construction. -- `apps/api/src/conditions/game-condition.types.ts` — `GameConditionType` mit - `REGION_REPUTATION`, `WORLD_RENOWN`, `FLAG_SET`, `HAS_ITEM` als +- `apps/api/src/conditions/game-condition.types.ts` — `GameConditionType` with + `REGION_REPUTATION`, `WORLD_RENOWN`, `FLAG_SET`, `HAS_ITEM` as `SUPPORTED_CONDITION_TYPES`; `ComparisonOperator` + `compare()`. -- `apps/api/src/conditions/game-condition.service.ts` — `evaluate()` (UND-Logik, - fail-closed) und `describe()` (pro Bedingung `{condition, met}`). `describe()` - hat **noch keinen Aufrufer** außer seinem eigenen Spec — es wurde explizit für - 0.8.5 gebaut. -- `shop_offers.conditions` (jsonb) existiert in Migration - `1795000000000-CreateNpcSystem.ts`; Seed setzt überall `conditions: []`. -- `ShopService.getShopView()` liefert bereits `unlocked` + `affordable`; - `purchase()` wirft `SHOP_OFFER_LOCKED` (403) serverseitig. Frontend rendert - „Locked“/„Too costly“ und mappt `SHOP_OFFER_LOCKED` in `merchant.store.ts`. +- `apps/api/src/conditions/game-condition.service.ts` — `evaluate()` (AND logic, + fail-closed) and `describe()` (per condition `{condition, met}`). `describe()` + has **no caller yet** other than its own spec — it was built explicitly for + 0.8.5. +- `shop_offers.conditions` (jsonb) exists in migration + `1795000000000-CreateNpcSystem.ts`; the seed sets `conditions: []` everywhere. +- `ShopService.getShopView()` already returns `unlocked` + `affordable`; + `purchase()` throws `SHOP_OFFER_LOCKED` (403) server-side. The frontend renders + "Locked"/"Too costly" and maps `SHOP_OFFER_LOCKED` in `merchant.store.ts`. -Fazit: „Server rejects purchases when requirements are not met“ (§11) ist -faktisch schon erfüllt und durch `shop.service.spec.ts` getestet. Fehlend sind -Content, Anforderungs-Text/Fortschritt in der UI, das Bag-Verkaufsformat, der -Referral-Bypass und das Unlock-Feedback. +Conclusion: "Server rejects purchases when requirements are not met" (§11) is in +practice already satisfied and covered by `shop.service.spec.ts`. What is missing +is content, the requirement text/progress in the UI, the bag sales format, the +referral bypass and the unlock feedback. -## Zentrale Architektur-Hürde: Taschen sind keine Items +## Central Architectural Obstacle: Bags Are Not Items -`shop_offers.item_definition_id` ist NOT NULL mit FK auf `item_definitions`. -Die beiden Vorzeige-Angebote der Spec (§4) sind aber `LootBagDefinition`-Zeilen -(`apps/api/src/loot-bags/entities/loot-bag-definition.entity.ts`) — bewusst -keine Items (0.7.5 §6: nie ausgerüstet, kein Loot, keine Kampfwerte). Der -0.8-Seed sagt das wörtlich voraus: +`shop_offers.item_definition_id` is NOT NULL with an FK onto `item_definitions`. +The spec's two flagship offers (§4), however, are `LootBagDefinition` rows +(`apps/api/src/loot-bags/entities/loot-bag-definition.entity.ts`) — deliberately +not items (0.7.5 §6: never equipped, no loot, no combat stats). The 0.8 seed +predicts this verbatim: -> „a bag is a `LootBagDefinition` rather than an `ItemDefinition`, so selling -> one needs an offer shape this slice has no reason to build.“ +> "a bag is a `LootBagDefinition` rather than an `ItemDefinition`, so selling +> one needs an offer shape this slice has no reason to build." -**Nötige Migration:** `item_definition_id` nullable machen, `loot_bag_definition_id` -(nullable, FK → `loot_bag_definitions`, ON DELETE RESTRICT) ergänzen, CHECK -„genau eine der beiden gesetzt“, und der Unique-Index -`IDX_shop_offers_shop_item` braucht ein Gegenstück für Taschen. `ShopService` -muss beim Kauf einer Tasche eine `CharacterLootBag`-Zeile anlegen statt -`CharacterItem` zu stapeln — idempotent, weil `IDX_character_loot_bags_character_definition` -unique ist und 0.9 §11 „bag grant is idempotent“ verlangt. +**Migration required:** make `item_definition_id` nullable, add +`loot_bag_definition_id` (nullable, FK → `loot_bag_definitions`, ON DELETE +RESTRICT), a CHECK for "exactly one of the two is set", and the unique index +`IDX_shop_offers_shop_item` needs a counterpart for bags. On buying a bag, +`ShopService` must create a `CharacterLootBag` row instead of stacking a +`CharacterItem` — idempotently, because +`IDX_character_loot_bags_character_definition` is unique and 0.9 §11 requires +"bag grant is idempotent". -## Getroffene Entscheidungen (vom Nutzer bestätigt) +## Decisions Taken (Confirmed by the User) -1. **Starter-Bags im Seed:** Nur die Trophy Pouch aus dem Demo-Seed entfernen - (`vertical-slice.seed.ts:376-392`). Die Hide Bag bleibt vorerst geseedet, - bis 0.9 sie über die Quest-Referral vergibt. -2. **Hide-Bag-Gate:** Ruf-Gate **oder** Referral-Flag - (`referred-by-south-gate-warden`, 0.9 §5). Braucht ein klar benanntes - Bypass-Feld am Angebot — ausdrücklich *keine* generische Regel-Engine - (§3, §7, §11 „No generalized rules engine“). Vorschlag: - `shop_offers.bypass_conditions` (jsonb) mit ODER-Semantik gegen - `conditions` — kleinste Erweiterung, die den Ausnahmefall trägt. -3. **Drittes Angebot:** Bandit Blade (existiert bereits als ItemDefinition, - `ITEM_IDS['bandit-blade']`, weaponDamage 11 / +1 Attack) hinter +1. **Starter bags in the seed:** Remove only the Trophy Pouch from the demo seed + (`vertical-slice.seed.ts:376-392`). The Hide Bag stays seeded for now, until + 0.9 hands it over through the quest referral. +2. **Hide Bag gate:** Reputation gate **or** referral flag + (`referred-by-south-gate-warden`, 0.9 §5). Needs a clearly named bypass field + on the offer — expressly *not* a generic rules engine (§3, §7, §11 "No + generalized rules engine"). Proposal: `shop_offers.bypass_conditions` (jsonb) + with OR semantics against `conditions` — the smallest extension that carries + the exception case. +3. **Third offer:** Bandit Blade (already exists as an ItemDefinition, + `ITEM_IDS['bandit-blade']`, weaponDamage 11 / +1 Attack) behind `WORLD_RENOWN >= 3`. -## Balancing-Anhaltspunkte +## Balancing Reference Points -- Exchange zahlt 2/3/5/12 Ruf pro Ware (`npc-content.ts` `EXCHANGE_RULES`). -- Reputationsränge: 0 Stranger, 100 Tolerated, 250 Known, 500 Recognized, - 800 Trusted, 1200 Esteemed (`reputation-rank.ts`). Die Gates sind numerisch, - nicht rangbasiert — die Spec-Beispiele nennen „Reputation 25“. -- Renown: `Character.renown`, aktuell nur ein Milestone (`first-goods-returned`, - +1). **Offen:** Renown 3 ist mit dem heutigen Content nicht erreichbar — - entweder niedrigerer Schwellwert oder das Angebot bleibt bis 0.11 sichtbar - gesperrt (was §5 „visible rewards create goals“ sogar entspricht). -- Spec-Beispiel §5 wörtlich: Trophy Pouch, 40 Silber, Requires Ashen Fields +- The exchange pays 2/3/5/12 reputation per trade good (`npc-content.ts` + `EXCHANGE_RULES`). +- Reputation ranks: 0 Stranger, 100 Tolerated, 250 Known, 500 Recognized, + 800 Trusted, 1200 Esteemed (`reputation-rank.ts`). The gates are numeric, not + rank-based — the spec examples say "Reputation 25". +- Renown: `Character.renown`, currently only one milestone + (`first-goods-returned`, +1). **Open:** Renown 3 is unreachable with today's + content — either a lower threshold, or the offer stays visibly locked until + 0.11 (which actually matches §5, "visible rewards create goals"). +- Spec example §5, verbatim: Trophy Pouch, 40 Silver, Requires Ashen Fields Reputation 25. -## Betroffene Dateien +## Files Affected -| Datei | Rolle | +| File | Role | |---|---| -| `apps/api/src/database/migrations/1796*-SellableLootBags.ts` | neu: Bag-Angebote + Bypass-Spalte | -| `apps/api/src/shops/entities/shop-offer.entity.ts` | nullable Item-FK, Bag-FK, `bypassConditions` | -| `apps/api/src/shops/shop.service.ts` | Bag-Grant, ODER-Bypass, Anforderungs-Beschreibung via `describe()` | -| `apps/api/src/shops/shop.errors.ts` | `MERCHANT_REPUTATION_TOO_LOW` (§6) statt/neben `SHOP_OFFER_LOCKED` | -| `apps/api/src/database/seeds/npc-content.ts` | drei gesperrte Angebote | -| `apps/api/src/database/seeds/vertical-slice.seed.ts:376` | Trophy Pouch aus Demo-Seed nehmen | -| `apps/web/src/app/core/api/game-api.models.ts:433` | `requirements` am `ShopOfferView` | -| `apps/web/src/app/features/npc/merchant-page.component.{html,scss}` | Anforderung + aktueller Wert je Zeile | -| `apps/web/src/app/features/npc/merchant.store.ts` | Unlock-Feedback nach Trade (§9) | +| `apps/api/src/database/migrations/1796*-SellableLootBags.ts` | new: bag offers + bypass column | +| `apps/api/src/shops/entities/shop-offer.entity.ts` | nullable item FK, bag FK, `bypassConditions` | +| `apps/api/src/shops/shop.service.ts` | bag grant, OR bypass, requirement description via `describe()` | +| `apps/api/src/shops/shop.errors.ts` | `MERCHANT_REPUTATION_TOO_LOW` (§6) instead of / alongside `SHOP_OFFER_LOCKED` | +| `apps/api/src/database/seeds/npc-content.ts` | three locked offers | +| `apps/api/src/database/seeds/vertical-slice.seed.ts:376` | take the Trophy Pouch out of the demo seed | +| `apps/web/src/app/core/api/game-api.models.ts:433` | `requirements` on `ShopOfferView` | +| `apps/web/src/app/features/npc/merchant-page.component.{html,scss}` | requirement + current value per row | +| `apps/web/src/app/features/npc/merchant.store.ts` | unlock feedback after a trade (§9) | -## Noch offen vor dem Plan +## Still Open Before the Plan -- Wortlaut/Träger des Unlock-Feedbacks (§9): Der Store lädt den Shop nach einem - Trade ohnehin neu (`merchant.store.ts:209-212`), also lässt sich ein - Vorher/Nachher-Vergleich der `unlocked`-Flags lokal ziehen — ohne neues - Server-Event. Das ist die kleinste Lösung und braucht keinen Endpoint. -- Ob `MERCHANT_REPUTATION_TOO_LOW` den bestehenden `SHOP_OFFER_LOCKED` ersetzt - oder ergänzt. Ersetzen bricht den vorhandenen Frontend-Mapping-Eintrag und - einen Test; ergänzen (spezifischer Code, wenn die verletzte Bedingung eine - Reputationsbedingung ist) erfüllt §6 wörtlich ohne Regression. +- The wording and carrier of the unlock feedback (§9): the store reloads the shop + after a trade anyway (`merchant.store.ts:209-212`), so a before/after + comparison of the `unlocked` flags can be drawn locally — with no new server + event. That is the smallest solution and needs no endpoint. +- Whether `MERCHANT_REPUTATION_TOO_LOW` replaces the existing + `SHOP_OFFER_LOCKED` or supplements it. Replacing it breaks the existing + frontend mapping entry and one test; supplementing it (a more specific code + when the violated condition is a reputation condition) satisfies §6 verbatim + without a regression.