feat(content): gate the trophy pouch, hide bag and bandit blade
This commit is contained in:
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,33 +358,27 @@ 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 [
|
const existingBag = await characterLootBagRepository.findOneBy({
|
||||||
BASIC_HIDE_BAG_ID,
|
characterId: DEMO_CHARACTER_ID,
|
||||||
BASIC_TROPHY_POUCH_ID,
|
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
|
||||||
]) {
|
});
|
||||||
const existingBag = await characterLootBagRepository.findOneBy({
|
if (!existingBag) {
|
||||||
|
await characterLootBagRepository.insert({
|
||||||
characterId: DEMO_CHARACTER_ID,
|
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).
|
// 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',
|
||||||
|
|||||||
Reference in New Issue
Block a user