feat(content): gate the trophy pouch, hide bag and bandit blade
This commit is contained in:
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<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 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user