docs
This commit is contained in:
@@ -265,9 +265,12 @@ describe('Visible vertical slice smoke (e2e)', () => {
|
||||
}
|
||||
|
||||
expect(atBurnedRoad.key).toBe('burned-road');
|
||||
// Ordered by encounter weight, heaviest first (spec §3).
|
||||
expect(atBurnedRoad.possibleMonsters).toEqual([
|
||||
'Ash Rat',
|
||||
'Feral Road Hound',
|
||||
'Road Bandit',
|
||||
'Charred Raider',
|
||||
]);
|
||||
|
||||
const firstHunt = await request(app.getHttpServer())
|
||||
@@ -277,13 +280,22 @@ describe('Visible vertical slice smoke (e2e)', () => {
|
||||
expect(typeof firstHunt.body.id).toBe('string');
|
||||
expect(firstHunt.body.location).toMatchObject({ key: 'burned-road' });
|
||||
expect(firstHunt.body.encounters).toHaveLength(3);
|
||||
for (const encounter of firstHunt.body.encounters as Array<{
|
||||
const encounters = firstHunt.body.encounters as Array<{
|
||||
id: string;
|
||||
monster: { key: string };
|
||||
monster: { key: string; flavorText: string | null };
|
||||
dangerRating: string;
|
||||
}>) {
|
||||
encounterType: string;
|
||||
}>;
|
||||
for (const encounter of encounters) {
|
||||
expect(typeof encounter.id).toBe('string');
|
||||
expect(['ash-rat', 'road-bandit']).toContain(encounter.monster.key);
|
||||
expect([
|
||||
'ash-rat',
|
||||
'wild-road-dog',
|
||||
'road-bandit',
|
||||
'charred-looter',
|
||||
]).toContain(encounter.monster.key);
|
||||
expect(typeof encounter.monster.flavorText).toBe('string');
|
||||
expect(['NORMAL', 'RARE']).toContain(encounter.encounterType);
|
||||
expect([
|
||||
'WEAK',
|
||||
'MATCH',
|
||||
@@ -293,6 +305,37 @@ describe('Visible vertical slice smoke (e2e)', () => {
|
||||
]).toContain(encounter.dangerRating);
|
||||
}
|
||||
|
||||
// The cards are a real choice between different enemies, so no monster
|
||||
// may fill two of them (spec §3).
|
||||
const rolledKeys = encounters.map((encounter) => encounter.monster.key);
|
||||
expect(new Set(rolledKeys).size).toBe(rolledKeys.length);
|
||||
|
||||
// Only the Charred Raider is marked rare, so the mark stays meaningful.
|
||||
for (const encounter of encounters) {
|
||||
expect(encounter.encounterType === 'RARE').toBe(
|
||||
encounter.monster.key === 'charred-looter',
|
||||
);
|
||||
}
|
||||
|
||||
const capacities = await request(app.getHttpServer())
|
||||
.get('/api/loot-bags/capacities')
|
||||
.expect(200);
|
||||
|
||||
// Slice 0.7.5 §11: the carrying state is server-derived and covers every
|
||||
// known loot category.
|
||||
expect(
|
||||
(capacities.body as Array<{ category: string }>).map(
|
||||
(entry) => entry.category,
|
||||
),
|
||||
).toEqual(['HIDE', 'RAIDER_TROPHY']);
|
||||
for (const entry of capacities.body as Array<{
|
||||
current: number;
|
||||
capacity: number;
|
||||
}>) {
|
||||
expect(entry.capacity).toBeGreaterThanOrEqual(1);
|
||||
expect(entry.current).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
|
||||
const secondHunt = await request(app.getHttpServer())
|
||||
.post('/api/hunts')
|
||||
.expect(201);
|
||||
@@ -321,6 +364,146 @@ describe('Visible vertical slice smoke (e2e)', () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('sells goods to Borin in Graufurt for Silver and reputation, and frees bag capacity', async () => {
|
||||
// Slice 0.8 §14: the merchant is reachable, trading is server-priced and
|
||||
// atomic, and handing goods over frees carrying capacity.
|
||||
interface LocationBody {
|
||||
id: string;
|
||||
key: string;
|
||||
connections: Array<{
|
||||
targetLocation: { id: string; key: string };
|
||||
travelDurationSeconds: number;
|
||||
}>;
|
||||
}
|
||||
interface Offer {
|
||||
itemKey: string;
|
||||
quantityCarried: number;
|
||||
silverPerStep: number;
|
||||
}
|
||||
|
||||
const origin = await request(app.getHttpServer())
|
||||
.get('/api/world/current-location')
|
||||
.expect(200);
|
||||
const originLocation = origin.body as LocationBody;
|
||||
|
||||
if (originLocation.key !== 'south-gate') {
|
||||
const toGate = originLocation.connections.find(
|
||||
(connection) => connection.targetLocation.key === 'south-gate',
|
||||
);
|
||||
expect(toGate).toBeDefined();
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/travel')
|
||||
.send({ targetLocationId: toGate!.targetLocation.id })
|
||||
.expect(201);
|
||||
await pollUntilTravelCompletes(app, toGate!.travelDurationSeconds);
|
||||
}
|
||||
|
||||
const interaction = await request(app.getHttpServer())
|
||||
.get('/api/npcs/borin-quartermaster/interaction')
|
||||
.expect(200);
|
||||
const npcBody = interaction.body as {
|
||||
npc: { key: string; name: string };
|
||||
dialogue: { text: string } | null;
|
||||
availableActions: Array<{ type: string }>;
|
||||
};
|
||||
|
||||
expect(npcBody.npc).toMatchObject({
|
||||
key: 'borin-quartermaster',
|
||||
name: 'Borin',
|
||||
});
|
||||
// One person, several jobs -- the composition model (NPC spec §2).
|
||||
expect(npcBody.availableActions.map((action) => action.type)).toEqual(
|
||||
expect.arrayContaining(['TALK', 'OPEN_SHOP', 'OPEN_EXCHANGE']),
|
||||
);
|
||||
expect(typeof npcBody.dialogue?.text).toBe('string');
|
||||
|
||||
const view = await request(app.getHttpServer())
|
||||
.get('/api/merchants/borin-quartermaster/trade-in')
|
||||
.expect(200);
|
||||
const offers = (view.body as { offers: Offer[] }).offers;
|
||||
|
||||
// All four Burned Road trade goods are accepted (slice §5).
|
||||
expect(offers.map((offer) => offer.itemKey).sort()).toEqual([
|
||||
'ash-pelt',
|
||||
'bandit-insignia',
|
||||
'charred-raider-insignia',
|
||||
'tough-hide',
|
||||
]);
|
||||
|
||||
// An item this merchant does not take is refused, whatever the client
|
||||
// claims (slice §13).
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/merchants/borin-quartermaster/trade-in')
|
||||
.send({ items: [{ itemKey: 'worn-short-sword', quantity: 1 }] })
|
||||
.expect(409);
|
||||
|
||||
const carried = offers.find((offer) => offer.quantityCarried > 0);
|
||||
if (!carried) {
|
||||
// Nothing to sell on this run. Everything above is still covered; the
|
||||
// trade itself is exercised whenever a hunt has produced goods.
|
||||
return;
|
||||
}
|
||||
|
||||
const before = await request(app.getHttpServer())
|
||||
.get('/api/characters/me')
|
||||
.expect(200);
|
||||
const silverBefore = (before.body as { silver: number }).silver;
|
||||
|
||||
// More than is carried must be refused outright, leaving Silver alone.
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/merchants/borin-quartermaster/trade-in')
|
||||
.send({
|
||||
items: [
|
||||
{ itemKey: carried.itemKey, quantity: carried.quantityCarried + 1 },
|
||||
],
|
||||
})
|
||||
.expect(409);
|
||||
|
||||
const unchanged = await request(app.getHttpServer())
|
||||
.get('/api/characters/me')
|
||||
.expect(200);
|
||||
expect((unchanged.body as { silver: number }).silver).toBe(silverBefore);
|
||||
|
||||
const traded = await request(app.getHttpServer())
|
||||
.post('/api/merchants/borin-quartermaster/trade-in')
|
||||
.send({ items: [{ itemKey: carried.itemKey, quantity: 1 }] })
|
||||
.expect(201);
|
||||
const result = traded.body as {
|
||||
consumed: Array<{ itemKey: string; quantity: number }>;
|
||||
rewards: { silver: number; regionalReputation: number };
|
||||
balances: { silver: number };
|
||||
capacities: Array<{ category: string }>;
|
||||
};
|
||||
|
||||
expect(result.consumed).toEqual([
|
||||
expect.objectContaining({ itemKey: carried.itemKey, quantity: 1 }),
|
||||
]);
|
||||
expect(result.rewards.silver).toBe(carried.silverPerStep);
|
||||
expect(result.balances.silver).toBe(silverBefore + carried.silverPerStep);
|
||||
expect(result.rewards.regionalReputation).toBeGreaterThan(0);
|
||||
|
||||
// Capacity is derived from what is carried, so the trade frees it
|
||||
// immediately (slice §11).
|
||||
expect(result.capacities.length).toBeGreaterThan(0);
|
||||
|
||||
const after = await request(app.getHttpServer())
|
||||
.get('/api/merchants/borin-quartermaster/trade-in')
|
||||
.expect(200);
|
||||
const afterOffer = (after.body as { offers: Offer[] }).offers.find(
|
||||
(offer) => offer.itemKey === carried.itemKey,
|
||||
);
|
||||
expect(afterOffer?.quantityCarried).toBe(carried.quantityCarried - 1);
|
||||
|
||||
if (originLocation.key !== 'south-gate') {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/travel')
|
||||
.send({ targetLocationId: originLocation.id })
|
||||
.expect(201);
|
||||
await pollUntilTravelCompletes(app, 30);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
async function pollUntilTravelCompletes(
|
||||
application: INestApplication<App>,
|
||||
travelDurationSeconds: number,
|
||||
|
||||
Reference in New Issue
Block a user