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 { 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..4463fce --- /dev/null +++ b/apps/api/src/database/migrations/1796000000000-SellableLootBags.ts @@ -0,0 +1,101 @@ +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..4059387 --- /dev/null +++ b/apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts @@ -0,0 +1,120 @@ +import 'reflect-metadata'; +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); + 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")', + ); + }); +}); + +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'); + }); +}); diff --git a/apps/api/src/database/seeds/npc-content.ts b/apps/api/src/database/seeds/npc-content.ts index 3f48340..46324c9 100644 --- a/apps/api/src/database/seeds/npc-content.ts +++ b/apps/api/src/database/seeds/npc-content.ts @@ -9,6 +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 { BORDER_GUARD_FACTION_ID } from './reputation-content'; export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001'; @@ -19,6 +20,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 +203,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..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,6 +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 { + ComparisonOperator, + GameConditionType, +} from '../../conditions/game-condition.types'; import { ASH_RAT_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID, @@ -27,6 +31,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 { BORIN_OFFER_IDS, SHOP_OFFERS } from './npc-content'; import { seedVisibleVerticalSlice } from './vertical-slice.seed'; type Row = Record; @@ -637,7 +643,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 +665,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 +908,149 @@ 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); + }); + + 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, + }, + ], + }); + }); }); 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', 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/offer-presentation.spec.ts b/apps/api/src/shops/offer-presentation.spec.ts new file mode 100644 index 0000000..e05383e --- /dev/null +++ b/apps/api/src/shops/offer-presentation.spec.ts @@ -0,0 +1,190 @@ +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(); + }); + + 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', () => { + 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}`; +} 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..0586d6b 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'; @@ -14,12 +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[]; + /** 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, @@ -36,26 +77,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: fixture.bagRepeatable ?? 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 +164,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); }, }; } @@ -104,8 +206,43 @@ 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(() => Promise.resolve(fixture.offerUnlocked ?? true)), + 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); + } + // 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: conditionHolds(condition), + actual: FAKE_MEASURED_VALUE[condition.type] ?? null, + })), + ), + ), } as unknown as GameConditionService; const npcs = { @@ -116,6 +253,7 @@ function createWorld(fixture: Fixture = {}) { service: new ShopService(dataSource, conditions, npcs), character, grantedItems, + grantedBags, owned, }; } @@ -190,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( @@ -203,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 () => { @@ -211,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 () => { @@ -224,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 () => { @@ -237,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 () => { @@ -261,4 +450,282 @@ 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, + }); + // 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 () => { + // 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, + 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); + 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 () => { + 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' }); + + 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('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). + 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). 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( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ), + ).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' }); + + expect(world.character.silver).toBe(10); + expect(world.grantedBags).toHaveLength(0); + }); }); diff --git a/apps/api/src/shops/shop.service.ts b/apps/api/src/shops/shop.service.ts index 006bebd..c8726ee 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,26 +112,58 @@ 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. 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({ where: { enabled: true } }) + ).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, + ); 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, }); } @@ -141,51 +206,83 @@ 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 }, + // 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' }, }); - 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, @@ -193,6 +290,112 @@ 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 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), + }; + } + // 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, ], 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..f05f567 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,8 @@ export interface ShopOfferView { currencyType: string; price: number; quantity: number; + 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..3841a9c 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.html +++ b/apps/web/src/app/features/npc/merchant-page.component.html @@ -208,6 +208,21 @@

{{ shop.shopName }}

{{ shop.silver }} Silver

+ @if (store.newlyUnlocked().length > 0) { +

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

+ } +
    @for (offer of shop.offers; track offer.itemKey) {
  • @@ -219,9 +234,33 @@ />
    {{ offer.itemName }} - {{ - offer.itemDescription - }} + @if (offer.itemDescription) { + {{ + 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..5f7d7a4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md @@ -0,0 +1,103 @@ +# 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 green, Web 292 tests / 27 suites green. + +## What Slice 0.8 Already Delivered + +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` with + `REGION_REPUTATION`, `WORLD_RENOWN`, `FLAG_SET`, `HAS_ITEM` as + `SUPPORTED_CONDITION_TYPES`; `ComparisonOperator` + `compare()`. +- `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`. + +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. + +## Central Architectural Obstacle: Bags Are Not Items + +`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." + +**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". + +## Decisions Taken (Confirmed by the User) + +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 Reference Points + +- 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. + +## Files Affected + +| File | Role | +|---|---| +| `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) | + +## Still Open Before the Plan + +- 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.