diff --git a/apps/api/src/database/seeds/npc-content.ts b/apps/api/src/database/seeds/npc-content.ts index 3f48340..6636668 100644 --- a/apps/api/src/database/seeds/npc-content.ts +++ b/apps/api/src/database/seeds/npc-content.ts @@ -9,6 +9,10 @@ import type { DialogueResponseContent, } from '../../npcs/npc.types'; import { ITEM_IDS } from './item.constants'; +import { + BASIC_HIDE_BAG_ID, + BASIC_TROPHY_POUCH_ID, +} from './loot-bag-content'; import { BORDER_GUARD_FACTION_ID } from './reputation-content'; export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001'; @@ -19,6 +23,20 @@ export const BORIN_KEY = 'borin-quartermaster'; export const BORIN_SHOP_KEY = 'borin-supplies'; export const BORIN_EXCHANGE_KEY = 'borin-trade-in'; +// Stable offer ids so re-seeding re-tunes a price instead of inserting a +// second row (AGENTS.md §8). Needed here in particular because an offer has +// two possible targets and no single natural key across both shapes. +export const BORIN_OFFER_IDS = { + potion: 'b4000000-0000-4000-8000-000000000001', + shortsword: 'b4000000-0000-4000-8000-000000000002', + trophyPouch: 'b4000000-0000-4000-8000-000000000003', + hideBag: 'b4000000-0000-4000-8000-000000000004', + banditBlade: 'b4000000-0000-4000-8000-000000000005', +} as const; + +/** The flag the Slice 0.9 warden sets when she sends the player to Borin. */ +export const SOUTH_GATE_REFERRAL_FLAG = 'referred-by-south-gate-warden'; + /** * The Renown 2 milestone (Playable Slice 0.6.5 §6). * @@ -188,49 +206,139 @@ export const NPC_SHOPS: SeedNpcShop[] = [ ]; export interface SeedShopOffer { + id: string; shopId: string; - itemDefinitionId: string; + itemDefinitionId: string | null; + lootBagDefinitionId: string | null; currencyType: string; price: number; quantity: number; repeatable: boolean; sortOrder: number; conditions: GameCondition[]; + bypassConditions: GameCondition[]; enabled: boolean; } /** - * Deliberately thin (slice §12: "Avoid adding many new items just to populate - * the shop"). The point of 0.8 is trade-in; the shop exists so Silver has - * somewhere to go the moment it is earned. + * What Borin sells (Playable Slice 0.8 §12, Slice 0.8.5 §4). * - * No offer carries conditions yet -- reputation-gated offers are Slice 0.8.5 - * (slice §3). Loot bags are not sold here either: 0.8.5 §4 plans the Basic - * Hide Bag as its locked-offer example, and a bag is a `LootBagDefinition` - * rather than an `ItemDefinition`, so selling one needs an offer shape this - * slice has no reason to build. + * 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, }, ]; diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index e4e774d..3f19478 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -20,6 +20,7 @@ import { NpcDefinition } from '../../npcs/entities/npc-definition.entity'; import { NpcShop } from '../../shops/entities/npc-shop.entity'; import { ShopOffer } from '../../shops/entities/shop-offer.entity'; import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants'; +import { GameConditionType } from '../../conditions/game-condition.types'; import { ASH_RAT_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID, @@ -27,6 +28,8 @@ import { ROAD_BANDIT_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; +import { BASIC_HIDE_BAG_ID } from './loot-bag-content'; +import { SHOP_OFFERS } from './npc-content'; import { seedVisibleVerticalSlice } from './vertical-slice.seed'; type Row = Record; @@ -637,7 +640,7 @@ describe('seedVisibleVerticalSlice', () => { ); }); - it('gives the demo character one active bag per category, idempotently', async () => { + it('gives the demo character only the Hide Bag, idempotently', async () => { const characterLootBagRepository = new InMemoryRepository(); const dataSource = createDataSource( new InMemoryRepository(), @@ -659,20 +662,18 @@ describe('seedVisibleVerticalSlice', () => { await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource); - // Deliberate: 0.7.5 §7 leaves acquisition to the merchant slice, but with - // no merchant yet a bagless character could never empty a full bag. See - // the ASSUMPTION note in the seed. - expect(characterLootBagRepository.rows).toHaveLength(2); + // Slice 0.8.5 decision: the Trophy Pouch is now a reputation-gated offer, + // so handing it to the demo character for free would undercut the + // showcase. The Hide Bag stays -- without it, HIDE capacity would drop to + // the bagless default of 1 with no way to raise it before Slice 0.9 grants + // it through the warden's referral. See the ASSUMPTION note in the seed. + expect(characterLootBagRepository.rows).toHaveLength(1); expect(characterLootBagRepository.rows.map((row) => row.active)).toEqual([ true, - true, ]); expect( characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId), - ).toEqual([ - 'a0000000-0000-4000-8000-000000000001', - 'a0000000-0000-4000-8000-000000000002', - ]); + ).toEqual(['a0000000-0000-4000-8000-000000000001']); }); it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => { @@ -904,4 +905,56 @@ describe('seedVisibleVerticalSlice', () => { enabled: true, }); }); + + it('seeds two visibly locked bag offers and one renown-gated weapon', async () => { + const gated = SHOP_OFFERS.filter((offer) => offer.conditions.length > 0); + + expect(gated).toHaveLength(3); + expect( + gated.map((offer) => offer.conditions[0].type).sort(), + ).toEqual([ + GameConditionType.REGION_REPUTATION, + GameConditionType.REGION_REPUTATION, + GameConditionType.WORLD_RENOWN, + ]); + }); + + it('gives the Hide Bag a referral bypass for Slice 0.9', async () => { + const hideBag = SHOP_OFFERS.find( + (offer) => offer.lootBagDefinitionId === BASIC_HIDE_BAG_ID, + ); + + expect(hideBag?.bypassConditions).toEqual([ + { + type: GameConditionType.FLAG_SET, + key: 'referred-by-south-gate-warden', + value: true, + }, + ]); + }); + + it('sells every bag as a one-off', async () => { + // A bag is one object; a second copy raises no capacity. + for (const offer of SHOP_OFFERS.filter( + (candidate) => candidate.lootBagDefinitionId !== null, + )) { + expect(offer.repeatable).toBe(false); + } + }); + + it('gives every offer exactly one target', async () => { + for (const offer of SHOP_OFFERS) { + const targets = [offer.itemDefinitionId, offer.lootBagDefinitionId].filter( + (target) => target !== null, + ); + expect(targets).toHaveLength(1); + } + }); + + it('gives every offer a stable id so re-seeding cannot duplicate it', async () => { + const ids = SHOP_OFFERS.map((offer) => offer.id); + + expect(new Set(ids).size).toBe(ids.length); + expect(ids.every((id) => id.length === 36)).toBe(true); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index b7a0f47..f5be0f9 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -43,11 +43,7 @@ import { BURNED_ROAD_LOCAL_CONTENT, SOUTH_GATE_LOCAL_CONTENT, } from './local-location.content'; -import { - BASIC_HIDE_BAG_ID, - BASIC_TROPHY_POUCH_ID, - LOOT_BAG_DEFINITIONS, -} from './loot-bag-content'; +import { BASIC_HIDE_BAG_ID, LOOT_BAG_DEFINITIONS } from './loot-bag-content'; import { REPUTATION_FACTIONS } from './reputation-content'; import { DIALOGUE_NODES, @@ -362,33 +358,27 @@ export async function seedVisibleVerticalSlice( }); } - // ASSUMPTION (Playable Slice 0.7.5 §7 leaves acquisition to the merchant - // and quest slices, so nothing in 0.7.5 hands out a bag). + // ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag + // and no longer starts with the Trophy Pouch. // - // The demo character starts with both starter bags anyway. Without them the - // bagless default of 1 applies, and since the merchant that empties a bag - // only arrives in 0.8, the second kill of a hunt would leave its trade good - // behind forever -- the hunting loop would be unplayable between these two - // slices. Capacity 5 lets the fill-up actually be experienced. + // 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. // - // Smallest reversible choice: two seed rows, no acquisition mechanism. - // Delete them once the merchant sells bags. + // Delete this block once 0.9 hands the Hide Bag over in the quest. const characterLootBagRepository = dataSource.getRepository(CharacterLootBag); - for (const lootBagDefinitionId of [ - BASIC_HIDE_BAG_ID, - BASIC_TROPHY_POUCH_ID, - ]) { - const existingBag = await characterLootBagRepository.findOneBy({ + const existingBag = await characterLootBagRepository.findOneBy({ + characterId: DEMO_CHARACTER_ID, + lootBagDefinitionId: BASIC_HIDE_BAG_ID, + }); + if (!existingBag) { + await characterLootBagRepository.insert({ characterId: DEMO_CHARACTER_ID, - lootBagDefinitionId, + lootBagDefinitionId: BASIC_HIDE_BAG_ID, + active: true, }); - if (!existingBag) { - await characterLootBagRepository.insert({ - characterId: DEMO_CHARACTER_ID, - lootBagDefinitionId, - active: true, - }); - } } // NPC content (NPC Specification V1 §32; Playable Slice 0.8). @@ -413,7 +403,10 @@ export async function seedVisibleVerticalSlice( await dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']); await npcShopRepository.upsert(NPC_SHOPS, ['key']); - await shopOfferRepository.upsert(SHOP_OFFERS, ['shopId', 'itemDefinitionId']); + // By id, not by (shop, item): an offer may now sell a bag instead of an + // item, so the old pair is null for half the rows and useless as a conflict + // target. Migration 1796 cleared the anonymous 0.8 rows for exactly this. + await shopOfferRepository.upsert(SHOP_OFFERS, ['id']); await npcExchangeProfileRepository.upsert(NPC_EXCHANGE_PROFILES, ['key']); await exchangeRuleRepository.upsert(EXCHANGE_RULES, [ 'profileId',