diff --git a/docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md b/docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md index 4ba6e4b..5f7d7a4 100644 --- a/docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md +++ b/docs/superpowers/plans/2026-08-22-slice-0.8.5-research-notes.md @@ -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.