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