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.
This commit is contained in:
Bastian Wagner
2026-08-22 17:51:47 +02:00
parent 081c9f83f9
commit 3b28450fb1
4 changed files with 227 additions and 11 deletions

View File

@@ -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<void> {
// 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<void> {
// 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
`);
}
}

View File

@@ -0,0 +1,75 @@
import 'reflect-metadata';
import { QueryRunner } from 'typeorm';
import { SellableLootBags1796000000000 } from './1796000000000-SellableLootBags';
async function runUp(): Promise<string[]> {
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<string[]> {
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")',
);
});
});