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:
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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")',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user