Merge branch 'slice/0.8.5-reputation-gated-merchant-offers'
This commit is contained in:
@@ -280,4 +280,38 @@ describe('GameConditionService', () => {
|
||||
|
||||
expect(outcomes.map((outcome) => outcome.met)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('reports the current value behind each requirement', async () => {
|
||||
const service = createService({ renown: 1, reputation: 14 });
|
||||
|
||||
const outcomes = await service.describe({ characterId: CHARACTER_ID }, [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
{
|
||||
type: GameConditionType.WORLD_RENOWN,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
// "Current: 14" against "Requires 25" is the whole point of showing a
|
||||
// locked offer rather than hiding it (slice §5).
|
||||
expect(outcomes[0]).toMatchObject({ met: false, actual: 14 });
|
||||
expect(outcomes[1]).toMatchObject({ met: false, actual: 1 });
|
||||
});
|
||||
|
||||
it('reports a null current value for a condition with no scale', async () => {
|
||||
const service = createService({ renown: 1, reputation: 100 });
|
||||
|
||||
const outcomes = await service.describe(
|
||||
{ characterId: CHARACTER_ID, npcId: NPC_ID },
|
||||
[{ type: GameConditionType.FLAG_SET, key: 'met', value: true }],
|
||||
);
|
||||
|
||||
expect(outcomes[0].actual).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,9 +27,21 @@ export interface ConditionContext {
|
||||
|
||||
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||
|
||||
/**
|
||||
* One condition's result, plus the number it was measured against.
|
||||
*
|
||||
* `actual` is null for conditions with no scale -- a flag is set or it is not,
|
||||
* and "Current: 0" would be a lie about a boolean.
|
||||
*/
|
||||
interface ConditionEvaluation {
|
||||
met: boolean;
|
||||
actual: number | null;
|
||||
}
|
||||
|
||||
export interface ConditionOutcome {
|
||||
condition: GameCondition;
|
||||
met: boolean;
|
||||
actual: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +71,8 @@ export class GameConditionService {
|
||||
|
||||
const scope: RepositoryScope = manager ?? this.dataSource;
|
||||
for (const condition of conditions) {
|
||||
if (!(await this.evaluateOne(context, condition, scope))) {
|
||||
const evaluation = await this.evaluateOne(context, condition, scope);
|
||||
if (!evaluation.met) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -83,9 +96,11 @@ export class GameConditionService {
|
||||
const scope: RepositoryScope = manager ?? this.dataSource;
|
||||
const outcomes: ConditionOutcome[] = [];
|
||||
for (const condition of conditions) {
|
||||
const evaluation = await this.evaluateOne(context, condition, scope);
|
||||
outcomes.push({
|
||||
condition,
|
||||
met: await this.evaluateOne(context, condition, scope),
|
||||
met: evaluation.met,
|
||||
actual: evaluation.actual,
|
||||
});
|
||||
}
|
||||
return outcomes;
|
||||
@@ -95,9 +110,9 @@ export class GameConditionService {
|
||||
context: ConditionContext,
|
||||
condition: GameCondition,
|
||||
scope: RepositoryScope,
|
||||
): Promise<boolean> {
|
||||
): Promise<ConditionEvaluation> {
|
||||
if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) {
|
||||
return false;
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
|
||||
switch (condition.type) {
|
||||
@@ -110,7 +125,7 @@ export class GameConditionService {
|
||||
case GameConditionType.HAS_ITEM:
|
||||
return this.evaluateHasItem(context, condition, scope);
|
||||
default:
|
||||
return false;
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,16 +133,16 @@ export class GameConditionService {
|
||||
context: ConditionContext,
|
||||
condition: GameCondition,
|
||||
scope: RepositoryScope,
|
||||
): Promise<boolean> {
|
||||
): Promise<ConditionEvaluation> {
|
||||
if (!condition.key) {
|
||||
return false;
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
|
||||
const faction = await scope
|
||||
.getRepository(ReputationFaction)
|
||||
.findOneBy({ key: condition.key, enabled: true });
|
||||
if (!faction) {
|
||||
return false;
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
|
||||
const row = await scope.getRepository(CharacterReputation).findOneBy({
|
||||
@@ -137,32 +152,39 @@ export class GameConditionService {
|
||||
|
||||
// A faction the character never interacted with reads as 0, not as absent
|
||||
// -- the same rule ReputationService.getCharacterReputation applies.
|
||||
return this.compareNumeric(row?.reputation ?? 0, condition);
|
||||
const reputation = row?.reputation ?? 0;
|
||||
return {
|
||||
met: this.compareNumeric(reputation, condition),
|
||||
actual: reputation,
|
||||
};
|
||||
}
|
||||
|
||||
private async evaluateWorldRenown(
|
||||
context: ConditionContext,
|
||||
condition: GameCondition,
|
||||
scope: RepositoryScope,
|
||||
): Promise<boolean> {
|
||||
): Promise<ConditionEvaluation> {
|
||||
const character = await scope
|
||||
.getRepository(Character)
|
||||
.findOneBy({ id: context.characterId });
|
||||
if (!character) {
|
||||
return false;
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
return this.compareNumeric(character.renown, condition);
|
||||
return {
|
||||
met: this.compareNumeric(character.renown, condition),
|
||||
actual: character.renown,
|
||||
};
|
||||
}
|
||||
|
||||
private async evaluateFlag(
|
||||
context: ConditionContext,
|
||||
condition: GameCondition,
|
||||
scope: RepositoryScope,
|
||||
): Promise<boolean> {
|
||||
): Promise<ConditionEvaluation> {
|
||||
// Flags are per-NPC player state (spec §7). Without an NPC in context
|
||||
// there is nothing to read, so the gate stays shut.
|
||||
if (!condition.key || !context.npcId) {
|
||||
return false;
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
|
||||
const state = await scope.getRepository(CharacterNpcState).findOneBy({
|
||||
@@ -171,23 +193,26 @@ export class GameConditionService {
|
||||
});
|
||||
|
||||
const expected = condition.value ?? true;
|
||||
return (state?.flags?.[condition.key] ?? false) === expected;
|
||||
return {
|
||||
met: (state?.flags?.[condition.key] ?? false) === expected,
|
||||
actual: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async evaluateHasItem(
|
||||
context: ConditionContext,
|
||||
condition: GameCondition,
|
||||
scope: RepositoryScope,
|
||||
): Promise<boolean> {
|
||||
): Promise<ConditionEvaluation> {
|
||||
if (!condition.key) {
|
||||
return false;
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
|
||||
const definition = await scope
|
||||
.getRepository(ItemDefinition)
|
||||
.findOneBy({ key: condition.key });
|
||||
if (!definition) {
|
||||
return false;
|
||||
return { met: false, actual: null };
|
||||
}
|
||||
|
||||
const owned = await scope.getRepository(CharacterItem).findOneBy({
|
||||
@@ -196,11 +221,15 @@ export class GameConditionService {
|
||||
});
|
||||
|
||||
// "Has item" without an explicit comparison means "at least one".
|
||||
return this.compareNumeric(owned?.quantity ?? 0, {
|
||||
const quantity = owned?.quantity ?? 0;
|
||||
return {
|
||||
met: this.compareNumeric(quantity, {
|
||||
...condition,
|
||||
operator: condition.operator ?? ComparisonOperator.GTE,
|
||||
value: condition.value ?? 1,
|
||||
});
|
||||
}),
|
||||
actual: quantity,
|
||||
};
|
||||
}
|
||||
|
||||
private compareNumeric(actual: number, condition: GameCondition): boolean {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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,120 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
|
||||
import { SellableLootBags1796000000000 } from './1796000000000-SellableLootBags';
|
||||
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
|
||||
|
||||
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")',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slice 0.8.5 entity schema', () => {
|
||||
function column(target: unknown, propertyName: string) {
|
||||
return getMetadataArgsStorage().columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === target && candidate.propertyName === propertyName,
|
||||
);
|
||||
}
|
||||
|
||||
it('lets the item target sit empty when the offer sells a bag instead', () => {
|
||||
expect(column(ShopOffer, 'itemDefinitionId')?.options.nullable).toBe(true);
|
||||
});
|
||||
|
||||
it('gives a bag offer its own nullable uuid target', () => {
|
||||
const lootBagDefinitionId = column(ShopOffer, 'lootBagDefinitionId');
|
||||
expect(lootBagDefinitionId?.options.type).toBe('uuid');
|
||||
expect(lootBagDefinitionId?.options.nullable).toBe(true);
|
||||
});
|
||||
|
||||
it('requires every offer to carry its bypass conditions, empty or not', () => {
|
||||
const bypassConditions = column(ShopOffer, 'bypassConditions');
|
||||
expect(bypassConditions?.options.type).toBe('jsonb');
|
||||
expect(bypassConditions?.options.nullable).toBeFalsy();
|
||||
});
|
||||
|
||||
it('declares both partial unique indexes with their WHERE clauses', () => {
|
||||
const indices = getMetadataArgsStorage().indices.filter(
|
||||
(candidate) => candidate.target === ShopOffer,
|
||||
);
|
||||
|
||||
const itemIndex = indices.find(
|
||||
(candidate) => candidate.name === 'IDX_shop_offers_shop_item',
|
||||
);
|
||||
const bagIndex = indices.find(
|
||||
(candidate) => candidate.name === 'IDX_shop_offers_shop_bag',
|
||||
);
|
||||
|
||||
expect(itemIndex?.unique).toBe(true);
|
||||
expect(itemIndex?.where).toBe('"item_definition_id" IS NOT NULL');
|
||||
|
||||
expect(bagIndex?.unique).toBe(true);
|
||||
expect(bagIndex?.where).toBe('"loot_bag_definition_id" IS NOT NULL');
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
DialogueResponseContent,
|
||||
} from '../../npcs/npc.types';
|
||||
import { ITEM_IDS } from './item.constants';
|
||||
import { BASIC_HIDE_BAG_ID, BASIC_TROPHY_POUCH_ID } from './loot-bag-content';
|
||||
import { BORDER_GUARD_FACTION_ID } from './reputation-content';
|
||||
|
||||
export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001';
|
||||
@@ -19,6 +20,20 @@ export const BORIN_KEY = 'borin-quartermaster';
|
||||
export const BORIN_SHOP_KEY = 'borin-supplies';
|
||||
export const BORIN_EXCHANGE_KEY = 'borin-trade-in';
|
||||
|
||||
// Stable offer ids so re-seeding re-tunes a price instead of inserting a
|
||||
// second row (AGENTS.md §8). Needed here in particular because an offer has
|
||||
// two possible targets and no single natural key across both shapes.
|
||||
export const BORIN_OFFER_IDS = {
|
||||
potion: 'b4000000-0000-4000-8000-000000000001',
|
||||
shortsword: 'b4000000-0000-4000-8000-000000000002',
|
||||
trophyPouch: 'b4000000-0000-4000-8000-000000000003',
|
||||
hideBag: 'b4000000-0000-4000-8000-000000000004',
|
||||
banditBlade: 'b4000000-0000-4000-8000-000000000005',
|
||||
} as const;
|
||||
|
||||
/** The flag the Slice 0.9 warden sets when she sends the player to Borin. */
|
||||
export const SOUTH_GATE_REFERRAL_FLAG = 'referred-by-south-gate-warden';
|
||||
|
||||
/**
|
||||
* The Renown 2 milestone (Playable Slice 0.6.5 §6).
|
||||
*
|
||||
@@ -188,49 +203,139 @@ export const NPC_SHOPS: SeedNpcShop[] = [
|
||||
];
|
||||
|
||||
export interface SeedShopOffer {
|
||||
id: string;
|
||||
shopId: string;
|
||||
itemDefinitionId: string;
|
||||
itemDefinitionId: string | null;
|
||||
lootBagDefinitionId: string | null;
|
||||
currencyType: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
repeatable: boolean;
|
||||
sortOrder: number;
|
||||
conditions: GameCondition[];
|
||||
bypassConditions: GameCondition[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately thin (slice §12: "Avoid adding many new items just to populate
|
||||
* the shop"). The point of 0.8 is trade-in; the shop exists so Silver has
|
||||
* somewhere to go the moment it is earned.
|
||||
* What Borin sells (Playable Slice 0.8 §12, Slice 0.8.5 §4).
|
||||
*
|
||||
* No offer carries conditions yet -- reputation-gated offers are Slice 0.8.5
|
||||
* (slice §3). Loot bags are not sold here either: 0.8.5 §4 plans the Basic
|
||||
* Hide Bag as its locked-offer example, and a bag is a `LootBagDefinition`
|
||||
* rather than an `ItemDefinition`, so selling one needs an offer shape this
|
||||
* slice has no reason to build.
|
||||
* Two open offers so Silver always has somewhere to go, and three gated ones
|
||||
* so reputation visibly changes what the player can do (0.8.5 §1). The locked
|
||||
* offers stay listed rather than hidden: a reward you can see is a goal, and a
|
||||
* reward you cannot see is nothing (0.8.5 §5).
|
||||
*
|
||||
* Thresholds are balancing data (0.8.5 §4). The exchange pays 2-12 reputation
|
||||
* per trade good, so 25 is three or four good hunts away -- close enough to
|
||||
* pull, far enough to matter -- and 40 is deliberately further out, which is
|
||||
* what makes the Slice 0.9 referral read as a favour rather than a shortcut
|
||||
* around nothing.
|
||||
*
|
||||
* The Bandit Blade sits behind World Renown 3, which today's content cannot
|
||||
* reach: there is exactly one renown milestone, worth +1. That is intentional
|
||||
* and not a balancing oversight -- it is the long-horizon goal on the shelf
|
||||
* until Slice 0.11 adds the milestones that reach it.
|
||||
*/
|
||||
export const SHOP_OFFERS: SeedShopOffer[] = [
|
||||
{
|
||||
id: BORIN_OFFER_IDS.potion,
|
||||
shopId: BORIN_SHOP_ID,
|
||||
itemDefinitionId: ITEM_IDS['small-healing-potion'],
|
||||
lootBagDefinitionId: null,
|
||||
currencyType: 'SILVER',
|
||||
price: 12,
|
||||
quantity: 1,
|
||||
repeatable: true,
|
||||
sortOrder: 1,
|
||||
conditions: [],
|
||||
bypassConditions: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: BORIN_OFFER_IDS.shortsword,
|
||||
shopId: BORIN_SHOP_ID,
|
||||
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||
lootBagDefinitionId: null,
|
||||
currencyType: 'SILVER',
|
||||
price: 30,
|
||||
quantity: 1,
|
||||
repeatable: true,
|
||||
sortOrder: 2,
|
||||
conditions: [],
|
||||
bypassConditions: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
// The first gate a player meets, and the one 0.8.5 §5 uses as its worked
|
||||
// example: 40 Silver, Ashen Fields reputation 25.
|
||||
id: BORIN_OFFER_IDS.trophyPouch,
|
||||
shopId: BORIN_SHOP_ID,
|
||||
itemDefinitionId: null,
|
||||
lootBagDefinitionId: BASIC_TROPHY_POUCH_ID,
|
||||
currencyType: 'SILVER',
|
||||
price: 40,
|
||||
quantity: 1,
|
||||
repeatable: false,
|
||||
sortOrder: 3,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
],
|
||||
bypassConditions: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
// Reputation 40 is out of reach for a new character on purpose. Slice 0.9
|
||||
// sends the player here with the warden's word instead, which is the one
|
||||
// exception the offer system supports (0.8.5 §7).
|
||||
id: BORIN_OFFER_IDS.hideBag,
|
||||
shopId: BORIN_SHOP_ID,
|
||||
itemDefinitionId: null,
|
||||
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
|
||||
currencyType: 'SILVER',
|
||||
price: 35,
|
||||
quantity: 1,
|
||||
repeatable: false,
|
||||
sortOrder: 4,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 40,
|
||||
},
|
||||
],
|
||||
bypassConditions: [
|
||||
{
|
||||
type: GameConditionType.FLAG_SET,
|
||||
key: SOUTH_GATE_REFERRAL_FLAG,
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: BORIN_OFFER_IDS.banditBlade,
|
||||
shopId: BORIN_SHOP_ID,
|
||||
itemDefinitionId: ITEM_IDS['bandit-blade'],
|
||||
lootBagDefinitionId: null,
|
||||
currencyType: 'SILVER',
|
||||
price: 60,
|
||||
quantity: 1,
|
||||
repeatable: true,
|
||||
sortOrder: 5,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.WORLD_RENOWN,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 3,
|
||||
},
|
||||
],
|
||||
bypassConditions: [],
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -20,6 +20,10 @@ import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
|
||||
import { NpcShop } from '../../shops/entities/npc-shop.entity';
|
||||
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
|
||||
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
|
||||
import {
|
||||
ComparisonOperator,
|
||||
GameConditionType,
|
||||
} from '../../conditions/game-condition.types';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||
@@ -27,6 +31,8 @@ import {
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
import { BASIC_HIDE_BAG_ID } from './loot-bag-content';
|
||||
import { BORIN_OFFER_IDS, SHOP_OFFERS } from './npc-content';
|
||||
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
@@ -637,7 +643,7 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('gives the demo character one active bag per category, idempotently', async () => {
|
||||
it('gives the demo character only the Hide Bag, idempotently', async () => {
|
||||
const characterLootBagRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
new InMemoryRepository(),
|
||||
@@ -659,20 +665,18 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
// Deliberate: 0.7.5 §7 leaves acquisition to the merchant slice, but with
|
||||
// no merchant yet a bagless character could never empty a full bag. See
|
||||
// the ASSUMPTION note in the seed.
|
||||
expect(characterLootBagRepository.rows).toHaveLength(2);
|
||||
// Slice 0.8.5 decision: the Trophy Pouch is now a reputation-gated offer,
|
||||
// so handing it to the demo character for free would undercut the
|
||||
// showcase. The Hide Bag stays -- without it, HIDE capacity would drop to
|
||||
// the bagless default of 1 with no way to raise it before Slice 0.9 grants
|
||||
// it through the warden's referral. See the ASSUMPTION note in the seed.
|
||||
expect(characterLootBagRepository.rows).toHaveLength(1);
|
||||
expect(characterLootBagRepository.rows.map((row) => row.active)).toEqual([
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(
|
||||
characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId),
|
||||
).toEqual([
|
||||
'a0000000-0000-4000-8000-000000000001',
|
||||
'a0000000-0000-4000-8000-000000000002',
|
||||
]);
|
||||
).toEqual(['a0000000-0000-4000-8000-000000000001']);
|
||||
});
|
||||
|
||||
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
|
||||
@@ -904,4 +908,149 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds two visibly locked bag offers and one renown-gated weapon', async () => {
|
||||
const gated = SHOP_OFFERS.filter((offer) => offer.conditions.length > 0);
|
||||
|
||||
expect(gated).toHaveLength(3);
|
||||
expect(gated.map((offer) => offer.conditions[0].type).sort()).toEqual([
|
||||
GameConditionType.REGION_REPUTATION,
|
||||
GameConditionType.REGION_REPUTATION,
|
||||
GameConditionType.WORLD_RENOWN,
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives the Hide Bag a referral bypass for Slice 0.9', async () => {
|
||||
const hideBag = SHOP_OFFERS.find(
|
||||
(offer) => offer.lootBagDefinitionId === BASIC_HIDE_BAG_ID,
|
||||
);
|
||||
|
||||
expect(hideBag?.bypassConditions).toEqual([
|
||||
{
|
||||
type: GameConditionType.FLAG_SET,
|
||||
key: 'referred-by-south-gate-warden',
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('sells every bag as a one-off', async () => {
|
||||
// A bag is one object; a second copy raises no capacity.
|
||||
for (const offer of SHOP_OFFERS.filter(
|
||||
(candidate) => candidate.lootBagDefinitionId !== null,
|
||||
)) {
|
||||
expect(offer.repeatable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every offer exactly one target', async () => {
|
||||
for (const offer of SHOP_OFFERS) {
|
||||
const targets = [
|
||||
offer.itemDefinitionId,
|
||||
offer.lootBagDefinitionId,
|
||||
].filter((target) => target !== null);
|
||||
expect(targets).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every offer a stable id so re-seeding cannot duplicate it', async () => {
|
||||
const ids = SHOP_OFFERS.map((offer) => offer.id);
|
||||
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids.every((id) => id.length === 36)).toBe(true);
|
||||
});
|
||||
|
||||
it('still holds exactly five offers after a re-seed, at the tuned numbers', async () => {
|
||||
const shopOfferRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
shopOfferRepository,
|
||||
);
|
||||
|
||||
// Twice, because that is the whole point of the stable ids (NPC spec §32).
|
||||
// Both bag offers carry `itemDefinitionId: null`, so the pre-0.8.5 conflict
|
||||
// target of (shopId, itemDefinitionId) would fold them into a single row
|
||||
// here -- and would blow up outright on Postgres, where NULL never equals
|
||||
// NULL in a unique index.
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(shopOfferRepository.rows).toHaveLength(5);
|
||||
expect(
|
||||
shopOfferRepository.rows.map((row: Row) => String(row.id)).sort(),
|
||||
).toEqual(
|
||||
[
|
||||
BORIN_OFFER_IDS.potion,
|
||||
BORIN_OFFER_IDS.shortsword,
|
||||
BORIN_OFFER_IDS.trophyPouch,
|
||||
BORIN_OFFER_IDS.hideBag,
|
||||
BORIN_OFFER_IDS.banditBlade,
|
||||
].sort(),
|
||||
);
|
||||
|
||||
// Prices and thresholds are balancing decisions, not incidentals, so a
|
||||
// refactor must not be able to drift one silently (AGENTS.md §39).
|
||||
const byId = new Map<string, Row>(
|
||||
shopOfferRepository.rows.map((row: Row): [string, Row] => [
|
||||
String(row.id),
|
||||
row,
|
||||
]),
|
||||
);
|
||||
expect(byId.get(BORIN_OFFER_IDS.potion)).toMatchObject({
|
||||
price: 12,
|
||||
conditions: [],
|
||||
});
|
||||
expect(byId.get(BORIN_OFFER_IDS.shortsword)).toMatchObject({
|
||||
price: 30,
|
||||
conditions: [],
|
||||
});
|
||||
expect(byId.get(BORIN_OFFER_IDS.trophyPouch)).toMatchObject({
|
||||
price: 40,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(byId.get(BORIN_OFFER_IDS.hideBag)).toMatchObject({
|
||||
price: 35,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 40,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(byId.get(BORIN_OFFER_IDS.banditBlade)).toMatchObject({
|
||||
price: 60,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.WORLD_RENOWN,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,11 +43,7 @@ import {
|
||||
BURNED_ROAD_LOCAL_CONTENT,
|
||||
SOUTH_GATE_LOCAL_CONTENT,
|
||||
} from './local-location.content';
|
||||
import {
|
||||
BASIC_HIDE_BAG_ID,
|
||||
BASIC_TROPHY_POUCH_ID,
|
||||
LOOT_BAG_DEFINITIONS,
|
||||
} from './loot-bag-content';
|
||||
import { BASIC_HIDE_BAG_ID, LOOT_BAG_DEFINITIONS } from './loot-bag-content';
|
||||
import { REPUTATION_FACTIONS } from './reputation-content';
|
||||
import {
|
||||
DIALOGUE_NODES,
|
||||
@@ -362,34 +358,28 @@ export async function seedVisibleVerticalSlice(
|
||||
});
|
||||
}
|
||||
|
||||
// ASSUMPTION (Playable Slice 0.7.5 §7 leaves acquisition to the merchant
|
||||
// and quest slices, so nothing in 0.7.5 hands out a bag).
|
||||
// ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag
|
||||
// and no longer starts with the Trophy Pouch.
|
||||
//
|
||||
// The demo character starts with both starter bags anyway. Without them the
|
||||
// bagless default of 1 applies, and since the merchant that empties a bag
|
||||
// only arrives in 0.8, the second kill of a hunt would leave its trade good
|
||||
// behind forever -- the hunting loop would be unplayable between these two
|
||||
// slices. Capacity 5 lets the fill-up actually be experienced.
|
||||
// The pouch is now a reputation-gated offer (0.8.5 §4) and handing it over
|
||||
// for free would make the slice's own showcase pointless. The Hide Bag stays
|
||||
// until Slice 0.9 grants it through the warden's referral -- removing both
|
||||
// now would drop HIDE capacity to the bagless default of 1 with no way to
|
||||
// raise it, and the hunting loop would be unplayable in between.
|
||||
//
|
||||
// Smallest reversible choice: two seed rows, no acquisition mechanism.
|
||||
// Delete them once the merchant sells bags.
|
||||
// Delete this block once 0.9 hands the Hide Bag over in the quest.
|
||||
const characterLootBagRepository = dataSource.getRepository(CharacterLootBag);
|
||||
for (const lootBagDefinitionId of [
|
||||
BASIC_HIDE_BAG_ID,
|
||||
BASIC_TROPHY_POUCH_ID,
|
||||
]) {
|
||||
const existingBag = await characterLootBagRepository.findOneBy({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
lootBagDefinitionId,
|
||||
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
|
||||
});
|
||||
if (!existingBag) {
|
||||
await characterLootBagRepository.insert({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
lootBagDefinitionId,
|
||||
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// NPC content (NPC Specification V1 §32; Playable Slice 0.8).
|
||||
//
|
||||
@@ -413,7 +403,10 @@ export async function seedVisibleVerticalSlice(
|
||||
|
||||
await dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']);
|
||||
await npcShopRepository.upsert(NPC_SHOPS, ['key']);
|
||||
await shopOfferRepository.upsert(SHOP_OFFERS, ['shopId', 'itemDefinitionId']);
|
||||
// By id, not by (shop, item): an offer may now sell a bag instead of an
|
||||
// item, so the old pair is null for half the rows and useless as a conflict
|
||||
// target. Migration 1796 cleared the anonymous 0.8 rows for exactly this.
|
||||
await shopOfferRepository.upsert(SHOP_OFFERS, ['id']);
|
||||
await npcExchangeProfileRepository.upsert(NPC_EXCHANGE_PROFILES, ['key']);
|
||||
await exchangeRuleRepository.upsert(EXCHANGE_RULES, [
|
||||
'profileId',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
190
apps/api/src/shops/offer-presentation.spec.ts
Normal file
190
apps/api/src/shops/offer-presentation.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
ComparisonOperator,
|
||||
GameConditionType,
|
||||
} from '../conditions/game-condition.types';
|
||||
import { LootCategory } from '../items/loot-category.enum';
|
||||
import {
|
||||
describeBagEffect,
|
||||
describeItemEffect,
|
||||
describeRequirement,
|
||||
} from './offer-presentation';
|
||||
|
||||
const FACTIONS = new Map([['border-guard', 'Border Watch']]);
|
||||
|
||||
describe('describeRequirement', () => {
|
||||
it('names the faction and the threshold for a reputation gate', () => {
|
||||
const requirement = describeRequirement(
|
||||
{
|
||||
condition: {
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
met: false,
|
||||
actual: 14,
|
||||
},
|
||||
FACTIONS,
|
||||
);
|
||||
|
||||
expect(requirement).toEqual({
|
||||
label: 'Requires Border Watch Reputation 25',
|
||||
current: 14,
|
||||
required: 25,
|
||||
met: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('describes a World Renown gate', () => {
|
||||
const requirement = describeRequirement(
|
||||
{
|
||||
condition: {
|
||||
type: GameConditionType.WORLD_RENOWN,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 3,
|
||||
},
|
||||
met: false,
|
||||
actual: 1,
|
||||
},
|
||||
FACTIONS,
|
||||
);
|
||||
|
||||
expect(requirement).toMatchObject({
|
||||
label: 'Requires World Renown 3',
|
||||
current: 1,
|
||||
required: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the faction key when the faction is unknown', () => {
|
||||
// A gate naming a faction that is not seeded still has to render as
|
||||
// something, and the key is more useful to a player than a blank.
|
||||
const requirement = describeRequirement(
|
||||
{
|
||||
condition: {
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'dusk-hunters',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 10,
|
||||
},
|
||||
met: false,
|
||||
actual: 0,
|
||||
},
|
||||
FACTIONS,
|
||||
);
|
||||
|
||||
expect(requirement?.label).toBe('Requires dusk-hunters Reputation 10');
|
||||
});
|
||||
|
||||
it('renders no line for a condition type the player is not shown', () => {
|
||||
// A dialogue flag is an internal gate. Naming it would spoil the quest
|
||||
// that sets it, and the offer already renders as locked without it.
|
||||
expect(
|
||||
describeRequirement(
|
||||
{
|
||||
condition: {
|
||||
type: GameConditionType.FLAG_SET,
|
||||
key: 'referred-by-south-gate-warden',
|
||||
value: true,
|
||||
},
|
||||
met: false,
|
||||
actual: null,
|
||||
},
|
||||
FACTIONS,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders no line if the condition has a non-numeric value', () => {
|
||||
// A requirement with no valid threshold is unactionable -- the player
|
||||
// has no goal to work toward. Better to hide the gate than to render
|
||||
// a malformed line.
|
||||
expect(
|
||||
describeRequirement(
|
||||
{
|
||||
condition: {
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: NaN,
|
||||
},
|
||||
met: false,
|
||||
actual: 14,
|
||||
},
|
||||
FACTIONS,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders no line for a reputation gate with no faction key', () => {
|
||||
// A gate that names no faction is malformed. The player cannot act on a
|
||||
// requirement that never says which faction. Better to show nothing than
|
||||
// broken content.
|
||||
expect(
|
||||
describeRequirement(
|
||||
{
|
||||
condition: {
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
} as any,
|
||||
met: false,
|
||||
actual: 14,
|
||||
},
|
||||
FACTIONS,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeItemEffect', () => {
|
||||
it('summarises a weapon', () => {
|
||||
expect(
|
||||
describeItemEffect({
|
||||
weaponDamage: 11,
|
||||
bonusAttack: 1,
|
||||
bonusHp: 0,
|
||||
bonusArmor: 0,
|
||||
}),
|
||||
).toBe('11 Weapon Damage, +1 Attack');
|
||||
});
|
||||
|
||||
it('summarises armour', () => {
|
||||
expect(
|
||||
describeItemEffect({
|
||||
weaponDamage: 0,
|
||||
bonusAttack: 0,
|
||||
bonusHp: 5,
|
||||
bonusArmor: 4,
|
||||
}),
|
||||
).toBe('+5 HP, +4 Armor');
|
||||
});
|
||||
|
||||
it('has nothing to say about an item with no stats', () => {
|
||||
expect(
|
||||
describeItemEffect({
|
||||
weaponDamage: 0,
|
||||
bonusAttack: 0,
|
||||
bonusHp: 0,
|
||||
bonusArmor: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeBagEffect', () => {
|
||||
it('states what the bag carries and how much', () => {
|
||||
expect(
|
||||
describeBagEffect({
|
||||
capacity: 5,
|
||||
lootCategory: LootCategory.RAIDER_TROPHY,
|
||||
}),
|
||||
).toBe('Capacity: 5 Raider Trophies');
|
||||
});
|
||||
|
||||
it('uses the plural label of the category', () => {
|
||||
expect(
|
||||
describeBagEffect({ capacity: 5, lootCategory: LootCategory.HIDE }),
|
||||
).toBe('Capacity: 5 Hides');
|
||||
});
|
||||
});
|
||||
104
apps/api/src/shops/offer-presentation.ts
Normal file
104
apps/api/src/shops/offer-presentation.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { ConditionOutcome } from '../conditions/game-condition.service';
|
||||
import { GameConditionType } from '../conditions/game-condition.types';
|
||||
import { LootCategory } from '../items/loot-category.enum';
|
||||
|
||||
/**
|
||||
* One requirement as the player reads it (Playable Slice 0.8.5 §5, §8).
|
||||
*
|
||||
* `current` is what makes a locked offer a goal rather than a wall: seeing
|
||||
* "Requires 25 / Current 14" tells the player how much further to go.
|
||||
*/
|
||||
export interface ShopOfferRequirementDto {
|
||||
label: string;
|
||||
current: number | null;
|
||||
required: number | null;
|
||||
met: boolean;
|
||||
}
|
||||
|
||||
/** Plural, because a capacity counts things. Mirrors the web strip's labels. */
|
||||
const CATEGORY_LABELS: Readonly<Record<LootCategory, string>> = {
|
||||
[LootCategory.HIDE]: 'Hides',
|
||||
[LootCategory.RAIDER_TROPHY]: 'Raider Trophies',
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns one evaluated condition into a player-facing line, or into nothing.
|
||||
*
|
||||
* Only the two gate types slice §8 names are rendered. Everything else --
|
||||
* dialogue flags above all -- returns null: the offer still shows as locked,
|
||||
* but an internal gate is not announced, and a quest flag named on a shop row
|
||||
* would spoil the Slice 0.9 tutorial before the quest exists.
|
||||
*/
|
||||
export function describeRequirement(
|
||||
outcome: ConditionOutcome,
|
||||
factionNames: ReadonlyMap<string, string>,
|
||||
): ShopOfferRequirementDto | null {
|
||||
const { condition, met, actual } = outcome;
|
||||
const required = Number(condition.value);
|
||||
if (!Number.isFinite(required)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (condition.type) {
|
||||
case GameConditionType.REGION_REPUTATION: {
|
||||
if (!condition.key) {
|
||||
return null;
|
||||
}
|
||||
// The key is a poor label, but a blank is worse -- a gate on a faction
|
||||
// that is not seeded yet should still read as something.
|
||||
const faction = factionNames.get(condition.key) ?? condition.key;
|
||||
return {
|
||||
label: `Requires ${faction} Reputation ${required}`,
|
||||
current: actual,
|
||||
required,
|
||||
met,
|
||||
};
|
||||
}
|
||||
case GameConditionType.WORLD_RENOWN:
|
||||
return {
|
||||
label: `Requires World Renown ${required}`,
|
||||
current: actual,
|
||||
required,
|
||||
met,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What an item does, in one line (slice §8 "relevant effect").
|
||||
*
|
||||
* Stats only. The flavour text is already carried separately, and repeating it
|
||||
* here would push the actual numbers off the row.
|
||||
*/
|
||||
export function describeItemEffect(item: {
|
||||
weaponDamage: number;
|
||||
bonusAttack: number;
|
||||
bonusHp: number;
|
||||
bonusArmor: number;
|
||||
}): string | null {
|
||||
const parts: string[] = [];
|
||||
if (item.weaponDamage > 0) {
|
||||
parts.push(`${item.weaponDamage} Weapon Damage`);
|
||||
}
|
||||
if (item.bonusAttack > 0) {
|
||||
parts.push(`+${item.bonusAttack} Attack`);
|
||||
}
|
||||
if (item.bonusHp > 0) {
|
||||
parts.push(`+${item.bonusHp} HP`);
|
||||
}
|
||||
if (item.bonusArmor > 0) {
|
||||
parts.push(`+${item.bonusArmor} Armor`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(', ') : null;
|
||||
}
|
||||
|
||||
/** What a bag does: one category, one capacity (Slice 0.7.5 §6). */
|
||||
export function describeBagEffect(bag: {
|
||||
capacity: number;
|
||||
lootCategory: LootCategory;
|
||||
}): string {
|
||||
const label = CATEGORY_LABELS[bag.lootCategory] ?? bag.lootCategory;
|
||||
return `Capacity: ${bag.capacity} ${label}`;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export type ShopErrorCode =
|
||||
| 'SHOP_DISABLED'
|
||||
| 'SHOP_OFFER_NOT_FOUND'
|
||||
| 'SHOP_OFFER_LOCKED'
|
||||
| 'MERCHANT_REPUTATION_TOO_LOW'
|
||||
| 'SHOP_BAG_ALREADY_OWNED'
|
||||
| 'SHOP_INVALID_QUANTITY'
|
||||
| 'SHOP_INSUFFICIENT_SILVER';
|
||||
|
||||
@@ -57,6 +59,34 @@ export function shopOfferLocked(): ShopDomainError {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The specific case of `SHOP_OFFER_LOCKED` where reputation is what is short
|
||||
* (Playable Slice 0.8.5 §6).
|
||||
*
|
||||
* Added alongside the generic code rather than replacing it: a gate on a quest
|
||||
* flag or an item is still `SHOP_OFFER_LOCKED`, and telling a player to earn
|
||||
* reputation they do not need would be worse than saying nothing specific.
|
||||
*/
|
||||
export function merchantReputationTooLow(): ShopDomainError {
|
||||
return new ShopDomainError(
|
||||
'MERCHANT_REPUTATION_TOO_LOW',
|
||||
HttpStatus.FORBIDDEN,
|
||||
'You have not earned enough standing for this yet.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the roomiest active bag per category counts (Slice 0.7.5 §6), so a
|
||||
* second copy grants nothing. Selling one would be taking Silver for nothing.
|
||||
*/
|
||||
export function shopBagAlreadyOwned(): ShopDomainError {
|
||||
return new ShopDomainError(
|
||||
'SHOP_BAG_ALREADY_OWNED',
|
||||
HttpStatus.CONFLICT,
|
||||
'You already carry that.',
|
||||
);
|
||||
}
|
||||
|
||||
export function shopInvalidQuantity(): ShopDomainError {
|
||||
return new ShopDomainError(
|
||||
'SHOP_INVALID_QUANTITY',
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { GameConditionService } from '../conditions/game-condition.service';
|
||||
import {
|
||||
ComparisonOperator,
|
||||
GameCondition,
|
||||
GameConditionType,
|
||||
} from '../conditions/game-condition.types';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { LootCategory } from '../items/loot-category.enum';
|
||||
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
|
||||
import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity';
|
||||
import { NpcService } from '../npcs/npc.service';
|
||||
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||
import { NpcShop } from './entities/npc-shop.entity';
|
||||
import { ShopOffer } from './entities/shop-offer.entity';
|
||||
import { ShopService } from './shop.service';
|
||||
@@ -14,12 +23,44 @@ interface Fixture {
|
||||
silver?: number;
|
||||
shopEnabled?: boolean;
|
||||
hasShop?: boolean;
|
||||
/** The fake engine's blanket answer for every condition it is handed. */
|
||||
offerUnlocked?: boolean;
|
||||
/**
|
||||
* Condition types the fake engine says do *not* hold, whatever
|
||||
* `offerUnlocked` says. Lets one list mix a met requirement with an unmet
|
||||
* one, which is what distinguishes "reputation is short" from "something
|
||||
* else is".
|
||||
*/
|
||||
unmetConditionTypes?: GameConditionType[];
|
||||
offerRepeatable?: boolean;
|
||||
offerQuantity?: number;
|
||||
ownedPotions?: number | null;
|
||||
/** Replaces the single item offer with one that sells the trophy pouch. */
|
||||
bagOffer?: boolean;
|
||||
/** Bag offers are one-off by nature; a test can lift that to reach the
|
||||
* bag-specific quantity rule underneath it. */
|
||||
bagRepeatable?: boolean;
|
||||
/** Whether the character already holds the bag the offer sells. */
|
||||
ownsBag?: boolean;
|
||||
/** Conditions on the offer, so a test can gate it on reputation. */
|
||||
conditions?: GameCondition[];
|
||||
/** The alternative way in (slice §7). */
|
||||
bypassConditions?: GameCondition[];
|
||||
/** Whether the fake engine says the bypass list holds. */
|
||||
bypassPasses?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the fake engine reports as measured, per condition type.
|
||||
*
|
||||
* Only conditions with a scale have one: a flag is set or it is not, and the
|
||||
* real engine reports `actual: null` for it.
|
||||
*/
|
||||
const FAKE_MEASURED_VALUE: Partial<Record<GameConditionType, number>> = {
|
||||
[GameConditionType.REGION_REPUTATION]: 14,
|
||||
[GameConditionType.WORLD_RENOWN]: 14,
|
||||
};
|
||||
|
||||
function createWorld(fixture: Fixture = {}) {
|
||||
const character = {
|
||||
id: CHARACTER_ID,
|
||||
@@ -36,17 +77,47 @@ function createWorld(fixture: Fixture = {}) {
|
||||
quantity: fixture.ownedPotions,
|
||||
};
|
||||
|
||||
const bag = {
|
||||
id: 'bag-trophy-pouch',
|
||||
key: 'basic-trophy-pouch',
|
||||
name: 'Basic Trophy Pouch',
|
||||
lootCategory: LootCategory.RAIDER_TROPHY,
|
||||
capacity: 5,
|
||||
iconPath: '/images/items/basic-trophy-pouch.png',
|
||||
};
|
||||
|
||||
const grantedBags: Array<Record<string, unknown>> = [];
|
||||
|
||||
const offers = [
|
||||
{
|
||||
fixture.bagOffer
|
||||
? {
|
||||
id: 'offer-bag',
|
||||
shopId: 'shop-1',
|
||||
itemDefinitionId: null,
|
||||
lootBagDefinitionId: bag.id,
|
||||
currencyType: 'SILVER',
|
||||
price: 40,
|
||||
quantity: 1,
|
||||
repeatable: fixture.bagRepeatable ?? false,
|
||||
sortOrder: 1,
|
||||
conditions: fixture.conditions ?? [],
|
||||
bypassConditions: fixture.bypassConditions ?? [],
|
||||
enabled: true,
|
||||
itemDefinition: null,
|
||||
lootBagDefinition: bag,
|
||||
}
|
||||
: {
|
||||
id: 'offer-1',
|
||||
shopId: 'shop-1',
|
||||
itemDefinitionId: 'item-potion',
|
||||
lootBagDefinitionId: null,
|
||||
currencyType: 'SILVER',
|
||||
price: 12,
|
||||
quantity: fixture.offerQuantity ?? 1,
|
||||
repeatable: fixture.offerRepeatable ?? true,
|
||||
sortOrder: 1,
|
||||
conditions: [],
|
||||
conditions: fixture.conditions ?? [],
|
||||
bypassConditions: fixture.bypassConditions ?? [],
|
||||
enabled: true,
|
||||
itemDefinition: {
|
||||
id: 'item-potion',
|
||||
@@ -54,7 +125,12 @@ function createWorld(fixture: Fixture = {}) {
|
||||
name: 'Small Healing Potion',
|
||||
description: 'A bitter draught.',
|
||||
iconPath: '/images/items/potion.png',
|
||||
weaponDamage: 0,
|
||||
bonusAttack: 0,
|
||||
bonusHp: 0,
|
||||
bonusArmor: 0,
|
||||
},
|
||||
lootBagDefinition: null,
|
||||
},
|
||||
] as unknown as ShopOffer[];
|
||||
|
||||
@@ -88,9 +164,35 @@ function createWorld(fixture: Fixture = {}) {
|
||||
return {
|
||||
findOne: () => Promise.resolve(owned),
|
||||
create: (row: Record<string, unknown>) => row,
|
||||
save: async (row: Record<string, unknown>) => {
|
||||
save: (row: Record<string, unknown>) => {
|
||||
grantedItems.push(row);
|
||||
return row;
|
||||
return Promise.resolve(row);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (entity === ReputationFaction) {
|
||||
return {
|
||||
find: () =>
|
||||
Promise.resolve([
|
||||
{ id: 'faction-1', key: 'border-guard', name: 'Border Watch' },
|
||||
]),
|
||||
};
|
||||
}
|
||||
if (entity === LootBagDefinition) {
|
||||
return { findOneBy: () => Promise.resolve(bag) };
|
||||
}
|
||||
if (entity === CharacterLootBag) {
|
||||
return {
|
||||
findOne: () =>
|
||||
Promise.resolve(
|
||||
fixture.ownsBag
|
||||
? { characterId: CHARACTER_ID, lootBagDefinitionId: bag.id }
|
||||
: null,
|
||||
),
|
||||
create: (row: Record<string, unknown>) => row,
|
||||
save: (row: Record<string, unknown>) => {
|
||||
grantedBags.push(row);
|
||||
return Promise.resolve(row);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -104,8 +206,43 @@ function createWorld(fixture: Fixture = {}) {
|
||||
run(manager),
|
||||
} as unknown as DataSource;
|
||||
|
||||
/** One condition's verdict, so `evaluate` and `describe` cannot disagree. */
|
||||
const conditionHolds = (condition: GameCondition) => {
|
||||
if (fixture.unmetConditionTypes?.includes(condition.type)) {
|
||||
return false;
|
||||
}
|
||||
return fixture.offerUnlocked ?? true;
|
||||
};
|
||||
|
||||
const conditions = {
|
||||
evaluate: jest.fn(() => Promise.resolve(fixture.offerUnlocked ?? true)),
|
||||
evaluate: jest.fn(
|
||||
(_context: unknown, list: GameCondition[] | undefined) => {
|
||||
// Mirrors the real engine (game-condition.service.ts): an AND over
|
||||
// nothing holds, so an empty list is not a gate at all. Keeping this
|
||||
// faithful is what makes the service's `bypass.length > 0` guard
|
||||
// load-bearing rather than decorative.
|
||||
if (!list || list.length === 0) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
// The fixture distinguishes the two lists by identity, so a test can say
|
||||
// "the gate is shut but the bypass is open".
|
||||
if (list === fixture.bypassConditions) {
|
||||
return Promise.resolve(fixture.bypassPasses ?? false);
|
||||
}
|
||||
// Answered from the list's own contents, so handing the gate the wrong
|
||||
// list cannot pass unnoticed.
|
||||
return Promise.resolve(list.every(conditionHolds));
|
||||
},
|
||||
),
|
||||
describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) =>
|
||||
Promise.resolve(
|
||||
(list ?? []).map((condition) => ({
|
||||
condition,
|
||||
met: conditionHolds(condition),
|
||||
actual: FAKE_MEASURED_VALUE[condition.type] ?? null,
|
||||
})),
|
||||
),
|
||||
),
|
||||
} as unknown as GameConditionService;
|
||||
|
||||
const npcs = {
|
||||
@@ -116,6 +253,7 @@ function createWorld(fixture: Fixture = {}) {
|
||||
service: new ShopService(dataSource, conditions, npcs),
|
||||
character,
|
||||
grantedItems,
|
||||
grantedBags,
|
||||
owned,
|
||||
};
|
||||
}
|
||||
@@ -190,8 +328,19 @@ describe('ShopService', () => {
|
||||
it('refuses a locked offer even when the request asks for it directly', async () => {
|
||||
// The gate is enforced server-side, so hiding it in the UI is not the
|
||||
// protection (NPC spec §33: "gesperrtes Item kann nicht direkt über API
|
||||
// gekauft werden").
|
||||
const world = createWorld({ offerUnlocked: false, silver: 1000 });
|
||||
// gekauft werden"). Gated on a flag rather than reputation, because a
|
||||
// non-reputation gate is what keeps the generic code in play.
|
||||
const world = createWorld({
|
||||
offerUnlocked: false,
|
||||
silver: 1000,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.FLAG_SET,
|
||||
key: 'vouched-for-by-the-warden',
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
@@ -203,6 +352,37 @@ describe('ShopService', () => {
|
||||
).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' });
|
||||
|
||||
expect(world.character.silver).toBe(1000);
|
||||
expect(world.grantedItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps an offer shut when its bypass list is empty', async () => {
|
||||
// An empty condition list is an AND over nothing, so it *holds*. Without
|
||||
// the service's emptiness guard, every offer in the database -- whose
|
||||
// `bypass_conditions` default to '[]' -- would open (slice §7).
|
||||
const world = createWorld({
|
||||
offerUnlocked: false,
|
||||
silver: 1000,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.FLAG_SET,
|
||||
key: 'vouched-for-by-the-warden',
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
bypassConditions: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
1,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' });
|
||||
|
||||
expect(world.character.silver).toBe(1000);
|
||||
expect(world.grantedItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses an item the shop does not stock', async () => {
|
||||
@@ -211,6 +391,9 @@ describe('ShopService', () => {
|
||||
await expect(
|
||||
world.service.purchase(CHARACTER_ID, MERCHANT_KEY, 'ash-blade', 1),
|
||||
).rejects.toMatchObject({ code: 'SHOP_OFFER_NOT_FOUND' });
|
||||
|
||||
expect(world.character.silver).toBe(100);
|
||||
expect(world.grantedItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a non-positive quantity', async () => {
|
||||
@@ -224,6 +407,9 @@ describe('ShopService', () => {
|
||||
0,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
|
||||
|
||||
expect(world.character.silver).toBe(100);
|
||||
expect(world.grantedItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses to buy a one-off offer more than once in a request', async () => {
|
||||
@@ -237,6 +423,9 @@ describe('ShopService', () => {
|
||||
2,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
|
||||
|
||||
expect(world.character.silver).toBe(100);
|
||||
expect(world.grantedItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a closed shop', async () => {
|
||||
@@ -261,4 +450,282 @@ describe('ShopService', () => {
|
||||
expect(result.quantity).toBe(6);
|
||||
expect(result.silverSpent).toBe(24);
|
||||
});
|
||||
|
||||
it('shows the requirement and the current value on a locked offer', async () => {
|
||||
const world = createWorld({
|
||||
offerUnlocked: false,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY);
|
||||
|
||||
// Locked, but still listed: a visible reward is a goal (slice §5).
|
||||
expect(view.offers).toHaveLength(1);
|
||||
expect(view.offers[0].unlocked).toBe(false);
|
||||
expect(view.offers[0].requirements).toEqual([
|
||||
{
|
||||
label: 'Requires Border Watch Reputation 25',
|
||||
current: 14,
|
||||
required: 25,
|
||||
met: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists a bag offer with its capacity as the effect', async () => {
|
||||
const world = createWorld({ bagOffer: true, silver: 100 });
|
||||
|
||||
const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY);
|
||||
|
||||
expect(view.offers[0]).toMatchObject({
|
||||
itemKey: 'basic-trophy-pouch',
|
||||
itemName: 'Basic Trophy Pouch',
|
||||
price: 40,
|
||||
effectSummary: 'Capacity: 5 Raider Trophies',
|
||||
unlocked: true,
|
||||
});
|
||||
// The capacity line is sent once, as the effect. Repeating it as the
|
||||
// description makes the row render it twice (slice §5).
|
||||
expect(view.offers[0].itemDescription).toBe('');
|
||||
});
|
||||
|
||||
it('grants a bag rather than stacking it as an item', async () => {
|
||||
// Slice §10 case 3: the reputation gate is present *and* satisfied, so the
|
||||
// purchase goes through. A fixture with no conditions at all would pass
|
||||
// without the gate ever being consulted.
|
||||
const world = createWorld({
|
||||
bagOffer: true,
|
||||
silver: 100,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'basic-trophy-pouch',
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result.silverSpent).toBe(40);
|
||||
expect(world.grantedItems).toHaveLength(0);
|
||||
expect(world.grantedBags[0]).toMatchObject({
|
||||
characterId: CHARACTER_ID,
|
||||
lootBagDefinitionId: 'bag-trophy-pouch',
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to sell a bag the character already carries', async () => {
|
||||
// A second copy grants nothing (only the roomiest active bag per category
|
||||
// counts) so charging for it would be taking Silver for nothing.
|
||||
const world = createWorld({ bagOffer: true, ownsBag: true, silver: 100 });
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'basic-trophy-pouch',
|
||||
1,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_BAG_ALREADY_OWNED' });
|
||||
|
||||
expect(world.character.silver).toBe(100);
|
||||
expect(world.grantedBags).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses to sell two of the same bag in one request', async () => {
|
||||
// A bag is one object, not a stack: the second grants nothing. The offer
|
||||
// is made repeatable here so the bag rule is what answers, not the
|
||||
// one-off rule that normally sits in front of it.
|
||||
const world = createWorld({
|
||||
bagOffer: true,
|
||||
bagRepeatable: true,
|
||||
silver: 1000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'basic-trophy-pouch',
|
||||
2,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
|
||||
|
||||
expect(world.character.silver).toBe(1000);
|
||||
expect(world.grantedBags).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('names reputation as the reason when a reputation gate is what blocks', async () => {
|
||||
const world = createWorld({
|
||||
offerUnlocked: false,
|
||||
silver: 1000,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
1,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'MERCHANT_REPUTATION_TOO_LOW' });
|
||||
|
||||
expect(world.character.silver).toBe(1000);
|
||||
expect(world.grantedItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not blame reputation when reputation is not what is short', async () => {
|
||||
// The standing is already earned; a quest flag is the thing missing.
|
||||
// Telling this player to go and earn reputation would send them after
|
||||
// something they already have (slice §6).
|
||||
const world = createWorld({
|
||||
silver: 1000,
|
||||
unmetConditionTypes: [GameConditionType.FLAG_SET],
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 10,
|
||||
},
|
||||
{
|
||||
type: GameConditionType.FLAG_SET,
|
||||
key: 'vouched-for-by-the-warden',
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
1,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' });
|
||||
|
||||
expect(world.character.silver).toBe(1000);
|
||||
expect(world.grantedItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('blocks a renown-gated offer without blaming merchant reputation', async () => {
|
||||
// Slice §10 case 4, and the discrimination this branch introduced: World
|
||||
// Renown is not the merchant's regard, so a renown block must read as a
|
||||
// plain lock. Sending this player off to trade pelts with Borin would be
|
||||
// pointing at the wrong bar entirely.
|
||||
const world = createWorld({
|
||||
silver: 1000,
|
||||
unmetConditionTypes: [GameConditionType.WORLD_RENOWN],
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.WORLD_RENOWN,
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
1,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' });
|
||||
|
||||
expect(world.character.silver).toBe(1000);
|
||||
expect(world.grantedItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('opens an offer whose bypass holds even though its conditions do not', async () => {
|
||||
// The Slice 0.9 referral: the warden's word is worth more than the
|
||||
// reputation the player has not earned yet (slice §7).
|
||||
const bypassConditions: GameCondition[] = [
|
||||
{
|
||||
type: GameConditionType.FLAG_SET,
|
||||
key: 'referred-by-south-gate-warden',
|
||||
value: true,
|
||||
},
|
||||
];
|
||||
const world = createWorld({
|
||||
bagOffer: true,
|
||||
silver: 100,
|
||||
offerUnlocked: false,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 40,
|
||||
},
|
||||
],
|
||||
bypassConditions,
|
||||
bypassPasses: true,
|
||||
});
|
||||
|
||||
const result = await world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'basic-trophy-pouch',
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result.silverSpent).toBe(40);
|
||||
expect(world.grantedBags).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still charges the price when a requirement is met', async () => {
|
||||
// Reputation opens the offer; it does not pay for it (slice §10). The
|
||||
// requirement has to actually exist and hold for that to be the thing
|
||||
// under test.
|
||||
const world = createWorld({
|
||||
bagOffer: true,
|
||||
silver: 10,
|
||||
conditions: [
|
||||
{
|
||||
type: GameConditionType.REGION_REPUTATION,
|
||||
key: 'border-guard',
|
||||
operator: ComparisonOperator.GTE,
|
||||
value: 25,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'basic-trophy-pouch',
|
||||
1,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' });
|
||||
|
||||
expect(world.character.silver).toBe(10);
|
||||
expect(world.grantedBags).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { GameConditionService } from '../conditions/game-condition.service';
|
||||
import { GameConditionType } from '../conditions/game-condition.types';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
|
||||
import { NpcService } from '../npcs/npc.service';
|
||||
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||
import { NpcShop } from './entities/npc-shop.entity';
|
||||
import { ShopOffer } from './entities/shop-offer.entity';
|
||||
import {
|
||||
describeBagEffect,
|
||||
describeItemEffect,
|
||||
describeRequirement,
|
||||
ShopOfferRequirementDto,
|
||||
} from './offer-presentation';
|
||||
import {
|
||||
characterNotFound,
|
||||
merchantReputationTooLow,
|
||||
shopBagAlreadyOwned,
|
||||
shopDisabled,
|
||||
shopInsufficientSilver,
|
||||
shopInvalidQuantity,
|
||||
@@ -26,12 +37,34 @@ export interface ShopOfferDto {
|
||||
currencyType: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
/** What buying it does: weapon stats, or a bag's capacity (slice §8). */
|
||||
effectSummary: string | null;
|
||||
/** Why it is locked, and how close the player is (slice §5). Empty when open. */
|
||||
requirements: ShopOfferRequirementDto[];
|
||||
/** False when the offer's conditions are not met (Slice 0.8.5 content). */
|
||||
unlocked: boolean;
|
||||
/** True when the character simply cannot afford an otherwise open offer. */
|
||||
affordable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an offer sells, flattened out of whichever target it points at.
|
||||
*
|
||||
* Both shapes reduce to a key, a name, an icon and an effect, which is all the
|
||||
* presentation layer needs -- and `kind` plus `definitionId` is all the grant
|
||||
* needs, so the branch on offer kind lives in one place instead of being spread
|
||||
* across the view and the purchase path.
|
||||
*/
|
||||
interface OfferTarget {
|
||||
kind: 'item' | 'bag';
|
||||
definitionId: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
iconPath: string;
|
||||
effectSummary: string | null;
|
||||
}
|
||||
|
||||
export interface ShopViewDto {
|
||||
shopKey: string;
|
||||
shopName: string;
|
||||
@@ -79,26 +112,58 @@ export class ShopService {
|
||||
|
||||
const offers = await this.dataSource.getRepository(ShopOffer).find({
|
||||
where: { shopId: shop.id, enabled: true },
|
||||
relations: { itemDefinition: true },
|
||||
relations: { itemDefinition: true, lootBagDefinition: true },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
|
||||
// One read for the whole view: every reputation requirement needs a
|
||||
// display name, and offers commonly gate on the same faction. Restricted
|
||||
// to enabled factions because that is what the condition engine evaluates
|
||||
// against -- naming a faction the engine treats as absent would describe a
|
||||
// requirement that can never be met.
|
||||
const factionNames = new Map(
|
||||
(
|
||||
await this.dataSource
|
||||
.getRepository(ReputationFaction)
|
||||
.find({ where: { enabled: true } })
|
||||
).map((faction) => [faction.key, faction.name]),
|
||||
);
|
||||
|
||||
const context = { characterId, npcId };
|
||||
const view: ShopOfferDto[] = [];
|
||||
for (const offer of offers) {
|
||||
const unlocked = await this.conditions.evaluate(
|
||||
{ characterId, npcId },
|
||||
const target = this.resolveTarget(offer);
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const gate = await this.evaluateGate(context, offer);
|
||||
// Described even when open, so the UI can show a requirement the player
|
||||
// has already met rather than having it vanish on unlock. Only
|
||||
// `conditions` are described: a bypass is content's private exception,
|
||||
// not something the player is told to go and satisfy.
|
||||
const outcomes = await this.conditions.describe(
|
||||
context,
|
||||
offer.conditions,
|
||||
);
|
||||
const requirements = outcomes
|
||||
.map((outcome) => describeRequirement(outcome, factionNames))
|
||||
.filter(
|
||||
(requirement): requirement is ShopOfferRequirementDto =>
|
||||
requirement !== null,
|
||||
);
|
||||
|
||||
view.push({
|
||||
itemKey: offer.itemDefinition.key,
|
||||
itemName: offer.itemDefinition.name,
|
||||
itemDescription: offer.itemDefinition.description,
|
||||
iconPath: offer.itemDefinition.iconPath,
|
||||
itemKey: target.key,
|
||||
itemName: target.name,
|
||||
itemDescription: target.description,
|
||||
iconPath: target.iconPath,
|
||||
currencyType: offer.currencyType,
|
||||
price: offer.price,
|
||||
quantity: offer.quantity,
|
||||
unlocked,
|
||||
effectSummary: target.effectSummary,
|
||||
requirements,
|
||||
unlocked: gate.open,
|
||||
affordable: character.silver >= offer.price,
|
||||
});
|
||||
}
|
||||
@@ -141,51 +206,83 @@ export class ShopService {
|
||||
}
|
||||
|
||||
// Matched on the joined definition's business key: the offer table is
|
||||
// keyed by item definition id, while the request carries the stable key.
|
||||
// keyed by definition id, while the request carries the stable key.
|
||||
const offers = await manager.getRepository(ShopOffer).find({
|
||||
where: { shopId: shop.id, enabled: true },
|
||||
relations: { itemDefinition: true },
|
||||
relations: { itemDefinition: true, lootBagDefinition: true },
|
||||
// Same order as the view: both paths answer "which offer does this key
|
||||
// mean", so neither may answer it from an arbitrary row order.
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
const match = offers.find(
|
||||
(candidate) => candidate.itemDefinition.key === itemKey,
|
||||
);
|
||||
let match: ShopOffer | undefined;
|
||||
let target: OfferTarget | undefined;
|
||||
for (const candidate of offers) {
|
||||
const candidateTarget = this.resolveTarget(candidate);
|
||||
if (candidateTarget?.key === itemKey) {
|
||||
match = candidate;
|
||||
target = candidateTarget;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
if (!match || !target) {
|
||||
throw shopOfferNotFound();
|
||||
}
|
||||
|
||||
const unlocked = await this.conditions.evaluate(
|
||||
const gate = await this.evaluateGate(
|
||||
{ characterId, npcId },
|
||||
match.conditions,
|
||||
match,
|
||||
manager,
|
||||
);
|
||||
if (!unlocked) {
|
||||
throw shopOfferLocked();
|
||||
if (!gate.open) {
|
||||
throw gate.reputationBlocked
|
||||
? merchantReputationTooLow()
|
||||
: shopOfferLocked();
|
||||
}
|
||||
|
||||
if (!match.repeatable && quantity > 1) {
|
||||
throw shopInvalidQuantity();
|
||||
}
|
||||
// A bag is one object, not a stack: buying two grants nothing extra.
|
||||
if (target.kind === 'bag' && quantity > 1) {
|
||||
throw shopInvalidQuantity();
|
||||
}
|
||||
|
||||
const silverSpent = match.price * quantity;
|
||||
if (character.silver < silverSpent) {
|
||||
throw shopInsufficientSilver();
|
||||
}
|
||||
|
||||
// Checked before the debit: the transaction would roll the Silver back
|
||||
// anyway, but failing on the cheap read keeps the error honest about
|
||||
// what went wrong.
|
||||
if (target.kind === 'bag') {
|
||||
const owned = await manager.getRepository(CharacterLootBag).findOne({
|
||||
where: { characterId, lootBagDefinitionId: target.definitionId },
|
||||
});
|
||||
if (owned) {
|
||||
throw shopBagAlreadyOwned();
|
||||
}
|
||||
}
|
||||
|
||||
character.silver -= silverSpent;
|
||||
await characters.save(character);
|
||||
|
||||
if (target.kind === 'bag') {
|
||||
await this.grantLootBag(manager, characterId, target.definitionId);
|
||||
} else {
|
||||
await this.grantItem(
|
||||
manager,
|
||||
characterId,
|
||||
match.itemDefinitionId,
|
||||
target.definitionId,
|
||||
match.quantity * quantity,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
shopKey: shop.key,
|
||||
itemKey,
|
||||
itemName: match.itemDefinition.name,
|
||||
itemName: target.name,
|
||||
quantity: match.quantity * quantity,
|
||||
silverSpent,
|
||||
silverBalance: character.silver,
|
||||
@@ -193,6 +290,112 @@ export class ShopService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What an offer sells, whichever kind of thing that is.
|
||||
*
|
||||
* The two targets are mutually exclusive by `CHK_shop_offers_single_target`,
|
||||
* so resolving them here lets the view and the purchase path agree on what an
|
||||
* offer *is* without either of them branching on offer kind itself.
|
||||
*/
|
||||
private resolveTarget(offer: ShopOffer): OfferTarget | null {
|
||||
if (offer.itemDefinition) {
|
||||
return {
|
||||
kind: 'item',
|
||||
definitionId: offer.itemDefinition.id,
|
||||
key: offer.itemDefinition.key,
|
||||
name: offer.itemDefinition.name,
|
||||
description: offer.itemDefinition.description,
|
||||
iconPath: offer.itemDefinition.iconPath,
|
||||
effectSummary: describeItemEffect(offer.itemDefinition),
|
||||
};
|
||||
}
|
||||
if (offer.lootBagDefinition) {
|
||||
return {
|
||||
kind: 'bag',
|
||||
definitionId: offer.lootBagDefinition.id,
|
||||
key: offer.lootBagDefinition.key,
|
||||
name: offer.lootBagDefinition.name,
|
||||
// A bag definition carries no flavour text of its own. The capacity
|
||||
// line is the whole of what there is to say about it, and it is already
|
||||
// carried by `effectSummary`; repeating it here would render it twice
|
||||
// (slice §5 shows the line once).
|
||||
description: '',
|
||||
iconPath: offer.lootBagDefinition.iconPath,
|
||||
effectSummary: describeBagEffect(offer.lootBagDefinition),
|
||||
};
|
||||
}
|
||||
// CHK_shop_offers_single_target makes this unreachable through the
|
||||
// database. Skipping the row beats rendering an offer that sells nothing.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an offer is open, and why not when it is shut.
|
||||
*
|
||||
* `conditions` OR `bypassConditions` -- the whole exception model (slice §7).
|
||||
* A referral does not lower the requirement; it provides a second, narrower
|
||||
* door that content opens deliberately.
|
||||
*/
|
||||
private async evaluateGate(
|
||||
context: { characterId: string; npcId: string },
|
||||
offer: ShopOffer,
|
||||
manager?: EntityManager,
|
||||
): Promise<{ open: boolean; reputationBlocked: boolean }> {
|
||||
if (await this.conditions.evaluate(context, offer.conditions, manager)) {
|
||||
return { open: true, reputationBlocked: false };
|
||||
}
|
||||
|
||||
const bypass = offer.bypassConditions ?? [];
|
||||
if (
|
||||
bypass.length > 0 &&
|
||||
(await this.conditions.evaluate(context, bypass, manager))
|
||||
) {
|
||||
return { open: true, reputationBlocked: false };
|
||||
}
|
||||
|
||||
// Which error to raise depends on what is actually short, so the player is
|
||||
// told to earn reputation only when reputation is the thing missing.
|
||||
const outcomes = await this.conditions.describe(
|
||||
context,
|
||||
offer.conditions,
|
||||
manager,
|
||||
);
|
||||
const reputationBlocked = outcomes.some(
|
||||
(outcome) =>
|
||||
!outcome.met &&
|
||||
outcome.condition.type === GameConditionType.REGION_REPUTATION,
|
||||
);
|
||||
|
||||
return { open: false, reputationBlocked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands over a bag, once.
|
||||
*
|
||||
* A second copy of the same bag grants nothing -- only the roomiest active
|
||||
* bag per category counts (Slice 0.7.5 §6) -- so a repeat purchase is
|
||||
* refused rather than silently charged. The unique index on
|
||||
* (character_id, loot_bag_definition_id) is the real guarantee; this check
|
||||
* is what turns a constraint violation into an explainable domain error.
|
||||
*/
|
||||
private async grantLootBag(
|
||||
manager: { getRepository: DataSource['getRepository'] },
|
||||
characterId: string,
|
||||
lootBagDefinitionId: string,
|
||||
): Promise<void> {
|
||||
const bags = manager.getRepository(CharacterLootBag);
|
||||
const existing = await bags.findOne({
|
||||
where: { characterId, lootBagDefinitionId },
|
||||
});
|
||||
if (existing) {
|
||||
throw shopBagAlreadyOwned();
|
||||
}
|
||||
|
||||
await bags.save(
|
||||
bags.create({ characterId, lootBagDefinitionId, active: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds to an existing stack or starts a new one.
|
||||
*
|
||||
|
||||
@@ -3,7 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { ConditionsModule } from '../conditions/conditions.module';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
|
||||
import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity';
|
||||
import { NpcsModule } from '../npcs/npcs.module';
|
||||
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||
import { NpcShop } from './entities/npc-shop.entity';
|
||||
import { ShopOffer } from './entities/shop-offer.entity';
|
||||
import { ShopController } from './shop.controller';
|
||||
@@ -12,7 +15,15 @@ import { ShopService } from './shop.service';
|
||||
/** Buying things for Silver (NPC spec §15, §29). */
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Character, CharacterItem, NpcShop, ShopOffer]),
|
||||
TypeOrmModule.forFeature([
|
||||
Character,
|
||||
CharacterItem,
|
||||
CharacterLootBag,
|
||||
LootBagDefinition,
|
||||
NpcShop,
|
||||
ReputationFaction,
|
||||
ShopOffer,
|
||||
]),
|
||||
ConditionsModule,
|
||||
NpcsModule,
|
||||
],
|
||||
|
||||
@@ -430,6 +430,14 @@ export interface ExchangeResult {
|
||||
capacities: LootCapacity[];
|
||||
}
|
||||
|
||||
/** One gate on an offer, as the server phrased it (slice 0.8.5 §5, §8). */
|
||||
export interface ShopOfferRequirement {
|
||||
label: string;
|
||||
current: number | null;
|
||||
required: number | null;
|
||||
met: boolean;
|
||||
}
|
||||
|
||||
export interface ShopOfferView {
|
||||
itemKey: string;
|
||||
itemName: string;
|
||||
@@ -438,6 +446,8 @@ export interface ShopOfferView {
|
||||
currencyType: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
effectSummary: string | null;
|
||||
requirements: ShopOfferRequirement[];
|
||||
unlocked: boolean;
|
||||
affordable: boolean;
|
||||
}
|
||||
|
||||
@@ -208,6 +208,21 @@
|
||||
<h2 class="merchant__panel-title">{{ shop.shopName }}</h2>
|
||||
<p class="merchant__purse" data-purse>{{ shop.silver }} Silver</p>
|
||||
|
||||
@if (store.newlyUnlocked().length > 0) {
|
||||
<p class="shop-unlocked" data-unlocked aria-live="polite">
|
||||
@for (name of store.newlyUnlocked(); track name) {
|
||||
<span>New merchant offer unlocked: {{ name }}</span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="merchant__link"
|
||||
(click)="store.dismissUnlocked()"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
|
||||
<ul class="shop-list">
|
||||
@for (offer of shop.offers; track offer.itemKey) {
|
||||
<li class="shop-row" [attr.data-shop-item]="offer.itemKey">
|
||||
@@ -219,9 +234,33 @@
|
||||
/>
|
||||
<div class="shop-row__naming">
|
||||
<span class="shop-row__name">{{ offer.itemName }}</span>
|
||||
@if (offer.itemDescription) {
|
||||
<span class="shop-row__description">{{
|
||||
offer.itemDescription
|
||||
}}</span>
|
||||
}
|
||||
@if (offer.effectSummary) {
|
||||
<span class="shop-row__effect" data-effect>{{
|
||||
offer.effectSummary
|
||||
}}</span>
|
||||
}
|
||||
@for (
|
||||
requirement of offer.requirements;
|
||||
track requirement.label
|
||||
) {
|
||||
<span
|
||||
class="shop-row__requirement"
|
||||
[class.shop-row__requirement--met]="requirement.met"
|
||||
data-requirement
|
||||
>
|
||||
{{ requirement.label }}
|
||||
@if (!requirement.met && requirement.current !== null) {
|
||||
<span class="shop-row__current"
|
||||
>· Current: {{ requirement.current }}</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<span class="shop-row__price">{{ offer.price }} Silver</span>
|
||||
<button
|
||||
|
||||
@@ -351,6 +351,26 @@
|
||||
font-size: var(--ar-font-sm);
|
||||
}
|
||||
|
||||
.shop-row__effect {
|
||||
color: var(--ar-text);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* A lock the player can measure themselves against, not a dead end
|
||||
(slice 0.8.5 §5). */
|
||||
.shop-row__requirement {
|
||||
color: var(--ar-gold);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.shop-row__requirement--met {
|
||||
color: var(--ar-text-muted);
|
||||
}
|
||||
|
||||
.shop-row__current {
|
||||
color: var(--ar-text-muted);
|
||||
}
|
||||
|
||||
.shop-receipt {
|
||||
display: flex;
|
||||
gap: var(--ar-space-2);
|
||||
@@ -360,6 +380,17 @@
|
||||
font-size: var(--ar-font-sm);
|
||||
}
|
||||
|
||||
/* A line, not a modal (slice 0.8.5 §9). */
|
||||
.shop-unlocked {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ar-space-2);
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
color: var(--ar-gold);
|
||||
font-size: var(--ar-font-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.merchant__identity {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -83,6 +83,8 @@ const SHOP: ShopView = {
|
||||
currencyType: 'SILVER',
|
||||
price: 12,
|
||||
quantity: 1,
|
||||
effectSummary: null,
|
||||
requirements: [],
|
||||
unlocked: true,
|
||||
affordable: true,
|
||||
},
|
||||
@@ -94,6 +96,8 @@ const SHOP: ShopView = {
|
||||
currencyType: 'SILVER',
|
||||
price: 400,
|
||||
quantity: 1,
|
||||
effectSummary: null,
|
||||
requirements: [],
|
||||
unlocked: false,
|
||||
affordable: false,
|
||||
},
|
||||
@@ -160,6 +164,79 @@ async function render(): Promise<{
|
||||
return { fixture, element: fixture.nativeElement as HTMLElement, store };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the merchant page with a caller-supplied shop and opens the shop
|
||||
* panel, so requirement/effect tests don't need to repeat the NPC and
|
||||
* exchange fixtures they don't care about.
|
||||
*/
|
||||
async function renderMerchantWithShop(
|
||||
shopOverrides: Partial<ShopView>,
|
||||
): Promise<{
|
||||
fixture: ComponentFixture<MerchantPageComponent>;
|
||||
element: HTMLElement;
|
||||
store: MerchantStore;
|
||||
}> {
|
||||
const shop: ShopView = {
|
||||
shopKey: 'borin-supplies',
|
||||
shopName: "Quartermaster's Supplies",
|
||||
npcKey: 'borin-quartermaster',
|
||||
silver: 100,
|
||||
offers: [],
|
||||
...shopOverrides,
|
||||
};
|
||||
|
||||
const api = {
|
||||
getNpcInteraction: vi.fn(() => of(INTERACTION)),
|
||||
getTradeIn: vi.fn(() => of(EXCHANGE)),
|
||||
getShop: vi.fn(() => of(shop)),
|
||||
tradeIn: vi.fn(() => of(TRADE_RESULT)),
|
||||
purchase: vi.fn(() =>
|
||||
of({
|
||||
shopKey: 'borin-supplies',
|
||||
itemKey: 'small-healing-potion',
|
||||
itemName: 'Small Healing Potion',
|
||||
quantity: 1,
|
||||
silverSpent: 12,
|
||||
silverBalance: 88,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MerchantPageComponent],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: GameApiService, useValue: api },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: { paramMap: { get: () => 'borin-quartermaster' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(MerchantPageComponent);
|
||||
const store = TestBed.inject(MerchantStore);
|
||||
|
||||
fixture.detectChanges();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
element
|
||||
.querySelector<HTMLButtonElement>('[data-action="OPEN_SHOP"]')
|
||||
?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
return { fixture, element, store };
|
||||
}
|
||||
|
||||
/** How many times `needle` shows up in `haystack` -- `toContain` cannot say. */
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1;
|
||||
}
|
||||
|
||||
describe('MerchantPageComponent', () => {
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
@@ -308,4 +385,147 @@ describe('MerchantPageComponent', () => {
|
||||
|
||||
expect(element.querySelector('[data-purse]')?.textContent).toContain('100');
|
||||
});
|
||||
|
||||
it('shows the requirement and the current value on a locked offer', async () => {
|
||||
// A visible lock with a number attached is a goal; a hidden offer is not
|
||||
// (slice §5). Shaped like the real Trophy Pouch offer: a bag definition
|
||||
// carries no flavour text, so the API sends an empty description and the
|
||||
// capacity line arrives once, as the effect.
|
||||
const { fixture } = await renderMerchantWithShop({
|
||||
offers: [
|
||||
{
|
||||
itemKey: 'basic-trophy-pouch',
|
||||
itemName: 'Basic Trophy Pouch',
|
||||
itemDescription: '',
|
||||
iconPath: '/images/items/basic-trophy-pouch.png',
|
||||
currencyType: 'SILVER',
|
||||
price: 40,
|
||||
quantity: 1,
|
||||
effectSummary: 'Capacity: 5 Raider Trophies',
|
||||
requirements: [
|
||||
{
|
||||
label: 'Requires Border Watch Reputation 25',
|
||||
current: 14,
|
||||
required: 25,
|
||||
met: false,
|
||||
},
|
||||
],
|
||||
unlocked: false,
|
||||
affordable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const row = fixture.nativeElement.querySelector(
|
||||
'[data-shop-item="basic-trophy-pouch"]',
|
||||
);
|
||||
expect(row.textContent).toContain('Requires Border Watch Reputation 25');
|
||||
expect(row.textContent).toContain('Current: 14');
|
||||
// Once, not twice: slice §5's worked example shows the capacity line a
|
||||
// single time.
|
||||
expect(
|
||||
countOccurrences(row.textContent, 'Capacity: 5 Raider Trophies'),
|
||||
).toBe(1);
|
||||
// An empty description renders nothing at all, rather than an empty span
|
||||
// that would let a duplicated capacity line back in unnoticed.
|
||||
expect(row.querySelector('.shop-row__description')).toBeNull();
|
||||
expect(row.querySelector('button').disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows what a purchase does', async () => {
|
||||
const { fixture } = await renderMerchantWithShop({
|
||||
offers: [
|
||||
{
|
||||
itemKey: 'bandit-blade',
|
||||
itemName: 'Bandit Blade',
|
||||
itemDescription: 'A roughly serrated blade, forged for quick raids.',
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
currencyType: 'SILVER',
|
||||
price: 60,
|
||||
quantity: 1,
|
||||
effectSummary: '11 Weapon Damage, +1 Attack',
|
||||
requirements: [
|
||||
{
|
||||
label: 'Requires World Renown 3',
|
||||
current: 1,
|
||||
required: 3,
|
||||
met: false,
|
||||
},
|
||||
],
|
||||
unlocked: false,
|
||||
affordable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const row = fixture.nativeElement.querySelector(
|
||||
'[data-shop-item="bandit-blade"]',
|
||||
);
|
||||
expect(row.textContent).toContain('11 Weapon Damage, +1 Attack');
|
||||
});
|
||||
|
||||
it('does not label an open offer as requiring anything unmet', async () => {
|
||||
const { fixture } = await renderMerchantWithShop({
|
||||
offers: [
|
||||
{
|
||||
itemKey: 'small-healing-potion',
|
||||
itemName: 'Small Healing Potion',
|
||||
itemDescription: 'A bitter draught.',
|
||||
iconPath: '/images/items/potion.png',
|
||||
currencyType: 'SILVER',
|
||||
price: 12,
|
||||
quantity: 1,
|
||||
effectSummary: null,
|
||||
requirements: [],
|
||||
unlocked: true,
|
||||
affordable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const row = fixture.nativeElement.querySelector(
|
||||
'[data-shop-item="small-healing-potion"]',
|
||||
);
|
||||
expect(row.querySelector('[data-requirement]')).toBeNull();
|
||||
expect(row.querySelector('button').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('renders a bypassed offer as buyable even with an unmet requirement shown', async () => {
|
||||
// A later slice opens an offer through a quest bypass rather than by
|
||||
// meeting the stated requirement. The server still describes the
|
||||
// requirement it evaluated, but `unlocked` is what actually gates the
|
||||
// button -- an unmet requirement must not re-lock a bypassed offer.
|
||||
const { fixture } = await renderMerchantWithShop({
|
||||
offers: [
|
||||
{
|
||||
itemKey: 'referred-trinket',
|
||||
itemName: 'Referred Trinket',
|
||||
itemDescription: 'A favour, called in.',
|
||||
iconPath: '/images/items/referred-trinket.png',
|
||||
currencyType: 'SILVER',
|
||||
price: 20,
|
||||
quantity: 1,
|
||||
effectSummary: null,
|
||||
requirements: [
|
||||
{
|
||||
label: 'Requires Border Watch Reputation 25',
|
||||
current: 14,
|
||||
required: 25,
|
||||
met: false,
|
||||
},
|
||||
],
|
||||
unlocked: true,
|
||||
affordable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const row = fixture.nativeElement.querySelector(
|
||||
'[data-shop-item="referred-trinket"]',
|
||||
);
|
||||
expect(row.textContent).toContain('Requires Border Watch Reputation 25');
|
||||
const button = row.querySelector('button');
|
||||
expect(button.disabled).toBe(false);
|
||||
expect(button.textContent).toContain('Buy');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ExchangeResult,
|
||||
ExchangeView,
|
||||
NpcInteraction,
|
||||
ShopOfferView,
|
||||
ShopView,
|
||||
} from '../../core/api/game-api.models';
|
||||
import { GameApiService } from '../../core/api/game-api.service';
|
||||
@@ -74,24 +75,35 @@ function exchangeView(overrides: Partial<ExchangeView> = {}): ExchangeView {
|
||||
};
|
||||
}
|
||||
|
||||
function shopView(): ShopView {
|
||||
/** A single shop offer, defaulting to the fields no test in this file varies. */
|
||||
function offer(
|
||||
itemKey: string,
|
||||
itemName: string,
|
||||
unlocked: boolean,
|
||||
): ShopOfferView {
|
||||
return {
|
||||
shopKey: 'borin-supplies',
|
||||
shopName: "Quartermaster's Supplies",
|
||||
npcKey: 'borin-quartermaster',
|
||||
silver: 100,
|
||||
offers: [
|
||||
{
|
||||
itemKey: 'small-healing-potion',
|
||||
itemName: 'Small Healing Potion',
|
||||
itemKey,
|
||||
itemName,
|
||||
itemDescription: 'A bitter draught.',
|
||||
iconPath: '/images/items/potion.png',
|
||||
currencyType: 'SILVER',
|
||||
price: 12,
|
||||
quantity: 1,
|
||||
unlocked: true,
|
||||
effectSummary: null,
|
||||
requirements: [],
|
||||
unlocked,
|
||||
affordable: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shopView(offers?: ShopOfferView[]): ShopView {
|
||||
return {
|
||||
shopKey: 'borin-supplies',
|
||||
shopName: "Quartermaster's Supplies",
|
||||
npcKey: 'borin-quartermaster',
|
||||
silver: 100,
|
||||
offers: offers ?? [
|
||||
offer('small-healing-potion', 'Small Healing Potion', true),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -109,11 +121,23 @@ function tradeResult(): ExchangeResult {
|
||||
};
|
||||
}
|
||||
|
||||
function createApi(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
function createApi(
|
||||
overrides: Partial<Record<string, unknown>> & { shopSequence?: ShopView[] } = {},
|
||||
) {
|
||||
// `shopSequence` lets a test hand back a different shop view on each call to
|
||||
// `getShop`, so before/after `unlocked` flags can be observed across a trade
|
||||
// without needing a real server round-trip.
|
||||
const { shopSequence, ...rest } = overrides;
|
||||
let shopCallIndex = 0;
|
||||
|
||||
return {
|
||||
getNpcInteraction: vi.fn(() => of(interaction())),
|
||||
getTradeIn: vi.fn(() => of(exchangeView())),
|
||||
getShop: vi.fn(() => of(shopView())),
|
||||
getShop: vi.fn(() =>
|
||||
shopSequence
|
||||
? of(shopSequence[Math.min(shopCallIndex++, shopSequence.length - 1)])
|
||||
: of(shopView()),
|
||||
),
|
||||
tradeIn: vi.fn(() => of(tradeResult())),
|
||||
getCharacter: vi.fn(() =>
|
||||
of({
|
||||
@@ -135,7 +159,7 @@ function createApi(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
silverBalance: 88,
|
||||
}),
|
||||
),
|
||||
...overrides,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -359,4 +383,75 @@ describe('MerchantStore', () => {
|
||||
store.showPanel('EXCHANGE');
|
||||
expect(store.panel()).toBe('EXCHANGE');
|
||||
});
|
||||
|
||||
it('announces an offer that a trade just unlocked', async () => {
|
||||
// Reputation earned by the trade opened the pouch. The player should learn
|
||||
// that without hunting for it (slice §9).
|
||||
const api = createApi({
|
||||
shopSequence: [
|
||||
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]),
|
||||
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', true)]),
|
||||
],
|
||||
});
|
||||
const store = createStore(api);
|
||||
|
||||
await store.load('borin-quartermaster');
|
||||
store.setQuantity('ash-pelt', 1);
|
||||
await store.tradeSelected();
|
||||
|
||||
expect(store.newlyUnlocked()).toEqual(['Basic Trophy Pouch']);
|
||||
});
|
||||
|
||||
it('says nothing when a trade unlocks nothing', async () => {
|
||||
const api = createApi({
|
||||
shopSequence: [
|
||||
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]),
|
||||
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]),
|
||||
],
|
||||
});
|
||||
const store = createStore(api);
|
||||
|
||||
await store.load('borin-quartermaster');
|
||||
store.setQuantity('ash-pelt', 1);
|
||||
await store.tradeSelected();
|
||||
|
||||
expect(store.newlyUnlocked()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not re-announce an offer that was already open', async () => {
|
||||
const api = createApi({
|
||||
shopSequence: [
|
||||
shopView([offer('small-healing-potion', 'Small Healing Potion', true)]),
|
||||
shopView([offer('small-healing-potion', 'Small Healing Potion', true)]),
|
||||
],
|
||||
});
|
||||
const store = createStore(api);
|
||||
|
||||
await store.load('borin-quartermaster');
|
||||
store.setQuantity('ash-pelt', 1);
|
||||
await store.tradeSelected();
|
||||
|
||||
expect(store.newlyUnlocked()).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears the unlock banner on a fresh load and at the start of a purchase', async () => {
|
||||
// A stale banner from a previous merchant visit, or from before a buy
|
||||
// click resolves, would misattribute an unlock to the wrong action.
|
||||
const api = createApi({
|
||||
shopSequence: [
|
||||
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]),
|
||||
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', true)]),
|
||||
],
|
||||
});
|
||||
const store = createStore(api);
|
||||
|
||||
await store.load('borin-quartermaster');
|
||||
store.setQuantity('ash-pelt', 1);
|
||||
await store.tradeSelected();
|
||||
expect(store.newlyUnlocked()).toEqual(['Basic Trophy Pouch']);
|
||||
|
||||
await store.buy('basic-trophy-pouch');
|
||||
|
||||
expect(store.newlyUnlocked()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,8 @@ const ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
||||
SHOP_OFFER_LOCKED: 'You have not earned the right to buy this yet.',
|
||||
SHOP_INSUFFICIENT_SILVER: 'You cannot afford that.',
|
||||
SHOP_INVALID_QUANTITY: 'That quantity cannot be bought.',
|
||||
MERCHANT_REPUTATION_TOO_LOW: 'You have not earned enough standing for this yet.',
|
||||
SHOP_BAG_ALREADY_OWNED: 'You already carry that.',
|
||||
CHARACTER_NOT_FOUND: 'Your character could not be found.',
|
||||
};
|
||||
|
||||
@@ -59,6 +61,7 @@ export class MerchantStore {
|
||||
private readonly lastTradeState = signal<ExchangeResult | null>(null);
|
||||
private readonly lastPurchaseState = signal<ShopPurchaseResult | null>(null);
|
||||
private readonly selectionState = signal<Record<string, number>>({});
|
||||
private readonly newlyUnlockedState = signal<string[]>([]);
|
||||
|
||||
readonly interaction = this.interactionState.asReadonly();
|
||||
readonly exchange = this.exchangeState.asReadonly();
|
||||
@@ -71,6 +74,7 @@ export class MerchantStore {
|
||||
readonly lastTrade = this.lastTradeState.asReadonly();
|
||||
readonly lastPurchase = this.lastPurchaseState.asReadonly();
|
||||
readonly selection = this.selectionState.asReadonly();
|
||||
readonly newlyUnlocked = this.newlyUnlockedState.asReadonly();
|
||||
|
||||
/** True once anything is selected, so the trade button can enable. */
|
||||
readonly hasSelection = computed(() =>
|
||||
@@ -110,6 +114,7 @@ export class MerchantStore {
|
||||
this.lastTradeState.set(null);
|
||||
this.lastPurchaseState.set(null);
|
||||
this.selectionState.set({});
|
||||
this.newlyUnlockedState.set([]);
|
||||
this.panelState.set('DIALOGUE');
|
||||
|
||||
try {
|
||||
@@ -207,8 +212,13 @@ export class MerchantStore {
|
||||
// capacity, Silver and possibly renown at once, and the server is the
|
||||
// only place that knows all of it.
|
||||
this.exchangeState.set(await firstValueFrom(this.api.getTradeIn(npcKey)));
|
||||
this.shopState.set(
|
||||
this.shopState() ? await firstValueFrom(this.api.getShop(npcKey)) : null,
|
||||
const previousShop = this.shopState();
|
||||
const refreshedShop = previousShop
|
||||
? await firstValueFrom(this.api.getShop(npcKey))
|
||||
: null;
|
||||
this.shopState.set(refreshedShop);
|
||||
this.newlyUnlockedState.set(
|
||||
this.unlockedSince(previousShop, refreshedShop),
|
||||
);
|
||||
|
||||
// The purse in the top bar comes from the shared character state, so a
|
||||
@@ -230,6 +240,9 @@ export class MerchantStore {
|
||||
|
||||
this.pendingState.set(itemKey);
|
||||
this.actionErrorState.set(null);
|
||||
// A purchase re-reads the shop below, so a banner from an earlier trade
|
||||
// must not linger and be misread as caused by this buy.
|
||||
this.newlyUnlockedState.set([]);
|
||||
|
||||
try {
|
||||
this.lastPurchaseState.set(
|
||||
@@ -252,6 +265,37 @@ export class MerchantStore {
|
||||
this.lastPurchaseState.set(null);
|
||||
}
|
||||
|
||||
dismissUnlocked(): void {
|
||||
this.newlyUnlockedState.set([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer names that went from locked to open (slice §9).
|
||||
*
|
||||
* Derived from two shop reads the store already performs rather than from a
|
||||
* server event: the trade re-fetches the shop anyway, so the before/after
|
||||
* state is in hand, and a notification the server has to remember would be
|
||||
* more machinery than a one-line message is worth.
|
||||
*/
|
||||
private unlockedSince(
|
||||
before: ShopView | null,
|
||||
after: ShopView | null,
|
||||
): string[] {
|
||||
if (!before || !after) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const wasLocked = new Set(
|
||||
before.offers
|
||||
.filter((offer) => !offer.unlocked)
|
||||
.map((offer) => offer.itemKey),
|
||||
);
|
||||
|
||||
return after.offers
|
||||
.filter((offer) => offer.unlocked && wasLocked.has(offer.itemKey))
|
||||
.map((offer) => offer.itemName);
|
||||
}
|
||||
|
||||
private toMessage(error: unknown): string {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const code = (error.error as { code?: string } | null)?.code;
|
||||
|
||||
@@ -223,14 +223,20 @@ No large modal is required.
|
||||
|
||||
## 11. Acceptance Criteria
|
||||
|
||||
- [ ] Merchant offers can define reputation/unlock requirements.
|
||||
- [ ] At least two offers visibly demonstrate locked states.
|
||||
- [ ] Requirements are shown to the player in English.
|
||||
- [ ] Server rejects purchases when requirements are not met.
|
||||
- [ ] New progression offers do not depend on level gates.
|
||||
- [ ] Quest/referral unlock support exists for Slice 0.9.
|
||||
- [ ] Reputation increase can visibly unlock a previously locked offer.
|
||||
- [ ] No generalized rules engine was added unnecessarily.
|
||||
- [x] Merchant offers can define reputation/unlock requirements.
|
||||
- [x] At least two offers visibly demonstrate locked states.
|
||||
- [x] Requirements are shown to the player in English.
|
||||
- [x] Server rejects purchases when requirements are not met.
|
||||
- [x] New progression offers do not depend on level gates.
|
||||
- [x] Quest/referral unlock support exists for Slice 0.9.
|
||||
- [x] Reputation increase can visibly unlock a previously locked offer.
|
||||
- [x] No generalized rules engine was added unnecessarily.
|
||||
|
||||
> **Note (implementation):** The Bandit Blade offer is gated on World Renown 3,
|
||||
> which current content cannot reach — there is one renown milestone, worth +1.
|
||||
> It is deliberately left visible and locked as a long-horizon goal until
|
||||
> Slice 0.11 adds the milestones that reach it (§5: visible rewards create
|
||||
> goals).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -133,7 +133,16 @@ Category: HIDE
|
||||
Capacity: 5
|
||||
```
|
||||
|
||||
The grant should use the quest/referral exception supported by Slice 0.8.5.
|
||||
The grant uses the referral exception built in Slice 0.8.5: the Basic Hide Bag
|
||||
offer (`BORIN_OFFER_IDS.hideBag`) carries
|
||||
|
||||
```text
|
||||
bypassConditions: [{ type: FLAG_SET, key: 'referred-by-south-gate-warden' }]
|
||||
```
|
||||
|
||||
so setting that flag on the character's Borin state opens the offer without
|
||||
changing any reputation. The offer's own gate stays at Border Watch
|
||||
Reputation 40.
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
103
docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md
Normal file
103
docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Slice 0.8.5 — Research Findings (Preliminary to the Plan)
|
||||
|
||||
**Spec:** `docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md`
|
||||
**Branch/Worktree:** `slice/0.8.5-reputation-gated-merchant-offers` in `.claude/worktrees/slice-0.8.5-reputation-gated-offers`
|
||||
**Baseline:** API 410 tests / 49 suites green, Web 292 tests / 27 suites green.
|
||||
|
||||
## What Slice 0.8 Already Delivered
|
||||
|
||||
Most of the mechanics already exist — 0.8.5 is largely content plus
|
||||
presentation, not new construction.
|
||||
|
||||
- `apps/api/src/conditions/game-condition.types.ts` — `GameConditionType` with
|
||||
`REGION_REPUTATION`, `WORLD_RENOWN`, `FLAG_SET`, `HAS_ITEM` as
|
||||
`SUPPORTED_CONDITION_TYPES`; `ComparisonOperator` + `compare()`.
|
||||
- `apps/api/src/conditions/game-condition.service.ts` — `evaluate()` (AND logic,
|
||||
fail-closed) and `describe()` (per condition `{condition, met}`). `describe()`
|
||||
has **no caller yet** other than its own spec — it was built explicitly for
|
||||
0.8.5.
|
||||
- `shop_offers.conditions` (jsonb) exists in migration
|
||||
`1795000000000-CreateNpcSystem.ts`; the seed sets `conditions: []` everywhere.
|
||||
- `ShopService.getShopView()` already returns `unlocked` + `affordable`;
|
||||
`purchase()` throws `SHOP_OFFER_LOCKED` (403) server-side. The frontend renders
|
||||
"Locked"/"Too costly" and maps `SHOP_OFFER_LOCKED` in `merchant.store.ts`.
|
||||
|
||||
Conclusion: "Server rejects purchases when requirements are not met" (§11) is in
|
||||
practice already satisfied and covered by `shop.service.spec.ts`. What is missing
|
||||
is content, the requirement text/progress in the UI, the bag sales format, the
|
||||
referral bypass and the unlock feedback.
|
||||
|
||||
## Central Architectural Obstacle: Bags Are Not Items
|
||||
|
||||
`shop_offers.item_definition_id` is NOT NULL with an FK onto `item_definitions`.
|
||||
The spec's two flagship offers (§4), however, are `LootBagDefinition` rows
|
||||
(`apps/api/src/loot-bags/entities/loot-bag-definition.entity.ts`) — deliberately
|
||||
not items (0.7.5 §6: never equipped, no loot, no combat stats). The 0.8 seed
|
||||
predicts this verbatim:
|
||||
|
||||
> "a bag is a `LootBagDefinition` rather than an `ItemDefinition`, so selling
|
||||
> one needs an offer shape this slice has no reason to build."
|
||||
|
||||
**Migration required:** make `item_definition_id` nullable, add
|
||||
`loot_bag_definition_id` (nullable, FK → `loot_bag_definitions`, ON DELETE
|
||||
RESTRICT), a CHECK for "exactly one of the two is set", and the unique index
|
||||
`IDX_shop_offers_shop_item` needs a counterpart for bags. On buying a bag,
|
||||
`ShopService` must create a `CharacterLootBag` row instead of stacking a
|
||||
`CharacterItem` — idempotently, because
|
||||
`IDX_character_loot_bags_character_definition` is unique and 0.9 §11 requires
|
||||
"bag grant is idempotent".
|
||||
|
||||
## Decisions Taken (Confirmed by the User)
|
||||
|
||||
1. **Starter bags in the seed:** Remove only the Trophy Pouch from the demo seed
|
||||
(`vertical-slice.seed.ts:376-392`). The Hide Bag stays seeded for now, until
|
||||
0.9 hands it over through the quest referral.
|
||||
2. **Hide Bag gate:** Reputation gate **or** referral flag
|
||||
(`referred-by-south-gate-warden`, 0.9 §5). Needs a clearly named bypass field
|
||||
on the offer — expressly *not* a generic rules engine (§3, §7, §11 "No
|
||||
generalized rules engine"). Proposal: `shop_offers.bypass_conditions` (jsonb)
|
||||
with OR semantics against `conditions` — the smallest extension that carries
|
||||
the exception case.
|
||||
3. **Third offer:** Bandit Blade (already exists as an ItemDefinition,
|
||||
`ITEM_IDS['bandit-blade']`, weaponDamage 11 / +1 Attack) behind
|
||||
`WORLD_RENOWN >= 3`.
|
||||
|
||||
## Balancing Reference Points
|
||||
|
||||
- The exchange pays 2/3/5/12 reputation per trade good (`npc-content.ts`
|
||||
`EXCHANGE_RULES`).
|
||||
- Reputation ranks: 0 Stranger, 100 Tolerated, 250 Known, 500 Recognized,
|
||||
800 Trusted, 1200 Esteemed (`reputation-rank.ts`). The gates are numeric, not
|
||||
rank-based — the spec examples say "Reputation 25".
|
||||
- Renown: `Character.renown`, currently only one milestone
|
||||
(`first-goods-returned`, +1). **Open:** Renown 3 is unreachable with today's
|
||||
content — either a lower threshold, or the offer stays visibly locked until
|
||||
0.11 (which actually matches §5, "visible rewards create goals").
|
||||
- Spec example §5, verbatim: Trophy Pouch, 40 Silver, Requires Ashen Fields
|
||||
Reputation 25.
|
||||
|
||||
## Files Affected
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `apps/api/src/database/migrations/1796*-SellableLootBags.ts` | new: bag offers + bypass column |
|
||||
| `apps/api/src/shops/entities/shop-offer.entity.ts` | nullable item FK, bag FK, `bypassConditions` |
|
||||
| `apps/api/src/shops/shop.service.ts` | bag grant, OR bypass, requirement description via `describe()` |
|
||||
| `apps/api/src/shops/shop.errors.ts` | `MERCHANT_REPUTATION_TOO_LOW` (§6) instead of / alongside `SHOP_OFFER_LOCKED` |
|
||||
| `apps/api/src/database/seeds/npc-content.ts` | three locked offers |
|
||||
| `apps/api/src/database/seeds/vertical-slice.seed.ts:376` | take the Trophy Pouch out of the demo seed |
|
||||
| `apps/web/src/app/core/api/game-api.models.ts:433` | `requirements` on `ShopOfferView` |
|
||||
| `apps/web/src/app/features/npc/merchant-page.component.{html,scss}` | requirement + current value per row |
|
||||
| `apps/web/src/app/features/npc/merchant.store.ts` | unlock feedback after a trade (§9) |
|
||||
|
||||
## Still Open Before the Plan
|
||||
|
||||
- The wording and carrier of the unlock feedback (§9): the store reloads the shop
|
||||
after a trade anyway (`merchant.store.ts:209-212`), so a before/after
|
||||
comparison of the `unlocked` flags can be drawn locally — with no new server
|
||||
event. That is the smallest solution and needs no endpoint.
|
||||
- Whether `MERCHANT_REPUTATION_TOO_LOW` replaces the existing
|
||||
`SHOP_OFFER_LOCKED` or supplements it. Replacing it breaks the existing
|
||||
frontend mapping entry and one test; supplementing it (a more specific code
|
||||
when the violated condition is a reputation condition) satisfies §6 verbatim
|
||||
without a regression.
|
||||
Reference in New Issue
Block a user