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';
|
} from 'typeorm';
|
||||||
import type { GameCondition } from '../../conditions/game-condition.types';
|
import type { GameCondition } from '../../conditions/game-condition.types';
|
||||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||||
|
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
|
||||||
import { NpcShop } from './npc-shop.entity';
|
import { NpcShop } from './npc-shop.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +24,11 @@ import { NpcShop } from './npc-shop.entity';
|
|||||||
@Entity({ name: 'shop_offers' })
|
@Entity({ name: 'shop_offers' })
|
||||||
@Index('IDX_shop_offers_shop_item', ['shopId', 'itemDefinitionId'], {
|
@Index('IDX_shop_offers_shop_item', ['shopId', 'itemDefinitionId'], {
|
||||||
unique: true,
|
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 {
|
export class ShopOffer {
|
||||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
@@ -31,8 +37,16 @@ export class ShopOffer {
|
|||||||
@Column({ name: 'shop_id', type: 'uuid' })
|
@Column({ name: 'shop_id', type: 'uuid' })
|
||||||
shopId!: string;
|
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
|
* 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" })
|
@Column({ name: 'conditions', type: 'jsonb', default: () => "'[]'::jsonb" })
|
||||||
conditions!: GameCondition[];
|
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 })
|
@Column({ name: 'enabled', type: 'boolean', default: true })
|
||||||
enabled!: boolean;
|
enabled!: boolean;
|
||||||
|
|
||||||
@@ -71,7 +100,11 @@ export class ShopOffer {
|
|||||||
@JoinColumn({ name: 'shop_id' })
|
@JoinColumn({ name: 'shop_id' })
|
||||||
shop!: NpcShop;
|
shop!: NpcShop;
|
||||||
|
|
||||||
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
|
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT', nullable: true })
|
||||||
@JoinColumn({ name: 'item_definition_id' })
|
@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,
|
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({
|
view.push({
|
||||||
itemKey: offer.itemDefinition.key,
|
itemKey: offer.itemDefinition!.key,
|
||||||
itemName: offer.itemDefinition.name,
|
itemName: offer.itemDefinition!.name,
|
||||||
itemDescription: offer.itemDefinition.description,
|
itemDescription: offer.itemDefinition!.description,
|
||||||
iconPath: offer.itemDefinition.iconPath,
|
iconPath: offer.itemDefinition!.iconPath,
|
||||||
currencyType: offer.currencyType,
|
currencyType: offer.currencyType,
|
||||||
price: offer.price,
|
price: offer.price,
|
||||||
quantity: offer.quantity,
|
quantity: offer.quantity,
|
||||||
@@ -147,7 +152,7 @@ export class ShopService {
|
|||||||
relations: { itemDefinition: true },
|
relations: { itemDefinition: true },
|
||||||
});
|
});
|
||||||
const match = offers.find(
|
const match = offers.find(
|
||||||
(candidate) => candidate.itemDefinition.key === itemKey,
|
(candidate) => candidate.itemDefinition!.key === itemKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -178,14 +183,14 @@ export class ShopService {
|
|||||||
await this.grantItem(
|
await this.grantItem(
|
||||||
manager,
|
manager,
|
||||||
characterId,
|
characterId,
|
||||||
match.itemDefinitionId,
|
match.itemDefinitionId!,
|
||||||
match.quantity * quantity,
|
match.quantity * quantity,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
shopKey: shop.key,
|
shopKey: shop.key,
|
||||||
itemKey,
|
itemKey,
|
||||||
itemName: match.itemDefinition.name,
|
itemName: match.itemDefinition!.name,
|
||||||
quantity: match.quantity * quantity,
|
quantity: match.quantity * quantity,
|
||||||
silverSpent,
|
silverSpent,
|
||||||
silverBalance: character.silver,
|
silverBalance: character.silver,
|
||||||
|
|||||||
Reference in New Issue
Block a user