Files
ashen-realms/docs/superpowers/plans/2026-08-22-slice-0.8.5-reputation-gated-merchant-offers.md
Bastian Wagner 81e44b2c15 docs: record 0.8.5 acceptance and the 0.9 referral mechanism
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>
2026-08-22 20:43:53 +02:00

2453 lines
85 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (`GameConditionService` docblock).
- **No generalized rules engine** (spec §11, acceptance criteria). `bypassConditions` is one extra jsonb column with OR semantics against `conditions` — not a rule tree, not a DSL, no operator nesting.
- **Migrations, never `synchronize`.** Schema changes go in a numbered migration under `apps/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.
1. **`MERCHANT_REPUTATION_TOO_LOW` is added alongside `SHOP_OFFER_LOCKED`, not instead of it.** The generic code stays for non-reputation gates, so the existing frontend mapping and `shop.service.spec.ts` keep passing. The specific code is returned only when the *unmet* condition is a `REGION_REPUTATION` one — which is what spec §6 asks for.
2. **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.
3. **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.
4. **Hide Bag = reputation gate OR referral flag.** Reputation 40, bypassed by the `referred-by-south-gate-warden` NPC 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 Fields` is the *region* (`ReputationFaction.regionKey`) while `Border Watch` is the faction *name* the rest of the shipped UI already shows ("+5 Border Watch Reputation" in the trade summary). This plan renders `Requires Border Watch Reputation 25` for consistency with what players already read on the same screen. One-line change in `offer-presentation.ts` if the user prefers the region wording.
- **Only `REGION_REPUTATION` and `WORLD_RENOWN` requirements 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.
- **`bypassConditions` are never described to the player.** Showing "Requires: referred by the South Gate Warden" would spoil the Slice 0.9 tutorial before the quest exists. Only `conditions` produce 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`:
```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`:
```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:
```ts
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
```
Replace the class-level index decorator:
```ts
@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:
```ts
/**
* 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:
```ts
/**
* 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:
```ts
@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**
```bash
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 turns `actual` into 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:
```ts
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:
```ts
/**
* 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`:
```ts
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:
```ts
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 }`:
```ts
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:
```ts
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:
```ts
return {
met: this.compareNumeric(character.renown, condition),
actual: character.renown,
};
```
In `evaluateFlag`, both the early return and the result carry `actual: null`:
```ts
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:
```ts
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**
```bash
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: `ConditionOutcome` from Task 2.
- Produces:
- `interface ShopOfferRequirementDto { label: string; current: number | null; required: number | null; met: boolean }`
- `function describeRequirement(outcome: ConditionOutcome, factionNames: ReadonlyMap<string, string>): ShopOfferRequirementDto | null`
- `function describeItemEffect(item: { weaponDamage: number; bonusAttack: number; bonusHp: number; bonusArmor: number }): string | null`
- `function 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`:
```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`:
```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**
```bash
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: `ShopOfferDto` extended with `requirements: ShopOfferRequirementDto[]` and `effectSummary: string | null`; error codes `MERCHANT_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:
```ts
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`:
```ts
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:
```ts
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`:
```ts
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`:
```ts
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:
```ts
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:
```ts
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';
```
```ts
/**
* 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:
```ts
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:
```ts
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*:
```ts
/**
* 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:
```ts
/**
* 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:
```ts
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:
```ts
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:
```ts
/**
* 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`:
```ts
// 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:
```ts
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';
```
```ts
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**
```bash
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 `ShopOffer` shape from Task 1.
- Produces: `SHOP_OFFERS` with 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 212 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:
```ts
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:
```ts
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:
```ts
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:
```ts
// 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:
```ts
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:
```ts
/**
* 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:
```ts
// 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:
```ts
// 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**
```bash
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 `ShopOfferDto` from Task 4.
- Produces: `ShopOfferRequirement` on 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:
```ts
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:
```ts
/** 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:
```html
<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`:
```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**
```bash
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[].unlocked` from 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:
```ts
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:
```ts
private readonly newlyUnlockedState = signal<string[]>([]);
```
```ts
readonly newlyUnlocked = this.newlyUnlockedState.asReadonly();
```
Add the two new error codes to `ERROR_MESSAGES`:
```ts
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:
```ts
this.newlyUnlockedState.set([]);
```
Add the helper:
```ts
/**
* 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:
```ts
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:
```ts
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:
```html
@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:
```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**
```bash
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**
```bash
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:
```bash
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:
```markdown
> **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:
```markdown
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 45 — 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 27; 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:
1. *Offer with no requirement can be purchased* — existing `debits Silver and grants the item` (Task 4 keeps it green).
2. *Insufficient regional reputation blocks purchase*`names reputation as the reason when a reputation gate is what blocks` (Task 4).
3. *Sufficient regional reputation allows purchase*`grants a bag rather than stacking it as an item` (Task 4; the fixture's gate is open).
4. *Insufficient World Renown blocks purchase*`shows the requirement and the current value on a locked offer` covers the display; the block itself is the same code path as case 2, and `describeRequirement` renown coverage is in Task 3.
5. *Quest flag can unlock configured tutorial offer*`opens an offer whose bypass holds even though its conditions do not` (Task 4).
6. *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).
7. *Price is still required even when reputation condition is met*`still charges the price when a requirement is met` (Task 4).
8. *Unlock state changes after a successful trade-in raises reputation*`announces an offer that a trade just unlocked` (Task 7).