feat(content): gate the trophy pouch, hide bag and bandit blade

This commit is contained in:
Bastian Wagner
2026-08-22 20:04:48 +02:00
parent 80be829309
commit 36adc32659
3 changed files with 201 additions and 47 deletions

View File

@@ -9,6 +9,10 @@ import type {
DialogueResponseContent, DialogueResponseContent,
} from '../../npcs/npc.types'; } from '../../npcs/npc.types';
import { ITEM_IDS } from './item.constants'; 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'; import { BORDER_GUARD_FACTION_ID } from './reputation-content';
export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001'; 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_SHOP_KEY = 'borin-supplies';
export const BORIN_EXCHANGE_KEY = 'borin-trade-in'; 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). * The Renown 2 milestone (Playable Slice 0.6.5 §6).
* *
@@ -188,49 +206,139 @@ export const NPC_SHOPS: SeedNpcShop[] = [
]; ];
export interface SeedShopOffer { export interface SeedShopOffer {
id: string;
shopId: string; shopId: string;
itemDefinitionId: string; itemDefinitionId: string | null;
lootBagDefinitionId: string | null;
currencyType: string; currencyType: string;
price: number; price: number;
quantity: number; quantity: number;
repeatable: boolean; repeatable: boolean;
sortOrder: number; sortOrder: number;
conditions: GameCondition[]; conditions: GameCondition[];
bypassConditions: GameCondition[];
enabled: boolean; enabled: boolean;
} }
/** /**
* Deliberately thin (slice §12: "Avoid adding many new items just to populate * What Borin sells (Playable Slice 0.8 §12, Slice 0.8.5 §4).
* the shop"). The point of 0.8 is trade-in; the shop exists so Silver has
* somewhere to go the moment it is earned.
* *
* No offer carries conditions yet -- reputation-gated offers are Slice 0.8.5 * Two open offers so Silver always has somewhere to go, and three gated ones
* (slice §3). Loot bags are not sold here either: 0.8.5 §4 plans the Basic * so reputation visibly changes what the player can do (0.8.5 §1). The locked
* Hide Bag as its locked-offer example, and a bag is a `LootBagDefinition` * offers stay listed rather than hidden: a reward you can see is a goal, and a
* rather than an `ItemDefinition`, so selling one needs an offer shape this * reward you cannot see is nothing (0.8.5 §5).
* slice has no reason to build. *
* 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[] = [ export const SHOP_OFFERS: SeedShopOffer[] = [
{ {
id: BORIN_OFFER_IDS.potion,
shopId: BORIN_SHOP_ID, shopId: BORIN_SHOP_ID,
itemDefinitionId: ITEM_IDS['small-healing-potion'], itemDefinitionId: ITEM_IDS['small-healing-potion'],
lootBagDefinitionId: null,
currencyType: 'SILVER', currencyType: 'SILVER',
price: 12, price: 12,
quantity: 1, quantity: 1,
repeatable: true, repeatable: true,
sortOrder: 1, sortOrder: 1,
conditions: [], conditions: [],
bypassConditions: [],
enabled: true, enabled: true,
}, },
{ {
id: BORIN_OFFER_IDS.shortsword,
shopId: BORIN_SHOP_ID, shopId: BORIN_SHOP_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'], itemDefinitionId: ITEM_IDS['worn-short-sword'],
lootBagDefinitionId: null,
currencyType: 'SILVER', currencyType: 'SILVER',
price: 30, price: 30,
quantity: 1, quantity: 1,
repeatable: true, repeatable: true,
sortOrder: 2, sortOrder: 2,
conditions: [], 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, enabled: true,
}, },
]; ];

View File

@@ -20,6 +20,7 @@ import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
import { NpcShop } from '../../shops/entities/npc-shop.entity'; import { NpcShop } from '../../shops/entities/npc-shop.entity';
import { ShopOffer } from '../../shops/entities/shop-offer.entity'; import { ShopOffer } from '../../shops/entities/shop-offer.entity';
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants'; import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
import { GameConditionType } from '../../conditions/game-condition.types';
import { import {
ASH_RAT_LOOT_TABLE_ID, ASH_RAT_LOOT_TABLE_ID,
CHARRED_LOOTER_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID,
@@ -27,6 +28,8 @@ import {
ROAD_BANDIT_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID,
WILD_ROAD_DOG_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants'; } from './item.constants';
import { BASIC_HIDE_BAG_ID } from './loot-bag-content';
import { SHOP_OFFERS } from './npc-content';
import { seedVisibleVerticalSlice } from './vertical-slice.seed'; import { seedVisibleVerticalSlice } from './vertical-slice.seed';
type Row = Record<string, unknown>; type Row = Record<string, unknown>;
@@ -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 characterLootBagRepository = new InMemoryRepository();
const dataSource = createDataSource( const dataSource = createDataSource(
new InMemoryRepository(), new InMemoryRepository(),
@@ -659,20 +662,18 @@ describe('seedVisibleVerticalSlice', () => {
await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource);
// Deliberate: 0.7.5 §7 leaves acquisition to the merchant slice, but with // Slice 0.8.5 decision: the Trophy Pouch is now a reputation-gated offer,
// no merchant yet a bagless character could never empty a full bag. See // so handing it to the demo character for free would undercut the
// the ASSUMPTION note in the seed. // showcase. The Hide Bag stays -- without it, HIDE capacity would drop to
expect(characterLootBagRepository.rows).toHaveLength(2); // 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([ expect(characterLootBagRepository.rows.map((row) => row.active)).toEqual([
true, true,
true,
]); ]);
expect( expect(
characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId), characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId),
).toEqual([ ).toEqual(['a0000000-0000-4000-8000-000000000001']);
'a0000000-0000-4000-8000-000000000001',
'a0000000-0000-4000-8000-000000000002',
]);
}); });
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => { it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
@@ -904,4 +905,56 @@ describe('seedVisibleVerticalSlice', () => {
enabled: true, 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);
});
}); });

View File

@@ -43,11 +43,7 @@ import {
BURNED_ROAD_LOCAL_CONTENT, BURNED_ROAD_LOCAL_CONTENT,
SOUTH_GATE_LOCAL_CONTENT, SOUTH_GATE_LOCAL_CONTENT,
} from './local-location.content'; } from './local-location.content';
import { import { BASIC_HIDE_BAG_ID, LOOT_BAG_DEFINITIONS } from './loot-bag-content';
BASIC_HIDE_BAG_ID,
BASIC_TROPHY_POUCH_ID,
LOOT_BAG_DEFINITIONS,
} from './loot-bag-content';
import { REPUTATION_FACTIONS } from './reputation-content'; import { REPUTATION_FACTIONS } from './reputation-content';
import { import {
DIALOGUE_NODES, DIALOGUE_NODES,
@@ -362,34 +358,28 @@ export async function seedVisibleVerticalSlice(
}); });
} }
// ASSUMPTION (Playable Slice 0.7.5 §7 leaves acquisition to the merchant // ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag
// and quest slices, so nothing in 0.7.5 hands out a bag). // and no longer starts with the Trophy Pouch.
// //
// The demo character starts with both starter bags anyway. Without them the // The pouch is now a reputation-gated offer (0.8.5 §4) and handing it over
// bagless default of 1 applies, and since the merchant that empties a bag // for free would make the slice's own showcase pointless. The Hide Bag stays
// only arrives in 0.8, the second kill of a hunt would leave its trade good // until Slice 0.9 grants it through the warden's referral -- removing both
// behind forever -- the hunting loop would be unplayable between these two // now would drop HIDE capacity to the bagless default of 1 with no way to
// slices. Capacity 5 lets the fill-up actually be experienced. // raise it, and the hunting loop would be unplayable in between.
// //
// Smallest reversible choice: two seed rows, no acquisition mechanism. // Delete this block once 0.9 hands the Hide Bag over in the quest.
// Delete them once the merchant sells bags.
const characterLootBagRepository = dataSource.getRepository(CharacterLootBag); 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, characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId, lootBagDefinitionId: BASIC_HIDE_BAG_ID,
}); });
if (!existingBag) { if (!existingBag) {
await characterLootBagRepository.insert({ await characterLootBagRepository.insert({
characterId: DEMO_CHARACTER_ID, characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId, lootBagDefinitionId: BASIC_HIDE_BAG_ID,
active: true, active: true,
}); });
} }
}
// NPC content (NPC Specification V1 §32; Playable Slice 0.8). // 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 dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']);
await npcShopRepository.upsert(NPC_SHOPS, ['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 npcExchangeProfileRepository.upsert(NPC_EXCHANGE_PROFILES, ['key']);
await exchangeRuleRepository.upsert(EXCHANGE_RULES, [ await exchangeRuleRepository.upsert(EXCHANGE_RULES, [
'profileId', 'profileId',