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")',
);
});
});

View File

@@ -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;
}

View File

@@ -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,