From 3b28450fb16f2802c59719b5d42daad120df6c1c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 17:51:47 +0200 Subject: [PATCH] feat(shops): let an offer sell a loot bag and carry bypass conditions Adds a second, mutually exclusive target column (loot_bag_definition_id) and a bypass_conditions column to shop_offers, so a later slice's quest referral can open one offer that reputation alone would not. Keeps shop.service.ts compiling against the now-nullable itemDefinition with temporary non-null assertions; Task 4 replaces them with a real branch on offer kind. --- .../1796000000000-SellableLootBags.ts | 103 ++++++++++++++++++ .../sellable-loot-bags.migration.spec.ts | 75 +++++++++++++ .../src/shops/entities/shop-offer.entity.ts | 41 ++++++- apps/api/src/shops/shop.service.ts | 19 ++-- 4 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/database/migrations/1796000000000-SellableLootBags.ts create mode 100644 apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts diff --git a/apps/api/src/database/migrations/1796000000000-SellableLootBags.ts b/apps/api/src/database/migrations/1796000000000-SellableLootBags.ts new file mode 100644 index 0000000..2c209d0 --- /dev/null +++ b/apps/api/src/database/migrations/1796000000000-SellableLootBags.ts @@ -0,0 +1,103 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Lets a shop offer sell a loot bag, and lets one offer carry an exception + * (Playable Slice 0.8.5 §4, §7). + * + * A bag is a `loot_bag_definitions` row, not an item -- deliberately so since + * Slice 0.7.5 §6, because a bag is never equipped, never rolls as loot and has + * no combat stats. Selling one therefore needs a second target column rather + * than a fake item definition, which is exactly the "offer shape" the Slice 0.8 + * seed said it had no reason to build yet. + * + * `bypass_conditions` is the minimal exception support Slice 0.9 needs: an + * offer opens when `conditions` hold *or* `bypass_conditions` hold. One column, + * OR semantics, no rule engine (slice §7, §11). + */ +export class SellableLootBags1796000000000 implements MigrationInterface { + name = 'SellableLootBags1796000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Shop offers are pure content -- nothing references them, and Slice 0.8 + // inserted its two rows with generated uuids. The seed re-creates every + // offer with a stable id (AGENTS.md §8), which needs the old anonymous + // rows gone or they would linger as duplicates the upsert never matches. + await queryRunner.query(`DELETE FROM "shop_offers"`); + + await queryRunner.query(` + ALTER TABLE "shop_offers" + ALTER COLUMN "item_definition_id" DROP NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE "shop_offers" + ADD COLUMN "loot_bag_definition_id" uuid + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" + ADD CONSTRAINT "FK_shop_offers_loot_bag" + FOREIGN KEY ("loot_bag_definition_id") + REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT + `); + + await queryRunner.query(` + ALTER TABLE "shop_offers" + ADD COLUMN "bypass_conditions" jsonb NOT NULL DEFAULT '[]'::jsonb + `); + + // Exactly one target. An offer selling nothing has no meaning, and one + // selling both would make the grant path ambiguous. + await queryRunner.query(` + ALTER TABLE "shop_offers" + ADD CONSTRAINT "CHK_shop_offers_single_target" + CHECK (num_nonnulls("item_definition_id", "loot_bag_definition_id") = 1) + `); + + // Partial, because the column each one covers is now nullable and Postgres + // treats NULLs as distinct -- a plain unique index over (shop_id, + // item_definition_id) would happily accept a hundred bag offers. + await queryRunner.query( + `DROP INDEX "IDX_shop_offers_shop_item"`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Bag offers cannot survive a rollback: the column that identifies what + // they sell is about to disappear, and the restored NOT NULL would reject + // them anyway. + await queryRunner.query( + `DELETE FROM "shop_offers" WHERE "loot_bag_definition_id" IS NOT NULL`, + ); + + await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_bag"`); + await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_item"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")`, + ); + + await queryRunner.query(` + ALTER TABLE "shop_offers" + DROP CONSTRAINT "CHK_shop_offers_single_target" + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" DROP COLUMN "bypass_conditions" + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" + DROP CONSTRAINT "FK_shop_offers_loot_bag" + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" DROP COLUMN "loot_bag_definition_id" + `); + await queryRunner.query(` + ALTER TABLE "shop_offers" + ALTER COLUMN "item_definition_id" SET NOT NULL + `); + } +} diff --git a/apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts b/apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts new file mode 100644 index 0000000..4943255 --- /dev/null +++ b/apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts @@ -0,0 +1,75 @@ +import 'reflect-metadata'; +import { QueryRunner } from 'typeorm'; +import { SellableLootBags1796000000000 } from './1796000000000-SellableLootBags'; + +async function runUp(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + await new SellableLootBags1796000000000().up(queryRunner); + return query.mock.calls.map(([sql]) => sql as string); +} + +async function runDown(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + const migration = new SellableLootBags1796000000000(); + await migration.up(queryRunner); + const upCount = query.mock.calls.length; + await migration.down(queryRunner); + return query.mock.calls.slice(upCount).map(([sql]) => sql as string); +} + +describe('SellableLootBags1796000000000', () => { + it('adds the bag target and bypass columns', async () => { + const joined = (await runUp()).join('\n'); + + expect(joined).toContain('"loot_bag_definition_id" uuid'); + expect(joined).toContain('"bypass_conditions" jsonb'); + expect(joined).toContain('FK_shop_offers_loot_bag'); + }); + + it('makes the item target nullable so a bag offer can exist', async () => { + const joined = (await runUp()).join('\n'); + + expect(joined).toContain('ALTER COLUMN "item_definition_id" DROP NOT NULL'); + }); + + it('requires exactly one target per offer', async () => { + const joined = (await runUp()).join('\n'); + + // An offer that sells nothing, or sells both an item and a bag, is a + // content bug the database must not store. + expect(joined).toContain('CHK_shop_offers_single_target'); + expect(joined).toContain('num_nonnulls'); + }); + + it('keeps both target kinds unique per shop via partial indexes', async () => { + const joined = (await runUp()).join('\n'); + + // A plain unique index over a nullable column would let a shop hold + // unlimited bag offers, because Postgres treats NULLs as distinct. + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL', + ); + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL', + ); + }); + + it('clears content offers so the seed can own stable ids', async () => { + const joined = (await runUp()).join('\n'); + + expect(joined).toContain('DELETE FROM "shop_offers"'); + }); + + it('drops what it added and restores the original index on rollback', async () => { + const joined = (await runDown()).join('\n'); + + expect(joined).toContain('DROP COLUMN "loot_bag_definition_id"'); + expect(joined).toContain('DROP COLUMN "bypass_conditions"'); + expect(joined).toContain('ALTER COLUMN "item_definition_id" SET NOT NULL'); + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")', + ); + }); +}); diff --git a/apps/api/src/shops/entities/shop-offer.entity.ts b/apps/api/src/shops/entities/shop-offer.entity.ts index d5cedcf..48476f9 100644 --- a/apps/api/src/shops/entities/shop-offer.entity.ts +++ b/apps/api/src/shops/entities/shop-offer.entity.ts @@ -10,6 +10,7 @@ import { } from 'typeorm'; import type { GameCondition } from '../../conditions/game-condition.types'; import { ItemDefinition } from '../../items/entities/item-definition.entity'; +import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity'; import { NpcShop } from './npc-shop.entity'; /** @@ -23,6 +24,11 @@ import { NpcShop } from './npc-shop.entity'; @Entity({ name: 'shop_offers' }) @Index('IDX_shop_offers_shop_item', ['shopId', 'itemDefinitionId'], { unique: true, + where: '"item_definition_id" IS NOT NULL', +}) +@Index('IDX_shop_offers_shop_bag', ['shopId', 'lootBagDefinitionId'], { + unique: true, + where: '"loot_bag_definition_id" IS NOT NULL', }) export class ShopOffer { @PrimaryGeneratedColumn('uuid', { name: 'id' }) @@ -31,8 +37,16 @@ export class ShopOffer { @Column({ name: 'shop_id', type: 'uuid' }) shopId!: string; - @Column({ name: 'item_definition_id', type: 'uuid' }) - itemDefinitionId!: string; + /** + * What the offer sells. Exactly one of these is set, enforced by + * `CHK_shop_offers_single_target`: a bag is a `LootBagDefinition` rather + * than an item (Slice 0.7.5 §6), so the two cannot share a column. + */ + @Column({ name: 'item_definition_id', type: 'uuid', nullable: true }) + itemDefinitionId!: string | null; + + @Column({ name: 'loot_bag_definition_id', type: 'uuid', nullable: true }) + lootBagDefinitionId!: string | null; /** * Which currency `price` is denominated in. Only SILVER exists today; the @@ -58,6 +72,21 @@ export class ShopOffer { @Column({ name: 'conditions', type: 'jsonb', default: () => "'[]'::jsonb" }) conditions!: GameCondition[]; + /** + * An alternative way in (Slice 0.8.5 §7). + * + * The offer opens when `conditions` hold **or** these do. That is the whole + * exception model: it lets one quest referral grant an acquisition that + * reputation alone would not yet allow, without a favor engine. Empty on + * every offer that has no exception. + */ + @Column({ + name: 'bypass_conditions', + type: 'jsonb', + default: () => "'[]'::jsonb", + }) + bypassConditions!: GameCondition[]; + @Column({ name: 'enabled', type: 'boolean', default: true }) enabled!: boolean; @@ -71,7 +100,11 @@ export class ShopOffer { @JoinColumn({ name: 'shop_id' }) shop!: NpcShop; - @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' }) + @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT', nullable: true }) @JoinColumn({ name: 'item_definition_id' }) - itemDefinition!: ItemDefinition; + itemDefinition!: ItemDefinition | null; + + @ManyToOne(() => LootBagDefinition, { onDelete: 'RESTRICT', nullable: true }) + @JoinColumn({ name: 'loot_bag_definition_id' }) + lootBagDefinition!: LootBagDefinition | null; } diff --git a/apps/api/src/shops/shop.service.ts b/apps/api/src/shops/shop.service.ts index 006bebd..654f762 100644 --- a/apps/api/src/shops/shop.service.ts +++ b/apps/api/src/shops/shop.service.ts @@ -90,11 +90,16 @@ export class ShopService { offer.conditions, ); + // TEMPORARY (Task 1 of Slice 0.8.5): `itemDefinition` became nullable + // when the offer table gained a loot-bag target alongside the item one. + // Every offer sold an item until Task 4 adds bag offers here, so the + // assertion is safe for now -- Task 4 replaces it with a real branch on + // offer kind. view.push({ - itemKey: offer.itemDefinition.key, - itemName: offer.itemDefinition.name, - itemDescription: offer.itemDefinition.description, - iconPath: offer.itemDefinition.iconPath, + itemKey: offer.itemDefinition!.key, + itemName: offer.itemDefinition!.name, + itemDescription: offer.itemDefinition!.description, + iconPath: offer.itemDefinition!.iconPath, currencyType: offer.currencyType, price: offer.price, quantity: offer.quantity, @@ -147,7 +152,7 @@ export class ShopService { relations: { itemDefinition: true }, }); const match = offers.find( - (candidate) => candidate.itemDefinition.key === itemKey, + (candidate) => candidate.itemDefinition!.key === itemKey, ); if (!match) { @@ -178,14 +183,14 @@ export class ShopService { await this.grantItem( manager, characterId, - match.itemDefinitionId, + match.itemDefinitionId!, match.quantity * quantity, ); return { shopKey: shop.key, itemKey, - itemName: match.itemDefinition.name, + itemName: match.itemDefinition!.name, quantity: match.quantity * quantity, silverSpent, silverBalance: character.silver,