Ticks Slice 0.8.5's acceptance criteria against the implemented behavior, verified against the seed and service code rather than assumed, and notes that the Bandit Blade's World Renown 3 gate is deliberately unreachable until Slice 0.11 adds the milestones to reach it. Points Slice 0.9 at the concrete bypass mechanism that now exists (BORIN_OFFER_IDS.hideBag's bypassConditions flag) instead of the placeholder reference to "the quest/referral exception". Also commits the slice's plan and research-notes documents, which were untracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85 KiB
Reputation-Gated Merchant Offers (Slice 0.8.5) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make reputation change what the player can buy, by putting three merchant offers behind reputation, renown and a quest-referral exception — visible while locked, enforced on the server.
Architecture: Slice 0.8 already shipped the condition engine (GameConditionService), the shop_offers.conditions column and the server-side SHOP_OFFER_LOCKED rejection. This slice adds four things: a shop offer that can sell a loot bag (bags are LootBagDefinition rows, not items, so the offer table needs a second nullable target column), an OR-shaped bypassConditions field so one quest referral can open one offer, server-computed requirement text so the UI can say why something is locked and how close the player is, and the seeded content that demonstrates all of it.
Tech Stack: NestJS 11, TypeORM, PostgreSQL, Angular 20 (signals, standalone components), Jest (API), Vitest (web).
Spec: docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md
Research notes: docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md
Global Constraints
- English only. All identifiers, API contracts, DB names and player-facing copy are English (AGENTS.md §33). Existing German comments in touched files stay as they are; new text is English.
- Server authority. The client never evaluates a condition. It is told the outcome and the requirement text (AGENTS.md §5, spec §6).
- Fail closed. An unknown, unbacked or malformed condition reads as not met. For a gate, the safe direction of a bug is locked (
GameConditionServicedocblock). - No generalized rules engine (spec §11, acceptance criteria).
bypassConditionsis one extra jsonb column with OR semantics againstconditions— not a rule tree, not a DSL, no operator nesting. - Migrations, never
synchronize. Schema changes go in a numbered migration underapps/api/src/database/migrations/with a matching*.migration.spec.ts(AGENTS.md §7). - Seeds are idempotent. Re-running the seed must not duplicate content (AGENTS.md §8).
- Domain errors are stable codes, not matched text:
{ statusCode, code, message }(AGENTS.md §6). - Out of scope (spec §12): haggling, daily shops, dynamic personalities, faction wars, reputation decay, negative reputation, per-offer multi-currency.
Decisions Locked In
These were decided with the user before planning. Do not revisit them mid-implementation.
MERCHANT_REPUTATION_TOO_LOWis added alongsideSHOP_OFFER_LOCKED, not instead of it. The generic code stays for non-reputation gates, so the existing frontend mapping andshop.service.spec.tskeep passing. The specific code is returned only when the unmet condition is aREGION_REPUTATIONone — which is what spec §6 asks for.- The Bandit Blade offer stays visibly locked until Slice 0.11. World Renown 3 is unreachable with today's content (one milestone,
first-goods-returned, +1). That is deliberate: spec §5 says visible rewards create goals. Do not lower the threshold or invent milestones to make it reachable. - The demo seed stops granting the Basic Trophy Pouch. It must be bought through the reputation-gated offer. The Basic Hide Bag stays seeded until Slice 0.9 grants it through the referral quest, so the hunting loop is not broken in the meantime.
- Hide Bag = reputation gate OR referral flag. Reputation 40, bypassed by the
referred-by-south-gate-wardenNPC flag from Slice 0.9 §5.
Assumptions (stated, per AGENTS.md §39)
- Requirement copy names the faction, not the region. Spec §8's example reads
Requires Ashen Fields Reputation 25;Ashen Fieldsis the region (ReputationFaction.regionKey) whileBorder Watchis the faction name the rest of the shipped UI already shows ("+5 Border Watch Reputation" in the trade summary). This plan rendersRequires Border Watch Reputation 25for consistency with what players already read on the same screen. One-line change inoffer-presentation.tsif the user prefers the region wording. - Only
REGION_REPUTATIONandWORLD_RENOWNrequirements get rendered text. They are the two spec §8 names, and the only two used by seeded content. Other condition types produce no requirement line; the offer still renders as locked, so nothing leaks and nothing opens. bypassConditionsare never described to the player. Showing "Requires: referred by the South Gate Warden" would spoil the Slice 0.9 tutorial before the quest exists. Onlyconditionsproduce requirement text.- Prices are provisional balancing data (spec §4: "Exact thresholds are balancing data"): Trophy Pouch 40 Silver (verbatim from spec §5), Hide Bag 35 Silver, Bandit Blade 60 Silver.
File Structure
| File | Responsibility |
|---|---|
apps/api/src/database/migrations/1796000000000-SellableLootBags.ts |
Create. Bag target column, nullable item column, bypass column, partial unique indexes, clears content rows so the seed can own stable ids. |
apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts |
Create. Asserts SQL shape and up/down symmetry. |
apps/api/src/shops/entities/shop-offer.entity.ts |
Modify. Nullable itemDefinitionId, new lootBagDefinitionId, new bypassConditions. |
apps/api/src/conditions/game-condition.service.ts |
Modify. ConditionOutcome gains actual so the UI can show "Current: 14". |
apps/api/src/shops/offer-presentation.ts |
Create. Pure functions: requirement text + effect summary. No DB access. |
apps/api/src/shops/offer-presentation.spec.ts |
Create. Unit tests for the above. |
apps/api/src/shops/shop.errors.ts |
Modify. Add MERCHANT_REPUTATION_TOO_LOW, SHOP_BAG_ALREADY_OWNED. |
apps/api/src/shops/shop.service.ts |
Modify. Gate with bypass, bag grant path, requirement DTOs. |
apps/api/src/shops/shops.module.ts |
Modify. Register the new entities it reads. |
apps/api/src/database/seeds/npc-content.ts |
Modify. Stable offer ids; three gated offers. |
apps/api/src/database/seeds/vertical-slice.seed.ts |
Modify. Upsert offers by id; stop granting the Trophy Pouch. |
apps/web/src/app/core/api/game-api.models.ts |
Modify. requirements + effectSummary on ShopOfferView. |
apps/web/src/app/features/npc/merchant.store.ts |
Modify. Unlock feedback, new error codes. |
apps/web/src/app/features/npc/merchant-page.component.html |
Modify. Requirement rows, unlock banner. |
apps/web/src/app/features/npc/merchant-page.component.scss |
Modify. Styles for the above. |
Task 1: Schema — sellable loot bags and a bypass column
Files:
- Create:
apps/api/src/database/migrations/1796000000000-SellableLootBags.ts - Create:
apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts - Modify:
apps/api/src/shops/entities/shop-offer.entity.ts
Interfaces:
- Consumes: nothing.
- Produces:
ShopOffer.itemDefinitionId: string | null,ShopOffer.lootBagDefinitionId: string | null,ShopOffer.lootBagDefinition: LootBagDefinition,ShopOffer.bypassConditions: GameCondition[].
Why the row wipe: shop_offers is pure content. Nothing references it — not character_items, not combat_reward_items. Slice 0.8 inserted its two offers with generated uuids; Task 5 re-seeds every offer with a stable id so the upsert has a conflict target that works for both offer shapes. Deleting the old rows in the migration is what makes those stable ids land cleanly instead of colliding with the partial unique indexes.
- Step 1: Write the failing migration test
Create apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts:
import 'reflect-metadata';
import { QueryRunner } from 'typeorm';
import { SellableLootBags1796000000000 } from './1796000000000-SellableLootBags';
async function runUp(): Promise<string[]> {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await new SellableLootBags1796000000000().up(queryRunner);
return query.mock.calls.map(([sql]) => sql as string);
}
async function runDown(): Promise<string[]> {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new SellableLootBags1796000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
describe('SellableLootBags1796000000000', () => {
it('adds the bag target and bypass columns', async () => {
const joined = (await runUp()).join('\n');
expect(joined).toContain('"loot_bag_definition_id" uuid');
expect(joined).toContain('"bypass_conditions" jsonb');
expect(joined).toContain('FK_shop_offers_loot_bag');
});
it('makes the item target nullable so a bag offer can exist', async () => {
const joined = (await runUp()).join('\n');
expect(joined).toContain('ALTER COLUMN "item_definition_id" DROP NOT NULL');
});
it('requires exactly one target per offer', async () => {
const joined = (await runUp()).join('\n');
// An offer that sells nothing, or sells both an item and a bag, is a
// content bug the database must not store.
expect(joined).toContain('CHK_shop_offers_single_target');
expect(joined).toContain('num_nonnulls');
});
it('keeps both target kinds unique per shop via partial indexes', async () => {
const joined = (await runUp()).join('\n');
// A plain unique index over a nullable column would let a shop hold
// unlimited bag offers, because Postgres treats NULLs as distinct.
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id") WHERE "item_definition_id" IS NOT NULL',
);
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_bag" ON "shop_offers" ("shop_id", "loot_bag_definition_id") WHERE "loot_bag_definition_id" IS NOT NULL',
);
});
it('clears content offers so the seed can own stable ids', async () => {
const joined = (await runUp()).join('\n');
expect(joined).toContain('DELETE FROM "shop_offers"');
});
it('drops what it added and restores the original index on rollback', async () => {
const joined = (await runDown()).join('\n');
expect(joined).toContain('DROP COLUMN "loot_bag_definition_id"');
expect(joined).toContain('DROP COLUMN "bypass_conditions"');
expect(joined).toContain('ALTER COLUMN "item_definition_id" SET NOT NULL');
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")',
);
});
});
- Step 2: Run it to make sure it fails
Run: npm run test --workspace=@ashen-realms/api -- sellable-loot-bags
Expected: FAIL — Cannot find module './1796000000000-SellableLootBags'.
- Step 3: Write the migration
Create apps/api/src/database/migrations/1796000000000-SellableLootBags.ts:
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
`);
}
}
- Step 4: Run the test to verify it passes
Run: npm run test --workspace=@ashen-realms/api -- sellable-loot-bags
Expected: PASS (6 tests).
- Step 5: Update the entity to match
In apps/api/src/shops/entities/shop-offer.entity.ts, replace the @Index decorator and the itemDefinitionId / conditions / itemDefinition members. Add the LootBagDefinition import next to the existing ItemDefinition one:
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
Replace the class-level index decorator:
@Entity({ name: 'shop_offers' })
@Index('IDX_shop_offers_shop_item', ['shopId', 'itemDefinitionId'], {
unique: true,
where: '"item_definition_id" IS NOT NULL',
})
@Index('IDX_shop_offers_shop_bag', ['shopId', 'lootBagDefinitionId'], {
unique: true,
where: '"loot_bag_definition_id" IS NOT NULL',
})
export class ShopOffer {
Replace the itemDefinitionId column with both targets:
/**
* 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;
After the existing conditions column, add:
/**
* 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[];
And make the item relation nullable, adding the bag relation beside it:
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT', nullable: true })
@JoinColumn({ name: 'item_definition_id' })
itemDefinition!: ItemDefinition | null;
@ManyToOne(() => LootBagDefinition, { onDelete: 'RESTRICT', nullable: true })
@JoinColumn({ name: 'loot_bag_definition_id' })
lootBagDefinition!: LootBagDefinition | null;
- Step 6: Verify nothing else broke
Run: npm run test --workspace=@ashen-realms/api
Expected: PASS. shop.service.ts still compiles because its fixtures always set itemDefinition; TypeScript will now flag the nullable access in shop.service.ts — if tsc complains here rather than in Task 2, leave the service alone and let Task 2 fix it properly. If the suite is red only on shop.service.spec.ts type errors, proceed to Task 2 and commit both together.
- Step 7: Commit
git add apps/api/src/database/migrations/1796000000000-SellableLootBags.ts apps/api/src/database/migrations/sellable-loot-bags.migration.spec.ts apps/api/src/shops/entities/shop-offer.entity.ts
git commit -m "feat(shops): let an offer sell a loot bag and carry bypass conditions"
Task 2: Condition outcomes report the player's current value
Files:
- Modify:
apps/api/src/conditions/game-condition.service.ts - Test:
apps/api/src/conditions/game-condition.service.spec.ts
Interfaces:
- Consumes: nothing from Task 1.
- Produces:
ConditionOutcome { condition: GameCondition; met: boolean; actual: number | null }. Task 4 turnsactualinto the "Current: 14" line spec §5 asks for.
Why: describe() already exists and already reports per-condition met. What it cannot report is how close the player is, which is half of the spec §5 display. The private evaluators each know the number they compared; they simply discard it.
- Step 1: Write the failing test
Append inside the existing top-level describe('GameConditionService', ...) block in apps/api/src/conditions/game-condition.service.spec.ts, next to the existing describe() test:
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();
});
If NPC_ID is not already a constant in that spec file, reuse whatever npc id the existing FLAG_SET tests in the file pass; read the file before writing this test.
- Step 2: Run it to make sure it fails
Run: npm run test --workspace=@ashen-realms/api -- game-condition
Expected: FAIL — actual is undefined, not 14.
- Step 3: Thread the value through the evaluators
In apps/api/src/conditions/game-condition.service.ts, add the internal shape above ConditionOutcome and extend the public one:
/**
* One condition's result, plus the number it was measured against.
*
* `actual` is null for conditions with no scale -- a flag is set or it is not,
* and "Current: 0" would be a lie about a boolean.
*/
interface ConditionEvaluation {
met: boolean;
actual: number | null;
}
export interface ConditionOutcome {
condition: GameCondition;
met: boolean;
actual: number | null;
}
Change evaluate() to read .met:
const scope: RepositoryScope = manager ?? this.dataSource;
for (const condition of conditions) {
const evaluation = await this.evaluateOne(context, condition, scope);
if (!evaluation.met) {
return false;
}
}
return true;
Change describe() to spread the evaluation:
const scope: RepositoryScope = manager ?? this.dataSource;
const outcomes: ConditionOutcome[] = [];
for (const condition of conditions) {
const evaluation = await this.evaluateOne(context, condition, scope);
outcomes.push({ condition, met: evaluation.met, actual: evaluation.actual });
}
return outcomes;
Change evaluateOne and the four evaluators to return ConditionEvaluation. The unsupported/default branches return { met: false, actual: null }:
private async evaluateOne(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<ConditionEvaluation> {
if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) {
return { met: false, actual: null };
}
switch (condition.type) {
case GameConditionType.REGION_REPUTATION:
return this.evaluateRegionReputation(context, condition, scope);
case GameConditionType.WORLD_RENOWN:
return this.evaluateWorldRenown(context, condition, scope);
case GameConditionType.FLAG_SET:
return this.evaluateFlag(context, condition, scope);
case GameConditionType.HAS_ITEM:
return this.evaluateHasItem(context, condition, scope);
default:
return { met: false, actual: null };
}
}
In evaluateRegionReputation, the two early returns become { met: false, actual: null } and the final line becomes:
const reputation = row?.reputation ?? 0;
return { met: this.compareNumeric(reputation, condition), actual: reputation };
In evaluateWorldRenown, the early return becomes { met: false, actual: null } and the final line:
return {
met: this.compareNumeric(character.renown, condition),
actual: character.renown,
};
In evaluateFlag, both the early return and the result carry actual: null:
if (!condition.key || !context.npcId) {
return { met: false, actual: null };
}
// ... unchanged lookup ...
const expected = condition.value ?? true;
return {
met: (state?.flags?.[condition.key] ?? false) === expected,
actual: null,
};
In evaluateHasItem, the two early returns become { met: false, actual: null } and the final block:
const quantity = owned?.quantity ?? 0;
return {
met: this.compareNumeric(quantity, {
...condition,
operator: condition.operator ?? ComparisonOperator.GTE,
value: condition.value ?? 1,
}),
actual: quantity,
};
- Step 4: Run the tests to verify they pass
Run: npm run test --workspace=@ashen-realms/api -- game-condition
Expected: PASS — all existing tests plus the two new ones.
- Step 5: Commit
git add apps/api/src/conditions/game-condition.service.ts apps/api/src/conditions/game-condition.service.spec.ts
git commit -m "feat(conditions): report the current value behind each condition"
Task 3: Requirement text and effect summaries
Files:
- Create:
apps/api/src/shops/offer-presentation.ts - Create:
apps/api/src/shops/offer-presentation.spec.ts
Interfaces:
- Consumes:
ConditionOutcomefrom Task 2. - Produces:
interface ShopOfferRequirementDto { label: string; current: number | null; required: number | null; met: boolean }function describeRequirement(outcome: ConditionOutcome, factionNames: ReadonlyMap<string, string>): ShopOfferRequirementDto | nullfunction describeItemEffect(item: { weaponDamage: number; bonusAttack: number; bonusHp: number; bonusArmor: number }): string | nullfunction describeBagEffect(bag: { capacity: number; lootCategory: LootCategory }): string
Why a separate file: these are pure string functions with no DB access, which makes them directly testable without the repository fixture scaffolding shop.service.spec.ts needs (AGENTS.md §11's separation principle applied at a smaller scale).
- Step 1: Write the failing test
Create apps/api/src/shops/offer-presentation.spec.ts:
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();
});
});
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');
});
});
- Step 2: Run it to make sure it fails
Run: npm run test --workspace=@ashen-realms/api -- offer-presentation
Expected: FAIL — Cannot find module './offer-presentation'.
- Step 3: Write the implementation
Create apps/api/src/shops/offer-presentation.ts:
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}`;
}
- Step 4: Run the tests to verify they pass
Run: npm run test --workspace=@ashen-realms/api -- offer-presentation
Expected: PASS (10 tests).
- Step 5: Commit
git add apps/api/src/shops/offer-presentation.ts apps/api/src/shops/offer-presentation.spec.ts
git commit -m "feat(shops): describe offer requirements and effects in English"
Task 4: The shop sells bags, honours bypasses, and explains its locks
Files:
- Modify:
apps/api/src/shops/shop.errors.ts - Modify:
apps/api/src/shops/shop.service.ts - Modify:
apps/api/src/shops/shops.module.ts - Test:
apps/api/src/shops/shop.service.spec.ts
Interfaces:
- Consumes:
ShopOffer.lootBagDefinitionId/.bypassConditions(Task 1),ConditionOutcome.actual(Task 2),describeRequirement/describeItemEffect/describeBagEffect(Task 3). - Produces:
ShopOfferDtoextended withrequirements: ShopOfferRequirementDto[]andeffectSummary: string | null; error codesMERCHANT_REPUTATION_TOO_LOW,SHOP_BAG_ALREADY_OWNED.
This is the task that satisfies spec §6 and §10 end to end. The purchase flow becomes: load character → load offer → validate quantity → validate gate (conditions OR bypass) → validate price → grant item or bag → commit, all inside the existing transaction.
- Step 1: Write the failing tests
The existing createWorld fixture in apps/api/src/shops/shop.service.spec.ts needs three additions: a LootBagDefinition/CharacterLootBag repository, a ReputationFaction repository (the service now loads faction names), and per-condition describe support. Rewrite the fixture's Fixture interface and repositories function as follows, leaving every existing test body untouched.
Add to the imports at the top of the spec:
import { ComparisonOperator, GameConditionType } from '../conditions/game-condition.types';
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 { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
Extend Fixture:
interface Fixture {
silver?: number;
shopEnabled?: boolean;
hasShop?: boolean;
offerUnlocked?: boolean;
offerRepeatable?: boolean;
offerQuantity?: number;
ownedPotions?: number | null;
/** Replaces the single item offer with one that sells the trophy pouch. */
bagOffer?: 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[];
/** Which of `conditions` / `bypassConditions` the fake engine says hold. */
bypassPasses?: boolean;
}
In createWorld, replace the offers array and add the extra repositories:
const bag = {
id: 'bag-trophy-pouch',
key: 'basic-trophy-pouch',
name: 'Basic Trophy Pouch',
lootCategory: LootCategory.RAIDER_TROPHY,
capacity: 5,
iconPath: '/images/items/basic-trophy-pouch.png',
};
const grantedBags: Array<Record<string, unknown>> = [];
const offers = [
fixture.bagOffer
? {
id: 'offer-bag',
shopId: 'shop-1',
itemDefinitionId: null,
lootBagDefinitionId: bag.id,
currencyType: 'SILVER',
price: 40,
quantity: 1,
repeatable: false,
sortOrder: 1,
conditions: fixture.conditions ?? [],
bypassConditions: fixture.bypassConditions ?? [],
enabled: true,
itemDefinition: null,
lootBagDefinition: bag,
}
: {
id: 'offer-1',
shopId: 'shop-1',
itemDefinitionId: 'item-potion',
lootBagDefinitionId: null,
currencyType: 'SILVER',
price: 12,
quantity: fixture.offerQuantity ?? 1,
repeatable: fixture.offerRepeatable ?? true,
sortOrder: 1,
conditions: fixture.conditions ?? [],
bypassConditions: fixture.bypassConditions ?? [],
enabled: true,
itemDefinition: {
id: 'item-potion',
key: 'small-healing-potion',
name: 'Small Healing Potion',
description: 'A bitter draught.',
iconPath: '/images/items/potion.png',
weaponDamage: 0,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
},
lootBagDefinition: null,
},
] as unknown as ShopOffer[];
Add these branches inside repositories, before the throw:
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: async (row: Record<string, unknown>) => {
grantedBags.push(row);
return row;
},
};
}
Replace the conditions fake so it answers per-condition-list, and add describe:
const conditions = {
evaluate: jest.fn((_context: unknown, list: GameCondition[] | undefined) => {
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);
}
return Promise.resolve(fixture.offerUnlocked ?? true);
}),
describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) =>
Promise.resolve(
(list ?? []).map((condition) => ({
condition,
met: fixture.offerUnlocked ?? true,
actual: 14,
})),
),
),
} as unknown as GameConditionService;
And return grantedBags from createWorld alongside grantedItems.
Now append these tests to the describe('ShopService', ...) block:
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,
});
});
it('grants a bag rather than stacking it as an item', async () => {
const world = createWorld({ bagOffer: true, silver: 100 });
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);
});
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' });
});
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).
const world = createWorld({ bagOffer: true, silver: 10 });
await expect(
world.service.purchase(
CHARACTER_ID,
MERCHANT_KEY,
'basic-trophy-pouch',
1,
),
).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' });
expect(world.grantedBags).toHaveLength(0);
});
- Step 2: Run the tests to make sure they fail
Run: npm run test --workspace=@ashen-realms/api -- shop.service
Expected: FAIL — requirements undefined, SHOP_BAG_ALREADY_OWNED never thrown, bag offers not resolved.
- Step 3: Add the two error codes
In apps/api/src/shops/shop.errors.ts, extend the union and append two factories:
export type ShopErrorCode =
| 'SHOP_NOT_FOUND'
| 'SHOP_DISABLED'
| 'SHOP_OFFER_NOT_FOUND'
| 'SHOP_OFFER_LOCKED'
| 'MERCHANT_REPUTATION_TOO_LOW'
| 'SHOP_BAG_ALREADY_OWNED'
| 'SHOP_INVALID_QUANTITY'
| 'SHOP_INSUFFICIENT_SILVER';
/**
* 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.',
);
}
- Step 4: Rework the service
In apps/api/src/shops/shop.service.ts, add imports:
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { GameConditionType } from '../conditions/game-condition.types';
import {
describeBagEffect,
describeItemEffect,
describeRequirement,
ShopOfferRequirementDto,
} from './offer-presentation';
and extend the error imports with merchantReputationTooLow, shopBagAlreadyOwned.
Extend the DTO:
export interface ShopOfferDto {
itemKey: string;
itemName: string;
itemDescription: string;
iconPath: string;
currencyType: string;
price: number;
quantity: number;
/** What buying it does: weapon stats, or a bag's capacity (slice §8). */
effectSummary: string | null;
/** Why it is locked, and how close the player is (slice §5). Empty when open. */
requirements: ShopOfferRequirementDto[];
unlocked: boolean;
affordable: boolean;
}
Add a private helper that flattens either target into the fields the DTO needs, so the view and the purchase path agree on what an offer is:
/**
* What an offer sells, whichever kind of thing that is.
*
* Both shapes reduce to a key, a name, an icon and an effect, which is all
* the presentation layer needs -- and keeps the branch on offer kind in one
* place instead of spread across the view and the purchase path.
*/
private resolveTarget(offer: ShopOffer): {
key: string;
name: string;
description: string;
iconPath: string;
effectSummary: string | null;
} | null {
if (offer.itemDefinition) {
return {
key: offer.itemDefinition.key,
name: offer.itemDefinition.name,
description: offer.itemDefinition.description,
iconPath: offer.itemDefinition.iconPath,
effectSummary: describeItemEffect(offer.itemDefinition),
};
}
if (offer.lootBagDefinition) {
return {
key: offer.lootBagDefinition.key,
name: offer.lootBagDefinition.name,
// A bag definition carries no flavour text of its own; the capacity
// line is the honest description of what it is.
description: describeBagEffect(offer.lootBagDefinition),
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;
}
Add the gate helper, which is where §7's exception lives:
/**
* 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 };
}
Rewrite getShopView's offer loop. Faction names are loaded once, not per offer:
const offers = await this.dataSource.getRepository(ShopOffer).find({
where: { shopId: shop.id, enabled: true },
relations: { itemDefinition: true, lootBagDefinition: true },
order: { sortOrder: 'ASC' },
});
// One read for the whole view: every reputation requirement needs a
// display name, and offers commonly gate on the same faction.
const factionNames = new Map(
(await this.dataSource.getRepository(ReputationFaction).find()).map(
(faction) => [faction.key, faction.name],
),
);
const context = { characterId, npcId };
const view: ShopOfferDto[] = [];
for (const offer of offers) {
const target = this.resolveTarget(offer);
if (!target) {
continue;
}
const gate = await this.evaluateGate(context, offer);
// Described even when open, so the UI can show a requirement the player
// has already met rather than having it vanish on unlock.
const outcomes = await this.conditions.describe(context, offer.conditions);
const requirements = outcomes
.map((outcome) => describeRequirement(outcome, factionNames))
.filter(
(requirement): requirement is ShopOfferRequirementDto =>
requirement !== null,
);
view.push({
itemKey: target.key,
itemName: target.name,
itemDescription: target.description,
iconPath: target.iconPath,
currencyType: offer.currencyType,
price: offer.price,
quantity: offer.quantity,
effectSummary: target.effectSummary,
requirements,
unlocked: gate.open,
affordable: character.silver >= offer.price,
});
}
Rewrite the matching and gate check inside purchase's transaction:
const offers = await manager.getRepository(ShopOffer).find({
where: { shopId: shop.id, enabled: true },
relations: { itemDefinition: true, lootBagDefinition: true },
});
const match = offers.find(
(candidate) => this.resolveTarget(candidate)?.key === itemKey,
);
if (!match) {
throw shopOfferNotFound();
}
const target = this.resolveTarget(match)!;
const gate = await this.evaluateGate(
{ characterId, npcId },
match,
manager,
);
if (!gate.open) {
throw gate.reputationBlocked
? merchantReputationTooLow()
: shopOfferLocked();
}
if (!match.repeatable && quantity > 1) {
throw shopInvalidQuantity();
}
// A bag is one object, not a stack: buying two grants nothing extra.
if (match.lootBagDefinitionId !== null && quantity > 1) {
throw shopInvalidQuantity();
}
const silverSpent = match.price * quantity;
if (character.silver < silverSpent) {
throw shopInsufficientSilver();
}
character.silver -= silverSpent;
await characters.save(character);
if (match.lootBagDefinitionId !== null) {
await this.grantLootBag(manager, characterId, match.lootBagDefinitionId);
} else {
await this.grantItem(
manager,
characterId,
match.itemDefinitionId!,
match.quantity * quantity,
);
}
return {
shopKey: shop.key,
itemKey,
itemName: target.name,
quantity: match.quantity * quantity,
silverSpent,
silverBalance: character.silver,
};
Note: the ownership check must run before the Silver debit. Put grantLootBag's duplicate check first by writing the method to throw:
/**
* 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 }),
);
}
Because the debit happens before the grant, move the ownership check above it. Insert this immediately after the shopInsufficientSilver check and before character.silver -= silverSpent:
// 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 (match.lootBagDefinitionId !== null) {
const owned = await manager.getRepository(CharacterLootBag).findOne({
where: {
characterId,
lootBagDefinitionId: match.lootBagDefinitionId,
},
});
if (owned) {
throw shopBagAlreadyOwned();
}
}
Also import EntityManager from typeorm at the top if it is not already imported.
- Step 5: Register the new entities
In apps/api/src/shops/shops.module.ts, extend the forFeature list:
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
TypeOrmModule.forFeature([
Character,
CharacterItem,
CharacterLootBag,
LootBagDefinition,
NpcShop,
ReputationFaction,
ShopOffer,
]),
- Step 6: Run the tests to verify they pass
Run: npm run test --workspace=@ashen-realms/api -- shop.service
Expected: PASS — the 12 original tests plus the 7 new ones.
- Step 7: Run the whole API suite
Run: npm run test --workspace=@ashen-realms/api
Expected: PASS. If npc.service.spec.ts or exchange.service.spec.ts fail on the changed ConditionOutcome, their fakes need actual: null added — a one-line fix per fixture.
- Step 8: Commit
git add apps/api/src/shops/
git commit -m "feat(shops): sell loot bags, honour bypass conditions, explain locks"
Task 5: Seed the three gated offers
Files:
- Modify:
apps/api/src/database/seeds/npc-content.ts - Modify:
apps/api/src/database/seeds/vertical-slice.seed.ts - Test:
apps/api/src/database/seeds/vertical-slice.seed.spec.ts
Interfaces:
- Consumes: the
ShopOffershape from Task 1. - Produces:
SHOP_OFFERSwith stable ids and gated entries; the demo character no longer starts with a Trophy Pouch.
Content decisions (spec §4, §5; balancing data, provisional):
| Offer | Sells | Price | Gate | Bypass |
|---|---|---|---|---|
| Small Healing Potion | item | 12 | none | — |
| Worn Shortsword | item | 30 | none | — |
| Basic Trophy Pouch | bag | 40 | Border Watch Reputation 25 | — |
| Basic Hide Bag | bag | 35 | Border Watch Reputation 40 | referred-by-south-gate-warden |
| Bandit Blade | item | 60 | World Renown 3 | — |
Reachability: the exchange pays 2–12 reputation per trade good, so 25 is roughly three or four successful hunts — visible early, earned soon. 40 is deliberately further out, which is what makes the Slice 0.9 referral feel like a favour. World Renown 3 is not reachable with current content and is meant to stay locked until Slice 0.11 (decision 2).
- Step 1: Write the failing seed tests
Append to apps/api/src/database/seeds/vertical-slice.seed.spec.ts, inside the existing top-level describe:
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);
});
Add the imports the tests need at the top of the spec file:
import { GameConditionType } from '../../conditions/game-condition.types';
import { BASIC_HIDE_BAG_ID } from './loot-bag-content';
import { SHOP_OFFERS } from './npc-content';
(Check the file's existing imports first — SHOP_OFFERS may already be imported.)
- Step 2: Run them to make sure they fail
Run: npm run test --workspace=@ashen-realms/api -- vertical-slice.seed
Expected: FAIL — SHOP_OFFERS entries have no id, no lootBagDefinitionId, no gated entries.
- Step 3: Rewrite the offer content
In apps/api/src/database/seeds/npc-content.ts, extend the imports:
import {
BASIC_HIDE_BAG_ID,
BASIC_TROPHY_POUCH_ID,
} from './loot-bag-content';
Add stable offer ids near the other id constants at the top:
// 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';
Replace the SeedShopOffer interface:
export interface SeedShopOffer {
id: string;
shopId: string;
itemDefinitionId: string | null;
lootBagDefinitionId: string | null;
currencyType: string;
price: number;
quantity: number;
repeatable: boolean;
sortOrder: number;
conditions: GameCondition[];
bypassConditions: GameCondition[];
enabled: boolean;
}
Replace the whole SHOP_OFFERS array and its docblock:
/**
* What Borin sells (Playable Slice 0.8 §12, Slice 0.8.5 §4).
*
* Two open offers so Silver always has somewhere to go, and three gated ones
* so reputation visibly changes what the player can do (0.8.5 §1). The locked
* offers stay listed rather than hidden: a reward you can see is a goal, and a
* reward you cannot see is nothing (0.8.5 §5).
*
* Thresholds are balancing data (0.8.5 §4). The exchange pays 2-12 reputation
* per trade good, so 25 is three or four good hunts away -- close enough to
* pull, far enough to matter -- and 40 is deliberately further out, which is
* what makes the Slice 0.9 referral read as a favour rather than a shortcut
* around nothing.
*
* The Bandit Blade sits behind World Renown 3, which today's content cannot
* reach: there is exactly one renown milestone, worth +1. That is intentional
* and not a balancing oversight -- it is the long-horizon goal on the shelf
* until Slice 0.11 adds the milestones that reach it.
*/
export const SHOP_OFFERS: SeedShopOffer[] = [
{
id: BORIN_OFFER_IDS.potion,
shopId: BORIN_SHOP_ID,
itemDefinitionId: ITEM_IDS['small-healing-potion'],
lootBagDefinitionId: null,
currencyType: 'SILVER',
price: 12,
quantity: 1,
repeatable: true,
sortOrder: 1,
conditions: [],
bypassConditions: [],
enabled: true,
},
{
id: BORIN_OFFER_IDS.shortsword,
shopId: BORIN_SHOP_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
lootBagDefinitionId: null,
currencyType: 'SILVER',
price: 30,
quantity: 1,
repeatable: true,
sortOrder: 2,
conditions: [],
bypassConditions: [],
enabled: true,
},
{
// The first gate a player meets, and the one 0.8.5 §5 uses as its worked
// example: 40 Silver, Ashen Fields reputation 25.
id: BORIN_OFFER_IDS.trophyPouch,
shopId: BORIN_SHOP_ID,
itemDefinitionId: null,
lootBagDefinitionId: BASIC_TROPHY_POUCH_ID,
currencyType: 'SILVER',
price: 40,
quantity: 1,
repeatable: false,
sortOrder: 3,
conditions: [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 25,
},
],
bypassConditions: [],
enabled: true,
},
{
// Reputation 40 is out of reach for a new character on purpose. Slice 0.9
// sends the player here with the warden's word instead, which is the one
// exception the offer system supports (0.8.5 §7).
id: BORIN_OFFER_IDS.hideBag,
shopId: BORIN_SHOP_ID,
itemDefinitionId: null,
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
currencyType: 'SILVER',
price: 35,
quantity: 1,
repeatable: false,
sortOrder: 4,
conditions: [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 40,
},
],
bypassConditions: [
{
type: GameConditionType.FLAG_SET,
key: SOUTH_GATE_REFERRAL_FLAG,
value: true,
},
],
enabled: true,
},
{
id: BORIN_OFFER_IDS.banditBlade,
shopId: BORIN_SHOP_ID,
itemDefinitionId: ITEM_IDS['bandit-blade'],
lootBagDefinitionId: null,
currencyType: 'SILVER',
price: 60,
quantity: 1,
repeatable: true,
sortOrder: 5,
conditions: [
{
type: GameConditionType.WORLD_RENOWN,
operator: ComparisonOperator.GTE,
value: 3,
},
],
bypassConditions: [],
enabled: true,
},
];
- Step 4: Update the seed runner
In apps/api/src/database/seeds/vertical-slice.seed.ts, change the offer upsert conflict target from the item pair to the stable id:
// 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']);
Then change the demo bag grant. Replace the loop over both bag ids with a single-bag version and rewrite its comment:
// ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag
// and no longer starts with the Trophy Pouch.
//
// The pouch is now a reputation-gated offer (0.8.5 §4) and handing it over
// for free would make the slice's own showcase pointless. The Hide Bag stays
// until Slice 0.9 grants it through the warden's referral -- removing both
// now would drop HIDE capacity to the bagless default of 1 with no way to
// raise it, and the hunting loop would be unplayable in between.
//
// Delete this block once 0.9 hands the Hide Bag over in the quest.
const characterLootBagRepository = dataSource.getRepository(CharacterLootBag);
const existingBag = await characterLootBagRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
});
if (!existingBag) {
await characterLootBagRepository.insert({
characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
active: true,
});
}
Remove the now-unused BASIC_TROPHY_POUCH_ID import from this file if nothing else uses it (LOOT_BAG_DEFINITIONS still seeds the definition itself — only the character's copy goes away).
- Step 5: Run the tests to verify they pass
Run: npm run test --workspace=@ashen-realms/api -- vertical-slice.seed
Expected: PASS.
- Step 6: Run the whole API suite and the linter
Run: npm run test --workspace=@ashen-realms/api
Run: npm run lint --workspace=@ashen-realms/api
Expected: both PASS.
- Step 7: Commit
git add apps/api/src/database/seeds/
git commit -m "feat(content): gate the trophy pouch, hide bag and bandit blade"
Task 6: The merchant screen shows requirements and progress
Files:
- Modify:
apps/web/src/app/core/api/game-api.models.ts - Modify:
apps/web/src/app/features/npc/merchant.store.ts - Modify:
apps/web/src/app/features/npc/merchant-page.component.html - Modify:
apps/web/src/app/features/npc/merchant-page.component.scss - Test:
apps/web/src/app/features/npc/merchant-page.component.spec.ts
Interfaces:
-
Consumes: the extended
ShopOfferDtofrom Task 4. -
Produces:
ShopOfferRequirementon the web side; the shop row renders name, icon, price, effect, requirement, current value and locked state (spec §8). -
Step 1: Write the failing component test
Read apps/web/src/app/features/npc/merchant-page.component.spec.ts first to match its existing harness (how it stubs GameApiService and builds a ShopView). Then append:
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).
const { fixture } = await renderMerchantWithShop({
offers: [
{
itemKey: 'basic-trophy-pouch',
itemName: 'Basic Trophy Pouch',
itemDescription: 'Capacity: 5 Raider Trophies',
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');
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);
});
If the spec file has no renderMerchantWithShop helper, write one modelled on the existing setup in that file — a function taking a partial ShopView, stubbing getNpcInteraction to return an interaction whose availableActions include OPEN_SHOP, stubbing getShop to return the view, rendering the component and clicking the shop action button.
- Step 2: Run it to make sure it fails
Run: npm run test --workspace=@ashen-realms/web -- merchant-page
Expected: FAIL — no requirement text is rendered.
- Step 3: Extend the frontend model
In apps/web/src/app/core/api/game-api.models.ts, add above ShopOfferView and extend it:
/** One gate on an offer, as the server phrased it (slice 0.8.5 §5, §8). */
export interface ShopOfferRequirement {
label: string;
current: number | null;
required: number | null;
met: boolean;
}
export interface ShopOfferView {
itemKey: string;
itemName: string;
itemDescription: string;
iconPath: string;
currencyType: string;
price: number;
quantity: number;
effectSummary: string | null;
requirements: ShopOfferRequirement[];
unlocked: boolean;
affordable: boolean;
}
- Step 4: Render it
In apps/web/src/app/features/npc/merchant-page.component.html, replace the shop-row__naming div and add a requirement block. The row becomes:
<li class="shop-row" [attr.data-shop-item]="offer.itemKey">
<img
class="shop-row__icon"
[src]="offer.iconPath"
[alt]=""
aria-hidden="true"
/>
<div class="shop-row__naming">
<span class="shop-row__name">{{ offer.itemName }}</span>
<span class="shop-row__description">{{
offer.itemDescription
}}</span>
@if (offer.effectSummary) {
<span class="shop-row__effect" data-effect>{{
offer.effectSummary
}}</span>
}
@for (requirement of offer.requirements; track requirement.label) {
<span
class="shop-row__requirement"
[class.shop-row__requirement--met]="requirement.met"
data-requirement
>
{{ requirement.label }}
@if (!requirement.met && requirement.current !== null) {
<span class="shop-row__current"
>· Current: {{ requirement.current }}</span
>
}
</span>
}
</div>
<span class="shop-row__price">{{ offer.price }} Silver</span>
<button
type="button"
class="merchant__button"
[disabled]="
!offer.unlocked ||
!offer.affordable ||
store.pending() !== null
"
(click)="store.buy(offer.itemKey)"
>
@if (!offer.unlocked) {
Locked
} @else if (!offer.affordable) {
Too costly
} @else {
Buy
}
</button>
</li>
- Step 5: Style it
Append to apps/web/src/app/features/npc/merchant-page.component.scss:
.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);
}
If --ar-gold is not defined in the token set, use --ar-accent; check apps/web/src/styles.scss (or wherever the tokens live) before choosing. --ar-gold is already used by .shop-row__price in this file, so it should exist.
- Step 6: Run the tests to verify they pass
Run: npm run test --workspace=@ashen-realms/web -- merchant-page
Expected: PASS.
- Step 7: Commit
git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/features/npc/merchant-page.component.html apps/web/src/app/features/npc/merchant-page.component.scss apps/web/src/app/features/npc/merchant-page.component.spec.ts
git commit -m "feat(web): show offer requirements, current progress and effects"
Task 7: Lightweight feedback when an offer unlocks
Files:
- Modify:
apps/web/src/app/features/npc/merchant.store.ts - Modify:
apps/web/src/app/features/npc/merchant-page.component.html - Test:
apps/web/src/app/features/npc/merchant.store.spec.ts
Interfaces:
- Consumes:
ShopView.offers[].unlockedfrom Task 4. - Produces:
MerchantStore.newlyUnlocked: Signal<string[]>(offer names),MerchantStore.dismissUnlocked(): void.
Why no server event: tradeSelected() already re-reads the shop after a trade, because the trade changed Silver, goods, capacity and possibly renown at once. The before/after unlocked flags are therefore already in hand — comparing them locally is the smallest thing that satisfies spec §9, and it adds no endpoint, no event channel and no state the server has to remember. Spec §9 explicitly asks for lightweight feedback and no modal.
- Step 1: Write the failing store test
Read apps/web/src/app/features/npc/merchant.store.spec.ts to match its existing API stubbing, then append:
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([]);
});
Write the offer() and shopView() helpers in that file to build a full ShopOfferView (including effectSummary: null and requirements: []) and a ShopView; extend the existing api stub so getShop returns successive entries of shopSequence.
- Step 2: Run it to make sure it fails
Run: npm run test --workspace=@ashen-realms/web -- merchant.store
Expected: FAIL — store.newlyUnlocked is not a function.
- Step 3: Add the comparison to the store
In apps/web/src/app/features/npc/merchant.store.ts, add the signal beside the others:
private readonly newlyUnlockedState = signal<string[]>([]);
readonly newlyUnlocked = this.newlyUnlockedState.asReadonly();
Add the two new error codes to ERROR_MESSAGES:
MERCHANT_REPUTATION_TOO_LOW:
'You have not earned enough standing for this yet.',
SHOP_BAG_ALREADY_OWNED: 'You already carry that.',
Clear the announcement in load() next to the other resets:
this.newlyUnlockedState.set([]);
Add the helper:
/**
* 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);
}
dismissUnlocked(): void {
this.newlyUnlockedState.set([]);
}
In tradeSelected(), capture the previous shop before the re-read and set the announcement after it. Replace the shop re-read block:
const previousShop = this.shopState();
const refreshedShop = previousShop
? await firstValueFrom(this.api.getShop(npcKey))
: null;
this.shopState.set(refreshedShop);
this.newlyUnlockedState.set(
this.unlockedSince(previousShop, refreshedShop),
);
Also clear it at the start of buy() alongside actionErrorState, so a purchase does not leave a stale unlock banner on screen:
this.newlyUnlockedState.set([]);
- Step 4: Render the announcement
In apps/web/src/app/features/npc/merchant-page.component.html, immediately after the merchant__purse paragraph inside the shop panel:
@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>
}
And append to the SCSS:
/* 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);
}
- Step 5: Run the tests to verify they pass
Run: npm run test --workspace=@ashen-realms/web -- merchant
Expected: PASS.
- Step 6: Run the whole web suite
Run: npm run test --workspace=@ashen-realms/web
Expected: PASS.
- Step 7: Commit
git add apps/web/src/app/features/npc/
git commit -m "feat(web): announce offers a trade just unlocked"
Task 8: Full verification and documentation
Files:
-
Modify:
docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md(tick the acceptance criteria) -
Modify:
docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md(point at the concrete bypass mechanism) -
Step 1: Run everything
npm run test --workspace=@ashen-realms/api
npm run test --workspace=@ashen-realms/web
npm run lint --workspace=@ashen-realms/api
npm run build
Expected: all PASS. Baseline before this branch was API 410 tests / 49 suites and web 292 tests / 27 suites; the totals should now be higher, with zero failures.
- Step 2: Verify the migration against a real database
If a PostgreSQL instance is configured (apps/api/.env), run:
npm run db:migrate
npm run db:seed
npm run db:seed
Expected: the migration applies cleanly, and seeding twice leaves five offers, not ten. If no database is reachable, say so plainly in the final report rather than claiming this step passed.
- Step 3: Tick the acceptance criteria
In docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md §11, change - [ ] to - [x] for each criterion, and append one line under the list recording where the Renown 3 offer stands:
> **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).
- Step 4: Point Slice 0.9 at the mechanism that now exists
In docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md §6, replace the line "The grant should use the quest/referral exception supported by Slice 0.8.5." with:
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.
- [ ] **Step 5: Commit**
```bash
git add docs/
git commit -m "docs: record 0.8.5 acceptance and the 0.9 referral mechanism"
Spec Coverage
| Spec section | Covered by |
|---|---|
| §2 No level gates | Tasks 4–5 — every new gate is reputation, renown or a flag; requiredLevel is untouched and unused. |
| §3 Offer requirement model | Task 1 (conditions reused, bypassConditions added); no new engine (§11). |
| §4 Initial locked offers | Task 5 — Hide Bag, Trophy Pouch, plus the optional Bandit Blade. |
| §5 Visible locked offers | Task 4 (requirements + current), Task 6 (rendering, disabled Buy). |
| §6 Server validation | Task 4 — gate before price, MERCHANT_REPUTATION_TOO_LOW, English message. |
| §7 Referral support | Task 1 (bypassConditions), Task 4 (OR evaluation), Task 5 (the flag on the Hide Bag). |
| §8 UI requirements | Task 6 — name, icon, price, effect, requirement, current value, locked state. |
| §9 Feedback on unlock | Task 7 — one line, no modal. |
| §10 Tests | Tasks 2–7; the eight listed cases map to named tests, see below. |
| §11 Acceptance criteria | Task 8. |
| §12 Out of scope | Nothing in this plan adds haggling, daily shops, decay or per-offer currencies. |
Spec §10's eight required tests, each with its home:
- Offer with no requirement can be purchased — existing
debits Silver and grants the item(Task 4 keeps it green). - Insufficient regional reputation blocks purchase —
names reputation as the reason when a reputation gate is what blocks(Task 4). - Sufficient regional reputation allows purchase —
grants a bag rather than stacking it as an item(Task 4; the fixture's gate is open). - Insufficient World Renown blocks purchase —
shows the requirement and the current value on a locked offercovers the display; the block itself is the same code path as case 2, anddescribeRequirementrenown coverage is in Task 3. - Quest flag can unlock configured tutorial offer —
opens an offer whose bypass holds even though its conditions do not(Task 4). - Disabled client state is not trusted by backend — existing
refuses a locked offer even when the request asks for it directly(Task 4 keeps it green). - Price is still required even when reputation condition is met —
still charges the price when a requirement is met(Task 4). - Unlock state changes after a successful trade-in raises reputation —
announces an offer that a trade just unlocked(Task 7).