Merge branch 'slice/0.8.5-reputation-gated-merchant-offers'
This commit is contained in:
@@ -280,4 +280,38 @@ describe('GameConditionService', () => {
|
|||||||
|
|
||||||
expect(outcomes.map((outcome) => outcome.met)).toEqual([true, false]);
|
expect(outcomes.map((outcome) => outcome.met)).toEqual([true, false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports the current value behind each requirement', async () => {
|
||||||
|
const service = createService({ renown: 1, reputation: 14 });
|
||||||
|
|
||||||
|
const outcomes = await service.describe({ characterId: CHARACTER_ID }, [
|
||||||
|
{
|
||||||
|
type: GameConditionType.REGION_REPUTATION,
|
||||||
|
key: 'border-guard',
|
||||||
|
operator: ComparisonOperator.GTE,
|
||||||
|
value: 25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: GameConditionType.WORLD_RENOWN,
|
||||||
|
operator: ComparisonOperator.GTE,
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// "Current: 14" against "Requires 25" is the whole point of showing a
|
||||||
|
// locked offer rather than hiding it (slice §5).
|
||||||
|
expect(outcomes[0]).toMatchObject({ met: false, actual: 14 });
|
||||||
|
expect(outcomes[1]).toMatchObject({ met: false, actual: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a null current value for a condition with no scale', async () => {
|
||||||
|
const service = createService({ renown: 1, reputation: 100 });
|
||||||
|
|
||||||
|
const outcomes = await service.describe(
|
||||||
|
{ characterId: CHARACTER_ID, npcId: NPC_ID },
|
||||||
|
[{ type: GameConditionType.FLAG_SET, key: 'met', value: true }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcomes[0].actual).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,9 +27,21 @@ export interface ConditionContext {
|
|||||||
|
|
||||||
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
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;
|
||||||
|
return {
|
||||||
|
met: this.compareNumeric(quantity, {
|
||||||
...condition,
|
...condition,
|
||||||
operator: condition.operator ?? ComparisonOperator.GTE,
|
operator: condition.operator ?? ComparisonOperator.GTE,
|
||||||
value: condition.value ?? 1,
|
value: condition.value ?? 1,
|
||||||
});
|
}),
|
||||||
|
actual: quantity,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private compareNumeric(actual: number, condition: GameCondition): boolean {
|
private compareNumeric(actual: number, condition: GameCondition): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets a shop offer sell a loot bag, and lets one offer carry an exception
|
||||||
|
* (Playable Slice 0.8.5 §4, §7).
|
||||||
|
*
|
||||||
|
* A bag is a `loot_bag_definitions` row, not an item -- deliberately so since
|
||||||
|
* Slice 0.7.5 §6, because a bag is never equipped, never rolls as loot and has
|
||||||
|
* no combat stats. Selling one therefore needs a second target column rather
|
||||||
|
* than a fake item definition, which is exactly the "offer shape" the Slice 0.8
|
||||||
|
* seed said it had no reason to build yet.
|
||||||
|
*
|
||||||
|
* `bypass_conditions` is the minimal exception support Slice 0.9 needs: an
|
||||||
|
* offer opens when `conditions` hold *or* `bypass_conditions` hold. One column,
|
||||||
|
* OR semantics, no rule engine (slice §7, §11).
|
||||||
|
*/
|
||||||
|
export class SellableLootBags1796000000000 implements MigrationInterface {
|
||||||
|
name = 'SellableLootBags1796000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Shop offers are pure content -- nothing references them, and Slice 0.8
|
||||||
|
// inserted its two rows with generated uuids. The seed re-creates every
|
||||||
|
// offer with a stable id (AGENTS.md §8), which needs the old anonymous
|
||||||
|
// rows gone or they would linger as duplicates the upsert never matches.
|
||||||
|
await queryRunner.query(`DELETE FROM "shop_offers"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "shop_offers"
|
||||||
|
ALTER COLUMN "item_definition_id" DROP NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "shop_offers"
|
||||||
|
ADD COLUMN "loot_bag_definition_id" uuid
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "shop_offers"
|
||||||
|
ADD CONSTRAINT "FK_shop_offers_loot_bag"
|
||||||
|
FOREIGN KEY ("loot_bag_definition_id")
|
||||||
|
REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "shop_offers"
|
||||||
|
ADD COLUMN "bypass_conditions" jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Exactly one target. An offer selling nothing has no meaning, and one
|
||||||
|
// selling both would make the grant path ambiguous.
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "shop_offers"
|
||||||
|
ADD CONSTRAINT "CHK_shop_offers_single_target"
|
||||||
|
CHECK (num_nonnulls("item_definition_id", "loot_bag_definition_id") = 1)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Partial, because the column each one covers is now nullable and Postgres
|
||||||
|
// treats NULLs as distinct -- a plain unique index over (shop_id,
|
||||||
|
// item_definition_id) would happily accept a hundred bag offers.
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_item"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Bag offers cannot survive a rollback: the column that identifies what
|
||||||
|
// they sell is about to disappear, and the restored NOT NULL would reject
|
||||||
|
// them anyway.
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM "shop_offers" WHERE "loot_bag_definition_id" IS NOT NULL`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_bag"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_shop_offers_shop_item"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "shop_offers"
|
||||||
|
DROP CONSTRAINT "CHK_shop_offers_single_target"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "shop_offers" DROP COLUMN "bypass_conditions"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "shop_offers"
|
||||||
|
DROP CONSTRAINT "FK_shop_offers_loot_bag"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "shop_offers" DROP COLUMN "loot_bag_definition_id"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "shop_offers"
|
||||||
|
ALTER COLUMN "item_definition_id" SET NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
|
||||||
|
import { SellableLootBags1796000000000 } from './1796000000000-SellableLootBags';
|
||||||
|
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
|
||||||
|
|
||||||
|
async function runUp(): Promise<string[]> {
|
||||||
|
const query = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const queryRunner = { query } as unknown as QueryRunner;
|
||||||
|
await new SellableLootBags1796000000000().up(queryRunner);
|
||||||
|
return query.mock.calls.map(([sql]) => sql as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDown(): Promise<string[]> {
|
||||||
|
const query = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const queryRunner = { query } as unknown as QueryRunner;
|
||||||
|
const migration = new SellableLootBags1796000000000();
|
||||||
|
await migration.up(queryRunner);
|
||||||
|
const upCount = query.mock.calls.length;
|
||||||
|
await migration.down(queryRunner);
|
||||||
|
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SellableLootBags1796000000000', () => {
|
||||||
|
it('adds the bag target and bypass columns', async () => {
|
||||||
|
const joined = (await runUp()).join('\n');
|
||||||
|
|
||||||
|
expect(joined).toContain('"loot_bag_definition_id" uuid');
|
||||||
|
expect(joined).toContain('"bypass_conditions" jsonb');
|
||||||
|
expect(joined).toContain('FK_shop_offers_loot_bag');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes the item target nullable so a bag offer can exist', async () => {
|
||||||
|
const joined = (await runUp()).join('\n');
|
||||||
|
|
||||||
|
expect(joined).toContain('ALTER COLUMN "item_definition_id" DROP NOT NULL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires exactly one target per offer', async () => {
|
||||||
|
const joined = (await runUp()).join('\n');
|
||||||
|
|
||||||
|
// An offer that sells nothing, or sells both an item and a bag, is a
|
||||||
|
// content bug the database must not store.
|
||||||
|
expect(joined).toContain('CHK_shop_offers_single_target');
|
||||||
|
expect(joined).toContain('num_nonnulls');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps both target kinds unique per shop via partial indexes', async () => {
|
||||||
|
const joined = (await runUp()).join('\n');
|
||||||
|
|
||||||
|
// A plain unique index over a nullable column would let a shop hold
|
||||||
|
// unlimited bag offers, because Postgres treats NULLs as distinct.
|
||||||
|
expect(joined).toContain(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL',
|
||||||
|
);
|
||||||
|
expect(joined).toContain(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears content offers so the seed can own stable ids', async () => {
|
||||||
|
const joined = (await runUp()).join('\n');
|
||||||
|
|
||||||
|
expect(joined).toContain('DELETE FROM "shop_offers"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops what it added and restores the original index on rollback', async () => {
|
||||||
|
const joined = (await runDown()).join('\n');
|
||||||
|
|
||||||
|
expect(joined).toContain('DROP COLUMN "loot_bag_definition_id"');
|
||||||
|
expect(joined).toContain('DROP COLUMN "bypass_conditions"');
|
||||||
|
expect(joined).toContain('ALTER COLUMN "item_definition_id" SET NOT NULL');
|
||||||
|
expect(joined).toContain(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('slice 0.8.5 entity schema', () => {
|
||||||
|
function column(target: unknown, propertyName: string) {
|
||||||
|
return getMetadataArgsStorage().columns.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.target === target && candidate.propertyName === propertyName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lets the item target sit empty when the offer sells a bag instead', () => {
|
||||||
|
expect(column(ShopOffer, 'itemDefinitionId')?.options.nullable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a bag offer its own nullable uuid target', () => {
|
||||||
|
const lootBagDefinitionId = column(ShopOffer, 'lootBagDefinitionId');
|
||||||
|
expect(lootBagDefinitionId?.options.type).toBe('uuid');
|
||||||
|
expect(lootBagDefinitionId?.options.nullable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires every offer to carry its bypass conditions, empty or not', () => {
|
||||||
|
const bypassConditions = column(ShopOffer, 'bypassConditions');
|
||||||
|
expect(bypassConditions?.options.type).toBe('jsonb');
|
||||||
|
expect(bypassConditions?.options.nullable).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares both partial unique indexes with their WHERE clauses', () => {
|
||||||
|
const indices = getMetadataArgsStorage().indices.filter(
|
||||||
|
(candidate) => candidate.target === ShopOffer,
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemIndex = indices.find(
|
||||||
|
(candidate) => candidate.name === 'IDX_shop_offers_shop_item',
|
||||||
|
);
|
||||||
|
const bagIndex = indices.find(
|
||||||
|
(candidate) => candidate.name === 'IDX_shop_offers_shop_bag',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(itemIndex?.unique).toBe(true);
|
||||||
|
expect(itemIndex?.where).toBe('"item_definition_id" IS NOT NULL');
|
||||||
|
|
||||||
|
expect(bagIndex?.unique).toBe(true);
|
||||||
|
expect(bagIndex?.where).toBe('"loot_bag_definition_id" IS NOT NULL');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
DialogueResponseContent,
|
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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,34 +358,28 @@ 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 [
|
|
||||||
BASIC_HIDE_BAG_ID,
|
|
||||||
BASIC_TROPHY_POUCH_ID,
|
|
||||||
]) {
|
|
||||||
const existingBag = await characterLootBagRepository.findOneBy({
|
const existingBag = await characterLootBagRepository.findOneBy({
|
||||||
characterId: DEMO_CHARACTER_ID,
|
characterId: DEMO_CHARACTER_ID,
|
||||||
lootBagDefinitionId,
|
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
|
||||||
});
|
});
|
||||||
if (!existingBag) {
|
if (!existingBag) {
|
||||||
await characterLootBagRepository.insert({
|
await characterLootBagRepository.insert({
|
||||||
characterId: DEMO_CHARACTER_ID,
|
characterId: DEMO_CHARACTER_ID,
|
||||||
lootBagDefinitionId,
|
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
|
||||||
active: true,
|
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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
190
apps/api/src/shops/offer-presentation.spec.ts
Normal file
190
apps/api/src/shops/offer-presentation.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import {
|
||||||
|
ComparisonOperator,
|
||||||
|
GameConditionType,
|
||||||
|
} from '../conditions/game-condition.types';
|
||||||
|
import { LootCategory } from '../items/loot-category.enum';
|
||||||
|
import {
|
||||||
|
describeBagEffect,
|
||||||
|
describeItemEffect,
|
||||||
|
describeRequirement,
|
||||||
|
} from './offer-presentation';
|
||||||
|
|
||||||
|
const FACTIONS = new Map([['border-guard', 'Border Watch']]);
|
||||||
|
|
||||||
|
describe('describeRequirement', () => {
|
||||||
|
it('names the faction and the threshold for a reputation gate', () => {
|
||||||
|
const requirement = describeRequirement(
|
||||||
|
{
|
||||||
|
condition: {
|
||||||
|
type: GameConditionType.REGION_REPUTATION,
|
||||||
|
key: 'border-guard',
|
||||||
|
operator: ComparisonOperator.GTE,
|
||||||
|
value: 25,
|
||||||
|
},
|
||||||
|
met: false,
|
||||||
|
actual: 14,
|
||||||
|
},
|
||||||
|
FACTIONS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requirement).toEqual({
|
||||||
|
label: 'Requires Border Watch Reputation 25',
|
||||||
|
current: 14,
|
||||||
|
required: 25,
|
||||||
|
met: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes a World Renown gate', () => {
|
||||||
|
const requirement = describeRequirement(
|
||||||
|
{
|
||||||
|
condition: {
|
||||||
|
type: GameConditionType.WORLD_RENOWN,
|
||||||
|
operator: ComparisonOperator.GTE,
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
met: false,
|
||||||
|
actual: 1,
|
||||||
|
},
|
||||||
|
FACTIONS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requirement).toMatchObject({
|
||||||
|
label: 'Requires World Renown 3',
|
||||||
|
current: 1,
|
||||||
|
required: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the faction key when the faction is unknown', () => {
|
||||||
|
// A gate naming a faction that is not seeded still has to render as
|
||||||
|
// something, and the key is more useful to a player than a blank.
|
||||||
|
const requirement = describeRequirement(
|
||||||
|
{
|
||||||
|
condition: {
|
||||||
|
type: GameConditionType.REGION_REPUTATION,
|
||||||
|
key: 'dusk-hunters',
|
||||||
|
operator: ComparisonOperator.GTE,
|
||||||
|
value: 10,
|
||||||
|
},
|
||||||
|
met: false,
|
||||||
|
actual: 0,
|
||||||
|
},
|
||||||
|
FACTIONS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requirement?.label).toBe('Requires dusk-hunters Reputation 10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no line for a condition type the player is not shown', () => {
|
||||||
|
// A dialogue flag is an internal gate. Naming it would spoil the quest
|
||||||
|
// that sets it, and the offer already renders as locked without it.
|
||||||
|
expect(
|
||||||
|
describeRequirement(
|
||||||
|
{
|
||||||
|
condition: {
|
||||||
|
type: GameConditionType.FLAG_SET,
|
||||||
|
key: 'referred-by-south-gate-warden',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
met: false,
|
||||||
|
actual: null,
|
||||||
|
},
|
||||||
|
FACTIONS,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no line if the condition has a non-numeric value', () => {
|
||||||
|
// A requirement with no valid threshold is unactionable -- the player
|
||||||
|
// has no goal to work toward. Better to hide the gate than to render
|
||||||
|
// a malformed line.
|
||||||
|
expect(
|
||||||
|
describeRequirement(
|
||||||
|
{
|
||||||
|
condition: {
|
||||||
|
type: GameConditionType.REGION_REPUTATION,
|
||||||
|
key: 'border-guard',
|
||||||
|
operator: ComparisonOperator.GTE,
|
||||||
|
value: NaN,
|
||||||
|
},
|
||||||
|
met: false,
|
||||||
|
actual: 14,
|
||||||
|
},
|
||||||
|
FACTIONS,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no line for a reputation gate with no faction key', () => {
|
||||||
|
// A gate that names no faction is malformed. The player cannot act on a
|
||||||
|
// requirement that never says which faction. Better to show nothing than
|
||||||
|
// broken content.
|
||||||
|
expect(
|
||||||
|
describeRequirement(
|
||||||
|
{
|
||||||
|
condition: {
|
||||||
|
type: GameConditionType.REGION_REPUTATION,
|
||||||
|
operator: ComparisonOperator.GTE,
|
||||||
|
value: 25,
|
||||||
|
} as any,
|
||||||
|
met: false,
|
||||||
|
actual: 14,
|
||||||
|
},
|
||||||
|
FACTIONS,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeItemEffect', () => {
|
||||||
|
it('summarises a weapon', () => {
|
||||||
|
expect(
|
||||||
|
describeItemEffect({
|
||||||
|
weaponDamage: 11,
|
||||||
|
bonusAttack: 1,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
}),
|
||||||
|
).toBe('11 Weapon Damage, +1 Attack');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarises armour', () => {
|
||||||
|
expect(
|
||||||
|
describeItemEffect({
|
||||||
|
weaponDamage: 0,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusHp: 5,
|
||||||
|
bonusArmor: 4,
|
||||||
|
}),
|
||||||
|
).toBe('+5 HP, +4 Armor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has nothing to say about an item with no stats', () => {
|
||||||
|
expect(
|
||||||
|
describeItemEffect({
|
||||||
|
weaponDamage: 0,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeBagEffect', () => {
|
||||||
|
it('states what the bag carries and how much', () => {
|
||||||
|
expect(
|
||||||
|
describeBagEffect({
|
||||||
|
capacity: 5,
|
||||||
|
lootCategory: LootCategory.RAIDER_TROPHY,
|
||||||
|
}),
|
||||||
|
).toBe('Capacity: 5 Raider Trophies');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the plural label of the category', () => {
|
||||||
|
expect(
|
||||||
|
describeBagEffect({ capacity: 5, lootCategory: LootCategory.HIDE }),
|
||||||
|
).toBe('Capacity: 5 Hides');
|
||||||
|
});
|
||||||
|
});
|
||||||
104
apps/api/src/shops/offer-presentation.ts
Normal file
104
apps/api/src/shops/offer-presentation.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import type { ConditionOutcome } from '../conditions/game-condition.service';
|
||||||
|
import { GameConditionType } from '../conditions/game-condition.types';
|
||||||
|
import { LootCategory } from '../items/loot-category.enum';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One requirement as the player reads it (Playable Slice 0.8.5 §5, §8).
|
||||||
|
*
|
||||||
|
* `current` is what makes a locked offer a goal rather than a wall: seeing
|
||||||
|
* "Requires 25 / Current 14" tells the player how much further to go.
|
||||||
|
*/
|
||||||
|
export interface ShopOfferRequirementDto {
|
||||||
|
label: string;
|
||||||
|
current: number | null;
|
||||||
|
required: number | null;
|
||||||
|
met: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plural, because a capacity counts things. Mirrors the web strip's labels. */
|
||||||
|
const CATEGORY_LABELS: Readonly<Record<LootCategory, string>> = {
|
||||||
|
[LootCategory.HIDE]: 'Hides',
|
||||||
|
[LootCategory.RAIDER_TROPHY]: 'Raider Trophies',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns one evaluated condition into a player-facing line, or into nothing.
|
||||||
|
*
|
||||||
|
* Only the two gate types slice §8 names are rendered. Everything else --
|
||||||
|
* dialogue flags above all -- returns null: the offer still shows as locked,
|
||||||
|
* but an internal gate is not announced, and a quest flag named on a shop row
|
||||||
|
* would spoil the Slice 0.9 tutorial before the quest exists.
|
||||||
|
*/
|
||||||
|
export function describeRequirement(
|
||||||
|
outcome: ConditionOutcome,
|
||||||
|
factionNames: ReadonlyMap<string, string>,
|
||||||
|
): ShopOfferRequirementDto | null {
|
||||||
|
const { condition, met, actual } = outcome;
|
||||||
|
const required = Number(condition.value);
|
||||||
|
if (!Number.isFinite(required)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (condition.type) {
|
||||||
|
case GameConditionType.REGION_REPUTATION: {
|
||||||
|
if (!condition.key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// The key is a poor label, but a blank is worse -- a gate on a faction
|
||||||
|
// that is not seeded yet should still read as something.
|
||||||
|
const faction = factionNames.get(condition.key) ?? condition.key;
|
||||||
|
return {
|
||||||
|
label: `Requires ${faction} Reputation ${required}`,
|
||||||
|
current: actual,
|
||||||
|
required,
|
||||||
|
met,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case GameConditionType.WORLD_RENOWN:
|
||||||
|
return {
|
||||||
|
label: `Requires World Renown ${required}`,
|
||||||
|
current: actual,
|
||||||
|
required,
|
||||||
|
met,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an item does, in one line (slice §8 "relevant effect").
|
||||||
|
*
|
||||||
|
* Stats only. The flavour text is already carried separately, and repeating it
|
||||||
|
* here would push the actual numbers off the row.
|
||||||
|
*/
|
||||||
|
export function describeItemEffect(item: {
|
||||||
|
weaponDamage: number;
|
||||||
|
bonusAttack: number;
|
||||||
|
bonusHp: number;
|
||||||
|
bonusArmor: number;
|
||||||
|
}): string | null {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (item.weaponDamage > 0) {
|
||||||
|
parts.push(`${item.weaponDamage} Weapon Damage`);
|
||||||
|
}
|
||||||
|
if (item.bonusAttack > 0) {
|
||||||
|
parts.push(`+${item.bonusAttack} Attack`);
|
||||||
|
}
|
||||||
|
if (item.bonusHp > 0) {
|
||||||
|
parts.push(`+${item.bonusHp} HP`);
|
||||||
|
}
|
||||||
|
if (item.bonusArmor > 0) {
|
||||||
|
parts.push(`+${item.bonusArmor} Armor`);
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts.join(', ') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a bag does: one category, one capacity (Slice 0.7.5 §6). */
|
||||||
|
export function describeBagEffect(bag: {
|
||||||
|
capacity: number;
|
||||||
|
lootCategory: LootCategory;
|
||||||
|
}): string {
|
||||||
|
const label = CATEGORY_LABELS[bag.lootCategory] ?? bag.lootCategory;
|
||||||
|
return `Capacity: ${bag.capacity} ${label}`;
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ export type ShopErrorCode =
|
|||||||
| 'SHOP_DISABLED'
|
| 'SHOP_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',
|
||||||
|
|||||||
@@ -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,17 +77,47 @@ 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-bag',
|
||||||
|
shopId: 'shop-1',
|
||||||
|
itemDefinitionId: null,
|
||||||
|
lootBagDefinitionId: bag.id,
|
||||||
|
currencyType: 'SILVER',
|
||||||
|
price: 40,
|
||||||
|
quantity: 1,
|
||||||
|
repeatable: fixture.bagRepeatable ?? false,
|
||||||
|
sortOrder: 1,
|
||||||
|
conditions: fixture.conditions ?? [],
|
||||||
|
bypassConditions: fixture.bypassConditions ?? [],
|
||||||
|
enabled: true,
|
||||||
|
itemDefinition: null,
|
||||||
|
lootBagDefinition: bag,
|
||||||
|
}
|
||||||
|
: {
|
||||||
id: 'offer-1',
|
id: 'offer-1',
|
||||||
shopId: 'shop-1',
|
shopId: 'shop-1',
|
||||||
itemDefinitionId: 'item-potion',
|
itemDefinitionId: 'item-potion',
|
||||||
|
lootBagDefinitionId: null,
|
||||||
currencyType: 'SILVER',
|
currencyType: 'SILVER',
|
||||||
price: 12,
|
price: 12,
|
||||||
quantity: fixture.offerQuantity ?? 1,
|
quantity: fixture.offerQuantity ?? 1,
|
||||||
repeatable: fixture.offerRepeatable ?? true,
|
repeatable: fixture.offerRepeatable ?? true,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
conditions: [],
|
conditions: fixture.conditions ?? [],
|
||||||
|
bypassConditions: fixture.bypassConditions ?? [],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
itemDefinition: {
|
itemDefinition: {
|
||||||
id: 'item-potion',
|
id: 'item-potion',
|
||||||
@@ -54,7 +125,12 @@ function createWorld(fixture: Fixture = {}) {
|
|||||||
name: 'Small Healing Potion',
|
name: 'Small Healing Potion',
|
||||||
description: 'A bitter draught.',
|
description: 'A bitter draught.',
|
||||||
iconPath: '/images/items/potion.png',
|
iconPath: '/images/items/potion.png',
|
||||||
|
weaponDamage: 0,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
},
|
},
|
||||||
|
lootBagDefinition: null,
|
||||||
},
|
},
|
||||||
] as unknown as ShopOffer[];
|
] as unknown as ShopOffer[];
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
if (target.kind === 'bag') {
|
||||||
|
await this.grantLootBag(manager, characterId, target.definitionId);
|
||||||
|
} else {
|
||||||
await this.grantItem(
|
await this.grantItem(
|
||||||
manager,
|
manager,
|
||||||
characterId,
|
characterId,
|
||||||
match.itemDefinitionId,
|
target.definitionId,
|
||||||
match.quantity * quantity,
|
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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
@if (offer.itemDescription) {
|
||||||
<span class="shop-row__description">{{
|
<span class="shop-row__description">{{
|
||||||
offer.itemDescription
|
offer.itemDescription
|
||||||
}}</span>
|
}}</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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
shopKey: 'borin-supplies',
|
itemKey,
|
||||||
shopName: "Quartermaster's Supplies",
|
itemName,
|
||||||
npcKey: 'borin-quartermaster',
|
|
||||||
silver: 100,
|
|
||||||
offers: [
|
|
||||||
{
|
|
||||||
itemKey: 'small-healing-potion',
|
|
||||||
itemName: 'Small Healing Potion',
|
|
||||||
itemDescription: 'A bitter draught.',
|
itemDescription: 'A bitter draught.',
|
||||||
iconPath: '/images/items/potion.png',
|
iconPath: '/images/items/potion.png',
|
||||||
currencyType: 'SILVER',
|
currencyType: 'SILVER',
|
||||||
price: 12,
|
price: 12,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unlocked: true,
|
effectSummary: null,
|
||||||
|
requirements: [],
|
||||||
|
unlocked,
|
||||||
affordable: true,
|
affordable: true,
|
||||||
},
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shopView(offers?: ShopOfferView[]): ShopView {
|
||||||
|
return {
|
||||||
|
shopKey: 'borin-supplies',
|
||||||
|
shopName: "Quartermaster's Supplies",
|
||||||
|
npcKey: 'borin-quartermaster',
|
||||||
|
silver: 100,
|
||||||
|
offers: offers ?? [
|
||||||
|
offer('small-healing-potion', 'Small Healing Potion', true),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -109,11 +121,23 @@ function tradeResult(): ExchangeResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createApi(overrides: Partial<Record<string, unknown>> = {}) {
|
function createApi(
|
||||||
|
overrides: Partial<Record<string, unknown>> & { shopSequence?: ShopView[] } = {},
|
||||||
|
) {
|
||||||
|
// `shopSequence` lets a test hand back a different shop view on each call to
|
||||||
|
// `getShop`, so before/after `unlocked` flags can be observed across a trade
|
||||||
|
// without needing a real server round-trip.
|
||||||
|
const { shopSequence, ...rest } = overrides;
|
||||||
|
let shopCallIndex = 0;
|
||||||
|
|
||||||
return {
|
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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
103
docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md
Normal file
103
docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Slice 0.8.5 — Research Findings (Preliminary to the Plan)
|
||||||
|
|
||||||
|
**Spec:** `docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md`
|
||||||
|
**Branch/Worktree:** `slice/0.8.5-reputation-gated-merchant-offers` in `.claude/worktrees/slice-0.8.5-reputation-gated-offers`
|
||||||
|
**Baseline:** API 410 tests / 49 suites green, Web 292 tests / 27 suites green.
|
||||||
|
|
||||||
|
## What Slice 0.8 Already Delivered
|
||||||
|
|
||||||
|
Most of the mechanics already exist — 0.8.5 is largely content plus
|
||||||
|
presentation, not new construction.
|
||||||
|
|
||||||
|
- `apps/api/src/conditions/game-condition.types.ts` — `GameConditionType` with
|
||||||
|
`REGION_REPUTATION`, `WORLD_RENOWN`, `FLAG_SET`, `HAS_ITEM` as
|
||||||
|
`SUPPORTED_CONDITION_TYPES`; `ComparisonOperator` + `compare()`.
|
||||||
|
- `apps/api/src/conditions/game-condition.service.ts` — `evaluate()` (AND logic,
|
||||||
|
fail-closed) and `describe()` (per condition `{condition, met}`). `describe()`
|
||||||
|
has **no caller yet** other than its own spec — it was built explicitly for
|
||||||
|
0.8.5.
|
||||||
|
- `shop_offers.conditions` (jsonb) exists in migration
|
||||||
|
`1795000000000-CreateNpcSystem.ts`; the seed sets `conditions: []` everywhere.
|
||||||
|
- `ShopService.getShopView()` already returns `unlocked` + `affordable`;
|
||||||
|
`purchase()` throws `SHOP_OFFER_LOCKED` (403) server-side. The frontend renders
|
||||||
|
"Locked"/"Too costly" and maps `SHOP_OFFER_LOCKED` in `merchant.store.ts`.
|
||||||
|
|
||||||
|
Conclusion: "Server rejects purchases when requirements are not met" (§11) is in
|
||||||
|
practice already satisfied and covered by `shop.service.spec.ts`. What is missing
|
||||||
|
is content, the requirement text/progress in the UI, the bag sales format, the
|
||||||
|
referral bypass and the unlock feedback.
|
||||||
|
|
||||||
|
## Central Architectural Obstacle: Bags Are Not Items
|
||||||
|
|
||||||
|
`shop_offers.item_definition_id` is NOT NULL with an FK onto `item_definitions`.
|
||||||
|
The spec's two flagship offers (§4), however, are `LootBagDefinition` rows
|
||||||
|
(`apps/api/src/loot-bags/entities/loot-bag-definition.entity.ts`) — deliberately
|
||||||
|
not items (0.7.5 §6: never equipped, no loot, no combat stats). The 0.8 seed
|
||||||
|
predicts this verbatim:
|
||||||
|
|
||||||
|
> "a bag is a `LootBagDefinition` rather than an `ItemDefinition`, so selling
|
||||||
|
> one needs an offer shape this slice has no reason to build."
|
||||||
|
|
||||||
|
**Migration required:** make `item_definition_id` nullable, add
|
||||||
|
`loot_bag_definition_id` (nullable, FK → `loot_bag_definitions`, ON DELETE
|
||||||
|
RESTRICT), a CHECK for "exactly one of the two is set", and the unique index
|
||||||
|
`IDX_shop_offers_shop_item` needs a counterpart for bags. On buying a bag,
|
||||||
|
`ShopService` must create a `CharacterLootBag` row instead of stacking a
|
||||||
|
`CharacterItem` — idempotently, because
|
||||||
|
`IDX_character_loot_bags_character_definition` is unique and 0.9 §11 requires
|
||||||
|
"bag grant is idempotent".
|
||||||
|
|
||||||
|
## Decisions Taken (Confirmed by the User)
|
||||||
|
|
||||||
|
1. **Starter bags in the seed:** Remove only the Trophy Pouch from the demo seed
|
||||||
|
(`vertical-slice.seed.ts:376-392`). The Hide Bag stays seeded for now, until
|
||||||
|
0.9 hands it over through the quest referral.
|
||||||
|
2. **Hide Bag gate:** Reputation gate **or** referral flag
|
||||||
|
(`referred-by-south-gate-warden`, 0.9 §5). Needs a clearly named bypass field
|
||||||
|
on the offer — expressly *not* a generic rules engine (§3, §7, §11 "No
|
||||||
|
generalized rules engine"). Proposal: `shop_offers.bypass_conditions` (jsonb)
|
||||||
|
with OR semantics against `conditions` — the smallest extension that carries
|
||||||
|
the exception case.
|
||||||
|
3. **Third offer:** Bandit Blade (already exists as an ItemDefinition,
|
||||||
|
`ITEM_IDS['bandit-blade']`, weaponDamage 11 / +1 Attack) behind
|
||||||
|
`WORLD_RENOWN >= 3`.
|
||||||
|
|
||||||
|
## Balancing Reference Points
|
||||||
|
|
||||||
|
- The exchange pays 2/3/5/12 reputation per trade good (`npc-content.ts`
|
||||||
|
`EXCHANGE_RULES`).
|
||||||
|
- Reputation ranks: 0 Stranger, 100 Tolerated, 250 Known, 500 Recognized,
|
||||||
|
800 Trusted, 1200 Esteemed (`reputation-rank.ts`). The gates are numeric, not
|
||||||
|
rank-based — the spec examples say "Reputation 25".
|
||||||
|
- Renown: `Character.renown`, currently only one milestone
|
||||||
|
(`first-goods-returned`, +1). **Open:** Renown 3 is unreachable with today's
|
||||||
|
content — either a lower threshold, or the offer stays visibly locked until
|
||||||
|
0.11 (which actually matches §5, "visible rewards create goals").
|
||||||
|
- Spec example §5, verbatim: Trophy Pouch, 40 Silver, Requires Ashen Fields
|
||||||
|
Reputation 25.
|
||||||
|
|
||||||
|
## Files Affected
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/database/migrations/1796*-SellableLootBags.ts` | new: bag offers + bypass column |
|
||||||
|
| `apps/api/src/shops/entities/shop-offer.entity.ts` | nullable item FK, bag FK, `bypassConditions` |
|
||||||
|
| `apps/api/src/shops/shop.service.ts` | bag grant, OR bypass, requirement description via `describe()` |
|
||||||
|
| `apps/api/src/shops/shop.errors.ts` | `MERCHANT_REPUTATION_TOO_LOW` (§6) instead of / alongside `SHOP_OFFER_LOCKED` |
|
||||||
|
| `apps/api/src/database/seeds/npc-content.ts` | three locked offers |
|
||||||
|
| `apps/api/src/database/seeds/vertical-slice.seed.ts:376` | take the Trophy Pouch out of the demo seed |
|
||||||
|
| `apps/web/src/app/core/api/game-api.models.ts:433` | `requirements` on `ShopOfferView` |
|
||||||
|
| `apps/web/src/app/features/npc/merchant-page.component.{html,scss}` | requirement + current value per row |
|
||||||
|
| `apps/web/src/app/features/npc/merchant.store.ts` | unlock feedback after a trade (§9) |
|
||||||
|
|
||||||
|
## Still Open Before the Plan
|
||||||
|
|
||||||
|
- The wording and carrier of the unlock feedback (§9): the store reloads the shop
|
||||||
|
after a trade anyway (`merchant.store.ts:209-212`), so a before/after
|
||||||
|
comparison of the `unlocked` flags can be drawn locally — with no new server
|
||||||
|
event. That is the smallest solution and needs no endpoint.
|
||||||
|
- Whether `MERCHANT_REPUTATION_TOO_LOW` replaces the existing
|
||||||
|
`SHOP_OFFER_LOCKED` or supplements it. Replacing it breaks the existing
|
||||||
|
frontend mapping entry and one test; supplementing it (a more specific code
|
||||||
|
when the violated condition is a reputation condition) satisfies §6 verbatim
|
||||||
|
without a regression.
|
||||||
Reference in New Issue
Block a user