docs: translate the 0.8.5 research notes to English

AGENTS.md §33 and this branch's own constraint are English-only, and the notes
were the one German document left. Content and structure are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 21:07:27 +02:00
parent 1a7018d790
commit 481c6be5a8

View File

@@ -1,100 +1,103 @@
# Slice 0.8.5 — Rechercheergebnisse (Vorstufe zum Plan)
# Slice 0.8.5 — Research Findings (Preliminary to the Plan)
**Spec:** `docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md`
**Branch/Worktree:** `slice/0.8.5-reputation-gated-merchant-offers` in `.claude/worktrees/slice-0.8.5-reputation-gated-offers`
**Baseline:** API 410 Tests / 49 Suites grün, Web 292 Tests / 27 Suites grün.
**Baseline:** API 410 tests / 49 suites green, Web 292 tests / 27 suites green.
## Was Slice 0.8 bereits geliefert hat
## What Slice 0.8 Already Delivered
Der Großteil der Mechanik existiert schon — 0.8.5 ist überwiegend Content plus
Präsentation, nicht Neubau.
Most of the mechanics already exist — 0.8.5 is largely content plus
presentation, not new construction.
- `apps/api/src/conditions/game-condition.types.ts``GameConditionType` mit
`REGION_REPUTATION`, `WORLD_RENOWN`, `FLAG_SET`, `HAS_ITEM` als
- `apps/api/src/conditions/game-condition.types.ts``GameConditionType` with
`REGION_REPUTATION`, `WORLD_RENOWN`, `FLAG_SET`, `HAS_ITEM` as
`SUPPORTED_CONDITION_TYPES`; `ComparisonOperator` + `compare()`.
- `apps/api/src/conditions/game-condition.service.ts``evaluate()` (UND-Logik,
fail-closed) und `describe()` (pro Bedingung `{condition, met}`). `describe()`
hat **noch keinen Aufrufer** außer seinem eigenen Spec — es wurde explizit für
0.8.5 gebaut.
- `shop_offers.conditions` (jsonb) existiert in Migration
`1795000000000-CreateNpcSystem.ts`; Seed setzt überall `conditions: []`.
- `ShopService.getShopView()` liefert bereits `unlocked` + `affordable`;
`purchase()` wirft `SHOP_OFFER_LOCKED` (403) serverseitig. Frontend rendert
Locked“/„Too costly“ und mappt `SHOP_OFFER_LOCKED` in `merchant.store.ts`.
- `apps/api/src/conditions/game-condition.service.ts``evaluate()` (AND logic,
fail-closed) and `describe()` (per condition `{condition, met}`). `describe()`
has **no caller yet** other than its own spec — it was built explicitly for
0.8.5.
- `shop_offers.conditions` (jsonb) exists in migration
`1795000000000-CreateNpcSystem.ts`; the seed sets `conditions: []` everywhere.
- `ShopService.getShopView()` already returns `unlocked` + `affordable`;
`purchase()` throws `SHOP_OFFER_LOCKED` (403) server-side. The frontend renders
"Locked"/"Too costly" and maps `SHOP_OFFER_LOCKED` in `merchant.store.ts`.
Fazit: Server rejects purchases when requirements are not met (§11) ist
faktisch schon erfüllt und durch `shop.service.spec.ts` getestet. Fehlend sind
Content, Anforderungs-Text/Fortschritt in der UI, das Bag-Verkaufsformat, der
Referral-Bypass und das Unlock-Feedback.
Conclusion: "Server rejects purchases when requirements are not met" (§11) is in
practice already satisfied and covered by `shop.service.spec.ts`. What is missing
is content, the requirement text/progress in the UI, the bag sales format, the
referral bypass and the unlock feedback.
## Zentrale Architektur-Hürde: Taschen sind keine Items
## Central Architectural Obstacle: Bags Are Not Items
`shop_offers.item_definition_id` ist NOT NULL mit FK auf `item_definitions`.
Die beiden Vorzeige-Angebote der Spec (§4) sind aber `LootBagDefinition`-Zeilen
(`apps/api/src/loot-bags/entities/loot-bag-definition.entity.ts`) — bewusst
keine Items (0.7.5 §6: nie ausgerüstet, kein Loot, keine Kampfwerte). Der
0.8-Seed sagt das wörtlich voraus:
`shop_offers.item_definition_id` is NOT NULL with an FK onto `item_definitions`.
The spec's two flagship offers (§4), however, are `LootBagDefinition` rows
(`apps/api/src/loot-bags/entities/loot-bag-definition.entity.ts`) — deliberately
not items (0.7.5 §6: never equipped, no loot, no combat stats). The 0.8 seed
predicts this verbatim:
> a bag is a `LootBagDefinition` rather than an `ItemDefinition`, so selling
> one needs an offer shape this slice has no reason to build.
> "a bag is a `LootBagDefinition` rather than an `ItemDefinition`, so selling
> one needs an offer shape this slice has no reason to build."
**Nötige Migration:** `item_definition_id` nullable machen, `loot_bag_definition_id`
(nullable, FK → `loot_bag_definitions`, ON DELETE RESTRICT) ergänzen, CHECK
„genau eine der beiden gesetzt“, und der Unique-Index
`IDX_shop_offers_shop_item` braucht ein Gegenstück für Taschen. `ShopService`
muss beim Kauf einer Tasche eine `CharacterLootBag`-Zeile anlegen statt
`CharacterItem` zu stapeln — idempotent, weil `IDX_character_loot_bags_character_definition`
unique ist und 0.9 §11 „bag grant is idempotent“ verlangt.
**Migration required:** make `item_definition_id` nullable, add
`loot_bag_definition_id` (nullable, FK → `loot_bag_definitions`, ON DELETE
RESTRICT), a CHECK for "exactly one of the two is set", and the unique index
`IDX_shop_offers_shop_item` needs a counterpart for bags. On buying a bag,
`ShopService` must create a `CharacterLootBag` row instead of stacking a
`CharacterItem` — idempotently, because
`IDX_character_loot_bags_character_definition` is unique and 0.9 §11 requires
"bag grant is idempotent".
## Getroffene Entscheidungen (vom Nutzer bestätigt)
## Decisions Taken (Confirmed by the User)
1. **Starter-Bags im Seed:** Nur die Trophy Pouch aus dem Demo-Seed entfernen
(`vertical-slice.seed.ts:376-392`). Die Hide Bag bleibt vorerst geseedet,
bis 0.9 sie über die Quest-Referral vergibt.
2. **Hide-Bag-Gate:** Ruf-Gate **oder** Referral-Flag
(`referred-by-south-gate-warden`, 0.9 §5). Braucht ein klar benanntes
Bypass-Feld am Angebot — ausdrücklich *keine* generische Regel-Engine
(§3, §7, §11 „No generalized rules engine). Vorschlag:
`shop_offers.bypass_conditions` (jsonb) mit ODER-Semantik gegen
`conditions` — kleinste Erweiterung, die den Ausnahmefall trägt.
3. **Drittes Angebot:** Bandit Blade (existiert bereits als ItemDefinition,
`ITEM_IDS['bandit-blade']`, weaponDamage 11 / +1 Attack) hinter
1. **Starter bags in the seed:** Remove only the Trophy Pouch from the demo seed
(`vertical-slice.seed.ts:376-392`). The Hide Bag stays seeded for now, until
0.9 hands it over through the quest referral.
2. **Hide Bag gate:** Reputation gate **or** referral flag
(`referred-by-south-gate-warden`, 0.9 §5). Needs a clearly named bypass field
on the offer — expressly *not* a generic rules engine (§3, §7, §11 "No
generalized rules engine"). Proposal: `shop_offers.bypass_conditions` (jsonb)
with OR semantics against `conditions` — the smallest extension that carries
the exception case.
3. **Third offer:** Bandit Blade (already exists as an ItemDefinition,
`ITEM_IDS['bandit-blade']`, weaponDamage 11 / +1 Attack) behind
`WORLD_RENOWN >= 3`.
## Balancing-Anhaltspunkte
## Balancing Reference Points
- Exchange zahlt 2/3/5/12 Ruf pro Ware (`npc-content.ts` `EXCHANGE_RULES`).
- Reputationsränge: 0 Stranger, 100 Tolerated, 250 Known, 500 Recognized,
800 Trusted, 1200 Esteemed (`reputation-rank.ts`). Die Gates sind numerisch,
nicht rangbasiert — die Spec-Beispiele nennen „Reputation 25“.
- Renown: `Character.renown`, aktuell nur ein Milestone (`first-goods-returned`,
+1). **Offen:** Renown 3 ist mit dem heutigen Content nicht erreichbar —
entweder niedrigerer Schwellwert oder das Angebot bleibt bis 0.11 sichtbar
gesperrt (was §5 „visible rewards create goals“ sogar entspricht).
- Spec-Beispiel §5 wörtlich: Trophy Pouch, 40 Silber, Requires Ashen Fields
- The exchange pays 2/3/5/12 reputation per trade good (`npc-content.ts`
`EXCHANGE_RULES`).
- Reputation ranks: 0 Stranger, 100 Tolerated, 250 Known, 500 Recognized,
800 Trusted, 1200 Esteemed (`reputation-rank.ts`). The gates are numeric, not
rank-based — the spec examples say "Reputation 25".
- Renown: `Character.renown`, currently only one milestone
(`first-goods-returned`, +1). **Open:** Renown 3 is unreachable with today's
content — either a lower threshold, or the offer stays visibly locked until
0.11 (which actually matches §5, "visible rewards create goals").
- Spec example §5, verbatim: Trophy Pouch, 40 Silver, Requires Ashen Fields
Reputation 25.
## Betroffene Dateien
## Files Affected
| Datei | Rolle |
| File | Role |
|---|---|
| `apps/api/src/database/migrations/1796*-SellableLootBags.ts` | neu: Bag-Angebote + Bypass-Spalte |
| `apps/api/src/shops/entities/shop-offer.entity.ts` | nullable Item-FK, Bag-FK, `bypassConditions` |
| `apps/api/src/shops/shop.service.ts` | Bag-Grant, ODER-Bypass, Anforderungs-Beschreibung via `describe()` |
| `apps/api/src/shops/shop.errors.ts` | `MERCHANT_REPUTATION_TOO_LOW` (§6) statt/neben `SHOP_OFFER_LOCKED` |
| `apps/api/src/database/seeds/npc-content.ts` | drei gesperrte Angebote |
| `apps/api/src/database/seeds/vertical-slice.seed.ts:376` | Trophy Pouch aus Demo-Seed nehmen |
| `apps/web/src/app/core/api/game-api.models.ts:433` | `requirements` am `ShopOfferView` |
| `apps/web/src/app/features/npc/merchant-page.component.{html,scss}` | Anforderung + aktueller Wert je Zeile |
| `apps/web/src/app/features/npc/merchant.store.ts` | Unlock-Feedback nach Trade (§9) |
| `apps/api/src/database/migrations/1796*-SellableLootBags.ts` | new: bag offers + bypass column |
| `apps/api/src/shops/entities/shop-offer.entity.ts` | nullable item FK, bag FK, `bypassConditions` |
| `apps/api/src/shops/shop.service.ts` | bag grant, OR bypass, requirement description via `describe()` |
| `apps/api/src/shops/shop.errors.ts` | `MERCHANT_REPUTATION_TOO_LOW` (§6) instead of / alongside `SHOP_OFFER_LOCKED` |
| `apps/api/src/database/seeds/npc-content.ts` | three locked offers |
| `apps/api/src/database/seeds/vertical-slice.seed.ts:376` | take the Trophy Pouch out of the demo seed |
| `apps/web/src/app/core/api/game-api.models.ts:433` | `requirements` on `ShopOfferView` |
| `apps/web/src/app/features/npc/merchant-page.component.{html,scss}` | requirement + current value per row |
| `apps/web/src/app/features/npc/merchant.store.ts` | unlock feedback after a trade (§9) |
## Noch offen vor dem Plan
## Still Open Before the Plan
- Wortlaut/Träger des Unlock-Feedbacks (§9): Der Store lädt den Shop nach einem
Trade ohnehin neu (`merchant.store.ts:209-212`), also lässt sich ein
Vorher/Nachher-Vergleich der `unlocked`-Flags lokal ziehen — ohne neues
Server-Event. Das ist die kleinste Lösung und braucht keinen Endpoint.
- Ob `MERCHANT_REPUTATION_TOO_LOW` den bestehenden `SHOP_OFFER_LOCKED` ersetzt
oder ergänzt. Ersetzen bricht den vorhandenen Frontend-Mapping-Eintrag und
einen Test; ergänzen (spezifischer Code, wenn die verletzte Bedingung eine
Reputationsbedingung ist) erfüllt §6 wörtlich ohne Regression.
- The wording and carrier of the unlock feedback (§9): the store reloads the shop
after a trade anyway (`merchant.store.ts:209-212`), so a before/after
comparison of the `unlocked` flags can be drawn locally — with no new server
event. That is the smallest solution and needs no endpoint.
- Whether `MERCHANT_REPUTATION_TOO_LOW` replaces the existing
`SHOP_OFFER_LOCKED` or supplements it. Replacing it breaks the existing
frontend mapping entry and one test; supplementing it (a more specific code
when the violated condition is a reputation condition) satisfies §6 verbatim
without a regression.