Merge branch 'slice/0.8.5-reputation-gated-merchant-offers'

This commit is contained in:
Bastian Wagner
2026-08-22 21:27:30 +02:00
24 changed files with 4733 additions and 155 deletions

View File

@@ -280,4 +280,38 @@ describe('GameConditionService', () => {
expect(outcomes.map((outcome) => outcome.met)).toEqual([true, false]); 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();
});
}); });

View File

@@ -27,9 +27,21 @@ export interface ConditionContext {
type RepositoryScope = Pick<DataSource, 'getRepository'>; 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 { export interface ConditionOutcome {
condition: GameCondition; condition: GameCondition;
met: boolean; met: boolean;
actual: number | null;
} }
/** /**
@@ -59,7 +71,8 @@ export class GameConditionService {
const scope: RepositoryScope = manager ?? this.dataSource; const scope: RepositoryScope = manager ?? this.dataSource;
for (const condition of conditions) { 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; return false;
} }
} }
@@ -83,9 +96,11 @@ export class GameConditionService {
const scope: RepositoryScope = manager ?? this.dataSource; const scope: RepositoryScope = manager ?? this.dataSource;
const outcomes: ConditionOutcome[] = []; const outcomes: ConditionOutcome[] = [];
for (const condition of conditions) { for (const condition of conditions) {
const evaluation = await this.evaluateOne(context, condition, scope);
outcomes.push({ outcomes.push({
condition, condition,
met: await this.evaluateOne(context, condition, scope), met: evaluation.met,
actual: evaluation.actual,
}); });
} }
return outcomes; return outcomes;
@@ -95,9 +110,9 @@ export class GameConditionService {
context: ConditionContext, context: ConditionContext,
condition: GameCondition, condition: GameCondition,
scope: RepositoryScope, scope: RepositoryScope,
): Promise<boolean> { ): Promise<ConditionEvaluation> {
if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) { if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) {
return false; return { met: false, actual: null };
} }
switch (condition.type) { switch (condition.type) {
@@ -110,7 +125,7 @@ export class GameConditionService {
case GameConditionType.HAS_ITEM: case GameConditionType.HAS_ITEM:
return this.evaluateHasItem(context, condition, scope); return this.evaluateHasItem(context, condition, scope);
default: default:
return false; return { met: false, actual: null };
} }
} }
@@ -118,16 +133,16 @@ export class GameConditionService {
context: ConditionContext, context: ConditionContext,
condition: GameCondition, condition: GameCondition,
scope: RepositoryScope, scope: RepositoryScope,
): Promise<boolean> { ): Promise<ConditionEvaluation> {
if (!condition.key) { if (!condition.key) {
return false; return { met: false, actual: null };
} }
const faction = await scope const faction = await scope
.getRepository(ReputationFaction) .getRepository(ReputationFaction)
.findOneBy({ key: condition.key, enabled: true }); .findOneBy({ key: condition.key, enabled: true });
if (!faction) { if (!faction) {
return false; return { met: false, actual: null };
} }
const row = await scope.getRepository(CharacterReputation).findOneBy({ 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 // A faction the character never interacted with reads as 0, not as absent
// -- the same rule ReputationService.getCharacterReputation applies. // -- 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( private async evaluateWorldRenown(
context: ConditionContext, context: ConditionContext,
condition: GameCondition, condition: GameCondition,
scope: RepositoryScope, scope: RepositoryScope,
): Promise<boolean> { ): Promise<ConditionEvaluation> {
const character = await scope const character = await scope
.getRepository(Character) .getRepository(Character)
.findOneBy({ id: context.characterId }); .findOneBy({ id: context.characterId });
if (!character) { 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( private async evaluateFlag(
context: ConditionContext, context: ConditionContext,
condition: GameCondition, condition: GameCondition,
scope: RepositoryScope, scope: RepositoryScope,
): Promise<boolean> { ): Promise<ConditionEvaluation> {
// Flags are per-NPC player state (spec §7). Without an NPC in context // Flags are per-NPC player state (spec §7). Without an NPC in context
// there is nothing to read, so the gate stays shut. // there is nothing to read, so the gate stays shut.
if (!condition.key || !context.npcId) { if (!condition.key || !context.npcId) {
return false; return { met: false, actual: null };
} }
const state = await scope.getRepository(CharacterNpcState).findOneBy({ const state = await scope.getRepository(CharacterNpcState).findOneBy({
@@ -171,23 +193,26 @@ export class GameConditionService {
}); });
const expected = condition.value ?? true; 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( private async evaluateHasItem(
context: ConditionContext, context: ConditionContext,
condition: GameCondition, condition: GameCondition,
scope: RepositoryScope, scope: RepositoryScope,
): Promise<boolean> { ): Promise<ConditionEvaluation> {
if (!condition.key) { if (!condition.key) {
return false; return { met: false, actual: null };
} }
const definition = await scope const definition = await scope
.getRepository(ItemDefinition) .getRepository(ItemDefinition)
.findOneBy({ key: condition.key }); .findOneBy({ key: condition.key });
if (!definition) { if (!definition) {
return false; return { met: false, actual: null };
} }
const owned = await scope.getRepository(CharacterItem).findOneBy({ const owned = await scope.getRepository(CharacterItem).findOneBy({
@@ -196,11 +221,15 @@ export class GameConditionService {
}); });
// "Has item" without an explicit comparison means "at least one". // "Has item" without an explicit comparison means "at least one".
return this.compareNumeric(owned?.quantity ?? 0, { const quantity = owned?.quantity ?? 0;
...condition, return {
operator: condition.operator ?? ComparisonOperator.GTE, met: this.compareNumeric(quantity, {
value: condition.value ?? 1, ...condition,
}); operator: condition.operator ?? ComparisonOperator.GTE,
value: condition.value ?? 1,
}),
actual: quantity,
};
} }
private compareNumeric(actual: number, condition: GameCondition): boolean { private compareNumeric(actual: number, condition: GameCondition): boolean {

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import type {
DialogueResponseContent, DialogueResponseContent,
} from '../../npcs/npc.types'; } from '../../npcs/npc.types';
import { ITEM_IDS } from './item.constants'; 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'; import { BORDER_GUARD_FACTION_ID } from './reputation-content';
export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001'; 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_SHOP_KEY = 'borin-supplies';
export const BORIN_EXCHANGE_KEY = 'borin-trade-in'; 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). * The Renown 2 milestone (Playable Slice 0.6.5 §6).
* *
@@ -188,49 +203,139 @@ export const NPC_SHOPS: SeedNpcShop[] = [
]; ];
export interface SeedShopOffer { export interface SeedShopOffer {
id: string;
shopId: string; shopId: string;
itemDefinitionId: string; itemDefinitionId: string | null;
lootBagDefinitionId: string | null;
currencyType: string; currencyType: string;
price: number; price: number;
quantity: number; quantity: number;
repeatable: boolean; repeatable: boolean;
sortOrder: number; sortOrder: number;
conditions: GameCondition[]; conditions: GameCondition[];
bypassConditions: GameCondition[];
enabled: boolean; enabled: boolean;
} }
/** /**
* Deliberately thin (slice §12: "Avoid adding many new items just to populate * What Borin sells (Playable Slice 0.8 §12, Slice 0.8.5 §4).
* the shop"). The point of 0.8 is trade-in; the shop exists so Silver has
* somewhere to go the moment it is earned.
* *
* No offer carries conditions yet -- reputation-gated offers are Slice 0.8.5 * Two open offers so Silver always has somewhere to go, and three gated ones
* (slice §3). Loot bags are not sold here either: 0.8.5 §4 plans the Basic * so reputation visibly changes what the player can do (0.8.5 §1). The locked
* Hide Bag as its locked-offer example, and a bag is a `LootBagDefinition` * offers stay listed rather than hidden: a reward you can see is a goal, and a
* rather than an `ItemDefinition`, so selling one needs an offer shape this * reward you cannot see is nothing (0.8.5 §5).
* slice has no reason to build. *
* 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[] = [ export const SHOP_OFFERS: SeedShopOffer[] = [
{ {
id: BORIN_OFFER_IDS.potion,
shopId: BORIN_SHOP_ID, shopId: BORIN_SHOP_ID,
itemDefinitionId: ITEM_IDS['small-healing-potion'], itemDefinitionId: ITEM_IDS['small-healing-potion'],
lootBagDefinitionId: null,
currencyType: 'SILVER', currencyType: 'SILVER',
price: 12, price: 12,
quantity: 1, quantity: 1,
repeatable: true, repeatable: true,
sortOrder: 1, sortOrder: 1,
conditions: [], conditions: [],
bypassConditions: [],
enabled: true, enabled: true,
}, },
{ {
id: BORIN_OFFER_IDS.shortsword,
shopId: BORIN_SHOP_ID, shopId: BORIN_SHOP_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'], itemDefinitionId: ITEM_IDS['worn-short-sword'],
lootBagDefinitionId: null,
currencyType: 'SILVER', currencyType: 'SILVER',
price: 30, price: 30,
quantity: 1, quantity: 1,
repeatable: true, repeatable: true,
sortOrder: 2, sortOrder: 2,
conditions: [], 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, enabled: true,
}, },
]; ];

View File

@@ -20,6 +20,10 @@ import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
import { NpcShop } from '../../shops/entities/npc-shop.entity'; import { NpcShop } from '../../shops/entities/npc-shop.entity';
import { ShopOffer } from '../../shops/entities/shop-offer.entity'; import { ShopOffer } from '../../shops/entities/shop-offer.entity';
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants'; import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
import {
ComparisonOperator,
GameConditionType,
} from '../../conditions/game-condition.types';
import { import {
ASH_RAT_LOOT_TABLE_ID, ASH_RAT_LOOT_TABLE_ID,
CHARRED_LOOTER_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID,
@@ -27,6 +31,8 @@ import {
ROAD_BANDIT_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID,
WILD_ROAD_DOG_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants'; } 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'; import { seedVisibleVerticalSlice } from './vertical-slice.seed';
type Row = Record<string, unknown>; 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 characterLootBagRepository = new InMemoryRepository();
const dataSource = createDataSource( const dataSource = createDataSource(
new InMemoryRepository(), new InMemoryRepository(),
@@ -659,20 +665,18 @@ describe('seedVisibleVerticalSlice', () => {
await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource);
// Deliberate: 0.7.5 §7 leaves acquisition to the merchant slice, but with // Slice 0.8.5 decision: the Trophy Pouch is now a reputation-gated offer,
// no merchant yet a bagless character could never empty a full bag. See // so handing it to the demo character for free would undercut the
// the ASSUMPTION note in the seed. // showcase. The Hide Bag stays -- without it, HIDE capacity would drop to
expect(characterLootBagRepository.rows).toHaveLength(2); // 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([ expect(characterLootBagRepository.rows.map((row) => row.active)).toEqual([
true, true,
true,
]); ]);
expect( expect(
characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId), characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId),
).toEqual([ ).toEqual(['a0000000-0000-4000-8000-000000000001']);
'a0000000-0000-4000-8000-000000000001',
'a0000000-0000-4000-8000-000000000002',
]);
}); });
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => { it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
@@ -904,4 +908,149 @@ describe('seedVisibleVerticalSlice', () => {
enabled: true, 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,
},
],
});
});
}); });

View File

@@ -43,11 +43,7 @@ import {
BURNED_ROAD_LOCAL_CONTENT, BURNED_ROAD_LOCAL_CONTENT,
SOUTH_GATE_LOCAL_CONTENT, SOUTH_GATE_LOCAL_CONTENT,
} from './local-location.content'; } from './local-location.content';
import { import { BASIC_HIDE_BAG_ID, LOOT_BAG_DEFINITIONS } from './loot-bag-content';
BASIC_HIDE_BAG_ID,
BASIC_TROPHY_POUCH_ID,
LOOT_BAG_DEFINITIONS,
} from './loot-bag-content';
import { REPUTATION_FACTIONS } from './reputation-content'; import { REPUTATION_FACTIONS } from './reputation-content';
import { import {
DIALOGUE_NODES, DIALOGUE_NODES,
@@ -362,33 +358,27 @@ export async function seedVisibleVerticalSlice(
}); });
} }
// ASSUMPTION (Playable Slice 0.7.5 §7 leaves acquisition to the merchant // ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag
// and quest slices, so nothing in 0.7.5 hands out a bag). // and no longer starts with the Trophy Pouch.
// //
// The demo character starts with both starter bags anyway. Without them the // The pouch is now a reputation-gated offer (0.8.5 §4) and handing it over
// bagless default of 1 applies, and since the merchant that empties a bag // for free would make the slice's own showcase pointless. The Hide Bag stays
// only arrives in 0.8, the second kill of a hunt would leave its trade good // until Slice 0.9 grants it through the warden's referral -- removing both
// behind forever -- the hunting loop would be unplayable between these two // now would drop HIDE capacity to the bagless default of 1 with no way to
// slices. Capacity 5 lets the fill-up actually be experienced. // raise it, and the hunting loop would be unplayable in between.
// //
// Smallest reversible choice: two seed rows, no acquisition mechanism. // Delete this block once 0.9 hands the Hide Bag over in the quest.
// Delete them once the merchant sells bags.
const characterLootBagRepository = dataSource.getRepository(CharacterLootBag); const characterLootBagRepository = dataSource.getRepository(CharacterLootBag);
for (const lootBagDefinitionId of [ const existingBag = await characterLootBagRepository.findOneBy({
BASIC_HIDE_BAG_ID, characterId: DEMO_CHARACTER_ID,
BASIC_TROPHY_POUCH_ID, lootBagDefinitionId: BASIC_HIDE_BAG_ID,
]) { });
const existingBag = await characterLootBagRepository.findOneBy({ if (!existingBag) {
await characterLootBagRepository.insert({
characterId: DEMO_CHARACTER_ID, characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId, lootBagDefinitionId: BASIC_HIDE_BAG_ID,
active: true,
}); });
if (!existingBag) {
await characterLootBagRepository.insert({
characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId,
active: true,
});
}
} }
// NPC content (NPC Specification V1 §32; Playable Slice 0.8). // 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 dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']);
await npcShopRepository.upsert(NPC_SHOPS, ['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 npcExchangeProfileRepository.upsert(NPC_EXCHANGE_PROFILES, ['key']);
await exchangeRuleRepository.upsert(EXCHANGE_RULES, [ await exchangeRuleRepository.upsert(EXCHANGE_RULES, [
'profileId', 'profileId',

View File

@@ -10,6 +10,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import type { GameCondition } from '../../conditions/game-condition.types'; import type { GameCondition } from '../../conditions/game-condition.types';
import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
import { NpcShop } from './npc-shop.entity'; import { NpcShop } from './npc-shop.entity';
/** /**
@@ -23,6 +24,11 @@ import { NpcShop } from './npc-shop.entity';
@Entity({ name: 'shop_offers' }) @Entity({ name: 'shop_offers' })
@Index('IDX_shop_offers_shop_item', ['shopId', 'itemDefinitionId'], { @Index('IDX_shop_offers_shop_item', ['shopId', 'itemDefinitionId'], {
unique: true, unique: true,
where: '"item_definition_id" IS NOT NULL',
})
@Index('IDX_shop_offers_shop_bag', ['shopId', 'lootBagDefinitionId'], {
unique: true,
where: '"loot_bag_definition_id" IS NOT NULL',
}) })
export class ShopOffer { export class ShopOffer {
@PrimaryGeneratedColumn('uuid', { name: 'id' }) @PrimaryGeneratedColumn('uuid', { name: 'id' })
@@ -31,8 +37,16 @@ export class ShopOffer {
@Column({ name: 'shop_id', type: 'uuid' }) @Column({ name: 'shop_id', type: 'uuid' })
shopId!: string; shopId!: string;
@Column({ name: 'item_definition_id', type: 'uuid' }) /**
itemDefinitionId!: string; * What the offer sells. Exactly one of these is set, enforced by
* `CHK_shop_offers_single_target`: a bag is a `LootBagDefinition` rather
* than an item (Slice 0.7.5 §6), so the two cannot share a column.
*/
@Column({ name: 'item_definition_id', type: 'uuid', nullable: true })
itemDefinitionId!: string | null;
@Column({ name: 'loot_bag_definition_id', type: 'uuid', nullable: true })
lootBagDefinitionId!: string | null;
/** /**
* Which currency `price` is denominated in. Only SILVER exists today; the * Which currency `price` is denominated in. Only SILVER exists today; the
@@ -58,6 +72,21 @@ export class ShopOffer {
@Column({ name: 'conditions', type: 'jsonb', default: () => "'[]'::jsonb" }) @Column({ name: 'conditions', type: 'jsonb', default: () => "'[]'::jsonb" })
conditions!: GameCondition[]; conditions!: GameCondition[];
/**
* An alternative way in (Slice 0.8.5 §7).
*
* The offer opens when `conditions` hold **or** these do. That is the whole
* exception model: it lets one quest referral grant an acquisition that
* reputation alone would not yet allow, without a favor engine. Empty on
* every offer that has no exception.
*/
@Column({
name: 'bypass_conditions',
type: 'jsonb',
default: () => "'[]'::jsonb",
})
bypassConditions!: GameCondition[];
@Column({ name: 'enabled', type: 'boolean', default: true }) @Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean; enabled!: boolean;
@@ -71,7 +100,11 @@ export class ShopOffer {
@JoinColumn({ name: 'shop_id' }) @JoinColumn({ name: 'shop_id' })
shop!: NpcShop; shop!: NpcShop;
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' }) @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT', nullable: true })
@JoinColumn({ name: 'item_definition_id' }) @JoinColumn({ name: 'item_definition_id' })
itemDefinition!: ItemDefinition; itemDefinition!: ItemDefinition | null;
@ManyToOne(() => LootBagDefinition, { onDelete: 'RESTRICT', nullable: true })
@JoinColumn({ name: 'loot_bag_definition_id' })
lootBagDefinition!: LootBagDefinition | null;
} }

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

View 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}`;
}

View File

@@ -5,6 +5,8 @@ export type ShopErrorCode =
| 'SHOP_DISABLED' | 'SHOP_DISABLED'
| 'SHOP_OFFER_NOT_FOUND' | 'SHOP_OFFER_NOT_FOUND'
| 'SHOP_OFFER_LOCKED' | 'SHOP_OFFER_LOCKED'
| 'MERCHANT_REPUTATION_TOO_LOW'
| 'SHOP_BAG_ALREADY_OWNED'
| 'SHOP_INVALID_QUANTITY' | 'SHOP_INVALID_QUANTITY'
| 'SHOP_INSUFFICIENT_SILVER'; | '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 { export function shopInvalidQuantity(): ShopDomainError {
return new ShopDomainError( return new ShopDomainError(
'SHOP_INVALID_QUANTITY', 'SHOP_INVALID_QUANTITY',

View File

@@ -1,8 +1,17 @@
import { DataSource, EntityManager } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { GameConditionService } from '../conditions/game-condition.service'; 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 { 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 { NpcService } from '../npcs/npc.service';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { NpcShop } from './entities/npc-shop.entity'; import { NpcShop } from './entities/npc-shop.entity';
import { ShopOffer } from './entities/shop-offer.entity'; import { ShopOffer } from './entities/shop-offer.entity';
import { ShopService } from './shop.service'; import { ShopService } from './shop.service';
@@ -14,12 +23,44 @@ interface Fixture {
silver?: number; silver?: number;
shopEnabled?: boolean; shopEnabled?: boolean;
hasShop?: boolean; hasShop?: boolean;
/** The fake engine's blanket answer for every condition it is handed. */
offerUnlocked?: boolean; 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; offerRepeatable?: boolean;
offerQuantity?: number; offerQuantity?: number;
ownedPotions?: number | null; 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 = {}) { function createWorld(fixture: Fixture = {}) {
const character = { const character = {
id: CHARACTER_ID, id: CHARACTER_ID,
@@ -36,26 +77,61 @@ function createWorld(fixture: Fixture = {}) {
quantity: fixture.ownedPotions, 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 = [ const offers = [
{ fixture.bagOffer
id: 'offer-1', ? {
shopId: 'shop-1', id: 'offer-bag',
itemDefinitionId: 'item-potion', shopId: 'shop-1',
currencyType: 'SILVER', itemDefinitionId: null,
price: 12, lootBagDefinitionId: bag.id,
quantity: fixture.offerQuantity ?? 1, currencyType: 'SILVER',
repeatable: fixture.offerRepeatable ?? true, price: 40,
sortOrder: 1, quantity: 1,
conditions: [], repeatable: fixture.bagRepeatable ?? false,
enabled: true, sortOrder: 1,
itemDefinition: { conditions: fixture.conditions ?? [],
id: 'item-potion', bypassConditions: fixture.bypassConditions ?? [],
key: 'small-healing-potion', enabled: true,
name: 'Small Healing Potion', itemDefinition: null,
description: 'A bitter draught.', lootBagDefinition: bag,
iconPath: '/images/items/potion.png', }
}, : {
}, 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: fixture.conditions ?? [],
bypassConditions: fixture.bypassConditions ?? [],
enabled: true,
itemDefinition: {
id: 'item-potion',
key: 'small-healing-potion',
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[]; ] as unknown as ShopOffer[];
const repositories = (entity: unknown) => { const repositories = (entity: unknown) => {
@@ -88,9 +164,35 @@ function createWorld(fixture: Fixture = {}) {
return { return {
findOne: () => Promise.resolve(owned), findOne: () => Promise.resolve(owned),
create: (row: Record<string, unknown>) => row, create: (row: Record<string, unknown>) => row,
save: async (row: Record<string, unknown>) => { save: (row: Record<string, unknown>) => {
grantedItems.push(row); 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), run(manager),
} as unknown as DataSource; } 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 = { 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; } as unknown as GameConditionService;
const npcs = { const npcs = {
@@ -116,6 +253,7 @@ function createWorld(fixture: Fixture = {}) {
service: new ShopService(dataSource, conditions, npcs), service: new ShopService(dataSource, conditions, npcs),
character, character,
grantedItems, grantedItems,
grantedBags,
owned, owned,
}; };
} }
@@ -190,8 +328,19 @@ describe('ShopService', () => {
it('refuses a locked offer even when the request asks for it directly', async () => { 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 // 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 // protection (NPC spec §33: "gesperrtes Item kann nicht direkt über API
// gekauft werden"). // gekauft werden"). Gated on a flag rather than reputation, because a
const world = createWorld({ offerUnlocked: false, silver: 1000 }); // 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( await expect(
world.service.purchase( world.service.purchase(
@@ -203,6 +352,37 @@ describe('ShopService', () => {
).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' }); ).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' });
expect(world.character.silver).toBe(1000); 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 () => { it('refuses an item the shop does not stock', async () => {
@@ -211,6 +391,9 @@ describe('ShopService', () => {
await expect( await expect(
world.service.purchase(CHARACTER_ID, MERCHANT_KEY, 'ash-blade', 1), world.service.purchase(CHARACTER_ID, MERCHANT_KEY, 'ash-blade', 1),
).rejects.toMatchObject({ code: 'SHOP_OFFER_NOT_FOUND' }); ).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 () => { it('refuses a non-positive quantity', async () => {
@@ -224,6 +407,9 @@ describe('ShopService', () => {
0, 0,
), ),
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' }); ).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 () => { it('refuses to buy a one-off offer more than once in a request', async () => {
@@ -237,6 +423,9 @@ describe('ShopService', () => {
2, 2,
), ),
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' }); ).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
expect(world.character.silver).toBe(100);
expect(world.grantedItems).toHaveLength(0);
}); });
it('refuses a closed shop', async () => { it('refuses a closed shop', async () => {
@@ -261,4 +450,282 @@ describe('ShopService', () => {
expect(result.quantity).toBe(6); expect(result.quantity).toBe(6);
expect(result.silverSpent).toBe(24); 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);
});
}); });

View File

@@ -1,13 +1,24 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { GameConditionService } from '../conditions/game-condition.service'; import { GameConditionService } from '../conditions/game-condition.service';
import { GameConditionType } from '../conditions/game-condition.types';
import { CharacterItem } from '../items/entities/character-item.entity'; 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 { NpcService } from '../npcs/npc.service';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { NpcShop } from './entities/npc-shop.entity'; import { NpcShop } from './entities/npc-shop.entity';
import { ShopOffer } from './entities/shop-offer.entity'; import { ShopOffer } from './entities/shop-offer.entity';
import {
describeBagEffect,
describeItemEffect,
describeRequirement,
ShopOfferRequirementDto,
} from './offer-presentation';
import { import {
characterNotFound, characterNotFound,
merchantReputationTooLow,
shopBagAlreadyOwned,
shopDisabled, shopDisabled,
shopInsufficientSilver, shopInsufficientSilver,
shopInvalidQuantity, shopInvalidQuantity,
@@ -26,12 +37,34 @@ export interface ShopOfferDto {
currencyType: string; currencyType: string;
price: number; price: number;
quantity: 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). */ /** False when the offer's conditions are not met (Slice 0.8.5 content). */
unlocked: boolean; unlocked: boolean;
/** True when the character simply cannot afford an otherwise open offer. */ /** True when the character simply cannot afford an otherwise open offer. */
affordable: boolean; 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 { export interface ShopViewDto {
shopKey: string; shopKey: string;
shopName: string; shopName: string;
@@ -79,26 +112,58 @@ export class ShopService {
const offers = await this.dataSource.getRepository(ShopOffer).find({ const offers = await this.dataSource.getRepository(ShopOffer).find({
where: { shopId: shop.id, enabled: true }, where: { shopId: shop.id, enabled: true },
relations: { itemDefinition: true }, relations: { itemDefinition: true, lootBagDefinition: true },
order: { sortOrder: 'ASC' }, 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[] = []; const view: ShopOfferDto[] = [];
for (const offer of offers) { for (const offer of offers) {
const unlocked = await this.conditions.evaluate( const target = this.resolveTarget(offer);
{ characterId, npcId }, 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, offer.conditions,
); );
const requirements = outcomes
.map((outcome) => describeRequirement(outcome, factionNames))
.filter(
(requirement): requirement is ShopOfferRequirementDto =>
requirement !== null,
);
view.push({ view.push({
itemKey: offer.itemDefinition.key, itemKey: target.key,
itemName: offer.itemDefinition.name, itemName: target.name,
itemDescription: offer.itemDefinition.description, itemDescription: target.description,
iconPath: offer.itemDefinition.iconPath, iconPath: target.iconPath,
currencyType: offer.currencyType, currencyType: offer.currencyType,
price: offer.price, price: offer.price,
quantity: offer.quantity, quantity: offer.quantity,
unlocked, effectSummary: target.effectSummary,
requirements,
unlocked: gate.open,
affordable: character.silver >= offer.price, affordable: character.silver >= offer.price,
}); });
} }
@@ -141,51 +206,83 @@ export class ShopService {
} }
// Matched on the joined definition's business key: the offer table is // 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({ const offers = await manager.getRepository(ShopOffer).find({
where: { shopId: shop.id, enabled: true }, 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( let match: ShopOffer | undefined;
(candidate) => candidate.itemDefinition.key === itemKey, 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(); throw shopOfferNotFound();
} }
const unlocked = await this.conditions.evaluate( const gate = await this.evaluateGate(
{ characterId, npcId }, { characterId, npcId },
match.conditions, match,
manager, manager,
); );
if (!unlocked) { if (!gate.open) {
throw shopOfferLocked(); throw gate.reputationBlocked
? merchantReputationTooLow()
: shopOfferLocked();
} }
if (!match.repeatable && quantity > 1) { if (!match.repeatable && quantity > 1) {
throw shopInvalidQuantity(); 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; const silverSpent = match.price * quantity;
if (character.silver < silverSpent) { if (character.silver < silverSpent) {
throw shopInsufficientSilver(); 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; character.silver -= silverSpent;
await characters.save(character); await characters.save(character);
await this.grantItem( if (target.kind === 'bag') {
manager, await this.grantLootBag(manager, characterId, target.definitionId);
characterId, } else {
match.itemDefinitionId, await this.grantItem(
match.quantity * quantity, manager,
); characterId,
target.definitionId,
match.quantity * quantity,
);
}
return { return {
shopKey: shop.key, shopKey: shop.key,
itemKey, itemKey,
itemName: match.itemDefinition.name, itemName: target.name,
quantity: match.quantity * quantity, quantity: match.quantity * quantity,
silverSpent, silverSpent,
silverBalance: character.silver, 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. * Adds to an existing stack or starts a new one.
* *

View File

@@ -3,7 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { ConditionsModule } from '../conditions/conditions.module'; import { ConditionsModule } from '../conditions/conditions.module';
import { CharacterItem } from '../items/entities/character-item.entity'; 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 { NpcsModule } from '../npcs/npcs.module';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { NpcShop } from './entities/npc-shop.entity'; import { NpcShop } from './entities/npc-shop.entity';
import { ShopOffer } from './entities/shop-offer.entity'; import { ShopOffer } from './entities/shop-offer.entity';
import { ShopController } from './shop.controller'; import { ShopController } from './shop.controller';
@@ -12,7 +15,15 @@ import { ShopService } from './shop.service';
/** Buying things for Silver (NPC spec §15, §29). */ /** Buying things for Silver (NPC spec §15, §29). */
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Character, CharacterItem, NpcShop, ShopOffer]), TypeOrmModule.forFeature([
Character,
CharacterItem,
CharacterLootBag,
LootBagDefinition,
NpcShop,
ReputationFaction,
ShopOffer,
]),
ConditionsModule, ConditionsModule,
NpcsModule, NpcsModule,
], ],

View File

@@ -430,6 +430,14 @@ export interface ExchangeResult {
capacities: LootCapacity[]; 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 { export interface ShopOfferView {
itemKey: string; itemKey: string;
itemName: string; itemName: string;
@@ -438,6 +446,8 @@ export interface ShopOfferView {
currencyType: string; currencyType: string;
price: number; price: number;
quantity: number; quantity: number;
effectSummary: string | null;
requirements: ShopOfferRequirement[];
unlocked: boolean; unlocked: boolean;
affordable: boolean; affordable: boolean;
} }

View File

@@ -208,6 +208,21 @@
<h2 class="merchant__panel-title">{{ shop.shopName }}</h2> <h2 class="merchant__panel-title">{{ shop.shopName }}</h2>
<p class="merchant__purse" data-purse>{{ shop.silver }} Silver</p> <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"> <ul class="shop-list">
@for (offer of shop.offers; track offer.itemKey) { @for (offer of shop.offers; track offer.itemKey) {
<li class="shop-row" [attr.data-shop-item]="offer.itemKey"> <li class="shop-row" [attr.data-shop-item]="offer.itemKey">
@@ -219,9 +234,33 @@
/> />
<div class="shop-row__naming"> <div class="shop-row__naming">
<span class="shop-row__name">{{ offer.itemName }}</span> <span class="shop-row__name">{{ offer.itemName }}</span>
<span class="shop-row__description">{{ @if (offer.itemDescription) {
offer.itemDescription <span class="shop-row__description">{{
}}</span> 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> </div>
<span class="shop-row__price">{{ offer.price }} Silver</span> <span class="shop-row__price">{{ offer.price }} Silver</span>
<button <button

View File

@@ -351,6 +351,26 @@
font-size: var(--ar-font-sm); 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 { .shop-receipt {
display: flex; display: flex;
gap: var(--ar-space-2); gap: var(--ar-space-2);
@@ -360,6 +380,17 @@
font-size: var(--ar-font-sm); 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) { @media (max-width: 40rem) {
.merchant__identity { .merchant__identity {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -83,6 +83,8 @@ const SHOP: ShopView = {
currencyType: 'SILVER', currencyType: 'SILVER',
price: 12, price: 12,
quantity: 1, quantity: 1,
effectSummary: null,
requirements: [],
unlocked: true, unlocked: true,
affordable: true, affordable: true,
}, },
@@ -94,6 +96,8 @@ const SHOP: ShopView = {
currencyType: 'SILVER', currencyType: 'SILVER',
price: 400, price: 400,
quantity: 1, quantity: 1,
effectSummary: null,
requirements: [],
unlocked: false, unlocked: false,
affordable: false, affordable: false,
}, },
@@ -160,6 +164,79 @@ async function render(): Promise<{
return { fixture, element: fixture.nativeElement as HTMLElement, store }; 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', () => { describe('MerchantPageComponent', () => {
afterEach(() => TestBed.resetTestingModule()); afterEach(() => TestBed.resetTestingModule());
@@ -308,4 +385,147 @@ describe('MerchantPageComponent', () => {
expect(element.querySelector('[data-purse]')?.textContent).toContain('100'); 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');
});
}); });

View File

@@ -6,6 +6,7 @@ import type {
ExchangeResult, ExchangeResult,
ExchangeView, ExchangeView,
NpcInteraction, NpcInteraction,
ShopOfferView,
ShopView, ShopView,
} from '../../core/api/game-api.models'; } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service'; 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 {
itemKey,
itemName,
itemDescription: 'A bitter draught.',
iconPath: '/images/items/potion.png',
currencyType: 'SILVER',
price: 12,
quantity: 1,
effectSummary: null,
requirements: [],
unlocked,
affordable: true,
};
}
function shopView(offers?: ShopOfferView[]): ShopView {
return { return {
shopKey: 'borin-supplies', shopKey: 'borin-supplies',
shopName: "Quartermaster's Supplies", shopName: "Quartermaster's Supplies",
npcKey: 'borin-quartermaster', npcKey: 'borin-quartermaster',
silver: 100, silver: 100,
offers: [ offers: offers ?? [
{ offer('small-healing-potion', 'Small Healing Potion', true),
itemKey: 'small-healing-potion',
itemName: 'Small Healing Potion',
itemDescription: 'A bitter draught.',
iconPath: '/images/items/potion.png',
currencyType: 'SILVER',
price: 12,
quantity: 1,
unlocked: true,
affordable: 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 { return {
getNpcInteraction: vi.fn(() => of(interaction())), getNpcInteraction: vi.fn(() => of(interaction())),
getTradeIn: vi.fn(() => of(exchangeView())), 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())), tradeIn: vi.fn(() => of(tradeResult())),
getCharacter: vi.fn(() => getCharacter: vi.fn(() =>
of({ of({
@@ -135,7 +159,7 @@ function createApi(overrides: Partial<Record<string, unknown>> = {}) {
silverBalance: 88, silverBalance: 88,
}), }),
), ),
...overrides, ...rest,
}; };
} }
@@ -359,4 +383,75 @@ describe('MerchantStore', () => {
store.showPanel('EXCHANGE'); store.showPanel('EXCHANGE');
expect(store.panel()).toBe('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([]);
});
}); });

View File

@@ -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_OFFER_LOCKED: 'You have not earned the right to buy this yet.',
SHOP_INSUFFICIENT_SILVER: 'You cannot afford that.', SHOP_INSUFFICIENT_SILVER: 'You cannot afford that.',
SHOP_INVALID_QUANTITY: 'That quantity cannot be bought.', 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.', 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 lastTradeState = signal<ExchangeResult | null>(null);
private readonly lastPurchaseState = signal<ShopPurchaseResult | null>(null); private readonly lastPurchaseState = signal<ShopPurchaseResult | null>(null);
private readonly selectionState = signal<Record<string, number>>({}); private readonly selectionState = signal<Record<string, number>>({});
private readonly newlyUnlockedState = signal<string[]>([]);
readonly interaction = this.interactionState.asReadonly(); readonly interaction = this.interactionState.asReadonly();
readonly exchange = this.exchangeState.asReadonly(); readonly exchange = this.exchangeState.asReadonly();
@@ -71,6 +74,7 @@ export class MerchantStore {
readonly lastTrade = this.lastTradeState.asReadonly(); readonly lastTrade = this.lastTradeState.asReadonly();
readonly lastPurchase = this.lastPurchaseState.asReadonly(); readonly lastPurchase = this.lastPurchaseState.asReadonly();
readonly selection = this.selectionState.asReadonly(); readonly selection = this.selectionState.asReadonly();
readonly newlyUnlocked = this.newlyUnlockedState.asReadonly();
/** True once anything is selected, so the trade button can enable. */ /** True once anything is selected, so the trade button can enable. */
readonly hasSelection = computed(() => readonly hasSelection = computed(() =>
@@ -110,6 +114,7 @@ export class MerchantStore {
this.lastTradeState.set(null); this.lastTradeState.set(null);
this.lastPurchaseState.set(null); this.lastPurchaseState.set(null);
this.selectionState.set({}); this.selectionState.set({});
this.newlyUnlockedState.set([]);
this.panelState.set('DIALOGUE'); this.panelState.set('DIALOGUE');
try { try {
@@ -207,8 +212,13 @@ export class MerchantStore {
// capacity, Silver and possibly renown at once, and the server is the // capacity, Silver and possibly renown at once, and the server is the
// only place that knows all of it. // only place that knows all of it.
this.exchangeState.set(await firstValueFrom(this.api.getTradeIn(npcKey))); this.exchangeState.set(await firstValueFrom(this.api.getTradeIn(npcKey)));
this.shopState.set( const previousShop = this.shopState();
this.shopState() ? await firstValueFrom(this.api.getShop(npcKey)) : null, 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 // 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.pendingState.set(itemKey);
this.actionErrorState.set(null); 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 { try {
this.lastPurchaseState.set( this.lastPurchaseState.set(
@@ -252,6 +265,37 @@ export class MerchantStore {
this.lastPurchaseState.set(null); 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 { private toMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) { if (error instanceof HttpErrorResponse) {
const code = (error.error as { code?: string } | null)?.code; const code = (error.error as { code?: string } | null)?.code;

View File

@@ -223,14 +223,20 @@ No large modal is required.
## 11. Acceptance Criteria ## 11. Acceptance Criteria
- [ ] Merchant offers can define reputation/unlock requirements. - [x] Merchant offers can define reputation/unlock requirements.
- [ ] At least two offers visibly demonstrate locked states. - [x] At least two offers visibly demonstrate locked states.
- [ ] Requirements are shown to the player in English. - [x] Requirements are shown to the player in English.
- [ ] Server rejects purchases when requirements are not met. - [x] Server rejects purchases when requirements are not met.
- [ ] New progression offers do not depend on level gates. - [x] New progression offers do not depend on level gates.
- [ ] Quest/referral unlock support exists for Slice 0.9. - [x] Quest/referral unlock support exists for Slice 0.9.
- [ ] Reputation increase can visibly unlock a previously locked offer. - [x] Reputation increase can visibly unlock a previously locked offer.
- [ ] No generalized rules engine was added unnecessarily. - [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).
--- ---

View File

@@ -133,7 +133,16 @@ Category: HIDE
Capacity: 5 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

View 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.