docs
This commit is contained in:
@@ -6,9 +6,15 @@ import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||
import { ItemRarity } from '../items/item-rarity.enum';
|
||||
import { ItemType } from '../items/item-type.enum';
|
||||
import { LootCategory } from '../items/loot-category.enum';
|
||||
import { LootService } from '../loot/loot.service';
|
||||
import {
|
||||
DEFAULT_LOOT_CAPACITY,
|
||||
LootCapacityBudget,
|
||||
LootCapacityService,
|
||||
} from '../loot-bags/loot-capacity.service';
|
||||
import type { LootCapacityDto } from '../loot-bags/loot-capacity.service';
|
||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||
import type { RandomSource } from '../shared/random-source';
|
||||
import { CombatRewardService } from './combat-reward.service';
|
||||
import { CombatReward } from './entities/combat-reward.entity';
|
||||
import { CombatRewardItem } from './entities/combat-reward-item.entity';
|
||||
@@ -22,6 +28,8 @@ const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001';
|
||||
const ROAD_BANDIT_TABLE = '60000000-0000-4000-8000-000000000002';
|
||||
const BANDIT_BLADE = '50000000-0000-4000-8000-000000000002';
|
||||
const BANDIT_HOOD = '50000000-0000-4000-8000-000000000001';
|
||||
const ASH_PELT = '50000000-0000-4000-8000-00000000000c';
|
||||
const TOUGH_HIDE = '50000000-0000-4000-8000-00000000000e';
|
||||
|
||||
interface State {
|
||||
characters: Character[];
|
||||
@@ -142,15 +150,11 @@ function createState(overrides: Partial<State> = {}): State {
|
||||
{
|
||||
id: ASH_RAT_ID,
|
||||
key: 'ash-rat',
|
||||
silverMin: 4,
|
||||
silverMax: 7,
|
||||
lootTableId: ASH_RAT_TABLE,
|
||||
} as MonsterDefinition,
|
||||
{
|
||||
id: ROAD_BANDIT_ID,
|
||||
key: 'road-bandit',
|
||||
silverMin: 9,
|
||||
silverMax: 15,
|
||||
lootTableId: ROAD_BANDIT_TABLE,
|
||||
} as MonsterDefinition,
|
||||
],
|
||||
@@ -161,8 +165,27 @@ function createState(overrides: Partial<State> = {}): State {
|
||||
name: 'Bandit Blade',
|
||||
type: ItemType.EQUIPMENT,
|
||||
rarity: ItemRarity.COMMON,
|
||||
lootCategory: null,
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
} as ItemDefinition,
|
||||
{
|
||||
id: ASH_PELT,
|
||||
key: 'ash-pelt',
|
||||
name: 'Ashen Pelt',
|
||||
type: ItemType.TRADE_GOOD,
|
||||
rarity: ItemRarity.COMMON,
|
||||
lootCategory: LootCategory.HIDE,
|
||||
iconPath: '/images/items/ash-pelt.png',
|
||||
} as ItemDefinition,
|
||||
{
|
||||
id: TOUGH_HIDE,
|
||||
key: 'tough-hide',
|
||||
name: 'Tough Hide',
|
||||
type: ItemType.TRADE_GOOD,
|
||||
rarity: ItemRarity.COMMON,
|
||||
lootCategory: LootCategory.HIDE,
|
||||
iconPath: '/images/items/tough-hide.png',
|
||||
} as ItemDefinition,
|
||||
],
|
||||
characterItems: [],
|
||||
combatRewards: [],
|
||||
@@ -187,16 +210,49 @@ function fakeLoot(
|
||||
} as unknown as LootService;
|
||||
}
|
||||
|
||||
function fixedRandom(value: number): RandomSource {
|
||||
return { next: () => value };
|
||||
/**
|
||||
* A capacity service with hand-set free room per category.
|
||||
*
|
||||
* Hands out a real `LootCapacityBudget`, so these tests exercise the actual
|
||||
* spend-down arithmetic rather than a mock of it. Where the numbers come from
|
||||
* -- bags, owned items -- is `LootCapacityService`'s own spec.
|
||||
*/
|
||||
function fakeCapacity(
|
||||
free: Partial<Record<LootCategory, number>> = {},
|
||||
): LootCapacityService {
|
||||
const roomFor = (category: LootCategory) =>
|
||||
free[category] ?? DEFAULT_LOOT_CAPACITY;
|
||||
|
||||
return {
|
||||
createBudget: () =>
|
||||
Promise.resolve(
|
||||
new LootCapacityBudget(
|
||||
new Map(
|
||||
Object.values(LootCategory).map((category) => [
|
||||
category,
|
||||
roomFor(category),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
getCapacities: (): Promise<LootCapacityDto[]> =>
|
||||
Promise.resolve(
|
||||
Object.values(LootCategory).map((category) => ({
|
||||
category,
|
||||
current: 0,
|
||||
capacity: roomFor(category),
|
||||
bag: null,
|
||||
})),
|
||||
),
|
||||
} as unknown as LootCapacityService;
|
||||
}
|
||||
|
||||
function service(
|
||||
state: State,
|
||||
loot: LootService = fakeLoot(),
|
||||
random: RandomSource = fixedRandom(0.5),
|
||||
capacity: LootCapacityService = fakeCapacity({ [LootCategory.HIDE]: 99 }),
|
||||
): CombatRewardService {
|
||||
return new CombatRewardService({} as never, loot, random);
|
||||
return new CombatRewardService({} as never, loot, capacity);
|
||||
}
|
||||
|
||||
describe('CombatRewardService', () => {
|
||||
@@ -211,7 +267,6 @@ describe('CombatRewardService', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'COMBAT_NOT_WON' });
|
||||
expect(state.combatRewards).toHaveLength(0);
|
||||
expect(state.characters[0].silver).toBe(3);
|
||||
});
|
||||
|
||||
it('rejects a LOST combat', async () => {
|
||||
@@ -234,53 +289,54 @@ describe('CombatRewardService', () => {
|
||||
combat(),
|
||||
);
|
||||
|
||||
expect(reward).toEqual({ silver: 6, items: [] });
|
||||
expect(reward.items).toEqual([]);
|
||||
expect(state.combatRewards).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ash Rat', () => {
|
||||
it('grants a silver roll inside 4-7, persisted on the character', async () => {
|
||||
it('grants the guaranteed Ashen Pelt the loot table rolled', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(
|
||||
state,
|
||||
fakeLoot(),
|
||||
fixedRandom(0),
|
||||
fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }),
|
||||
).grantVictoryRewards(fakeManager(state), combat());
|
||||
|
||||
expect(reward.silver).toBe(4);
|
||||
expect(state.characters[0].silver).toBe(7);
|
||||
expect(reward.items).toEqual([
|
||||
{
|
||||
characterItemId: state.characterItems[0].id,
|
||||
item: {
|
||||
key: 'ash-pelt',
|
||||
name: 'Ashen Pelt',
|
||||
type: ItemType.TRADE_GOOD,
|
||||
rarity: ItemRarity.COMMON,
|
||||
iconPath: '/images/items/ash-pelt.png',
|
||||
lootCategory: LootCategory.HIDE,
|
||||
},
|
||||
quantity: 1,
|
||||
quantityLeftBehind: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rolls the top of the silver range from the top of the random range', async () => {
|
||||
// Playable Slice 0.7 V2 §7: a normal kill hands over goods, never currency.
|
||||
it('grants no Silver for a normal kill', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(
|
||||
state,
|
||||
fakeLoot(),
|
||||
fixedRandom(0.99),
|
||||
fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }),
|
||||
).grantVictoryRewards(fakeManager(state), combat());
|
||||
|
||||
expect(reward.silver).toBe(7);
|
||||
expect(reward).not.toHaveProperty('silver');
|
||||
expect(state.characters[0].silver).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Road Bandit', () => {
|
||||
const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID });
|
||||
|
||||
it('grants a silver roll inside 9-15', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(
|
||||
state,
|
||||
fakeLoot(),
|
||||
fixedRandom(0),
|
||||
).grantVictoryRewards(fakeManager(state), banditCombat);
|
||||
|
||||
expect(reward.silver).toBe(9);
|
||||
});
|
||||
|
||||
it('persists a dropped Bandit Blade as a CharacterItem and references it in the reward', async () => {
|
||||
const state = createState();
|
||||
|
||||
@@ -302,10 +358,13 @@ describe('CombatRewardService', () => {
|
||||
item: {
|
||||
key: 'bandit-blade',
|
||||
name: 'Bandit Blade',
|
||||
type: ItemType.EQUIPMENT,
|
||||
rarity: ItemRarity.COMMON,
|
||||
iconPath: '/images/items/bandit-blade.png',
|
||||
lootCategory: null,
|
||||
},
|
||||
quantity: 1,
|
||||
quantityLeftBehind: 0,
|
||||
},
|
||||
]);
|
||||
expect(state.combatRewardItems).toHaveLength(1);
|
||||
@@ -348,25 +407,163 @@ describe('CombatRewardService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('grants no renown for a normal monster kill', async () => {
|
||||
it('grants neither renown nor Silver for a normal monster kill', async () => {
|
||||
const state = createState();
|
||||
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
|
||||
const subject = service(state, loot, fixedRandom(0));
|
||||
const subject = service(state, loot);
|
||||
|
||||
await subject.grantVictoryRewards(fakeManager(state), combat());
|
||||
|
||||
// Renown comes from milestones only (spec §36). Killing things must never
|
||||
// move it -- that is the whole point of replacing XP with Renown, so this
|
||||
// asserts the prohibition rather than trusting that nothing wired it up.
|
||||
// Renown comes from milestones and Silver from merchant exchange only
|
||||
// (slice 0.6.5 spec §36, slice 0.7 V2 §7). Killing things must move
|
||||
// neither -- that is the whole point of the new progression model, so this
|
||||
// asserts the prohibition rather than trusting nothing wired it up.
|
||||
expect(state.characters[0].renown).toBe(4);
|
||||
expect(state.characters[0].silver).toBe(7);
|
||||
expect(state.characters[0].silver).toBe(3);
|
||||
});
|
||||
|
||||
describe('loot-bag capacity (slice 0.7.5 §9, §10)', () => {
|
||||
it('carries the first pelt when the bagless default of 1 is all there is', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(
|
||||
state,
|
||||
fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }),
|
||||
fakeCapacity(),
|
||||
).grantVictoryRewards(fakeManager(state), combat());
|
||||
|
||||
expect(reward.items[0]).toMatchObject({
|
||||
quantity: 1,
|
||||
quantityLeftBehind: 0,
|
||||
});
|
||||
expect(state.characterItems[0].quantity).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves a pelt behind once the category is full', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(
|
||||
state,
|
||||
fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }),
|
||||
fakeCapacity({ [LootCategory.HIDE]: 0 }),
|
||||
).grantVictoryRewards(fakeManager(state), combat());
|
||||
|
||||
expect(reward.items[0]).toMatchObject({
|
||||
characterItemId: null,
|
||||
quantity: 0,
|
||||
quantityLeftBehind: 1,
|
||||
});
|
||||
// Nothing was carried, so no stack may have been created.
|
||||
expect(state.characterItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the victory and its reward record valid when nothing fit', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(
|
||||
state,
|
||||
fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }),
|
||||
fakeCapacity({ [LootCategory.HIDE]: 0 }),
|
||||
).grantVictoryRewards(fakeManager(state), combat());
|
||||
|
||||
// §9: a full bag must never invalidate the win. The reward exists, and
|
||||
// the refused drop is persisted so a refresh can still explain it.
|
||||
expect(state.combatRewards).toHaveLength(1);
|
||||
expect(state.combatRewardItems).toHaveLength(1);
|
||||
expect(state.combatRewardItems[0]).toMatchObject({
|
||||
quantity: 0,
|
||||
quantityLeftBehind: 1,
|
||||
});
|
||||
expect(reward.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('grants only what fits and reports the rest', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(
|
||||
state,
|
||||
fakeLoot({ itemDefinitionId: TOUGH_HIDE, quantity: 2 }),
|
||||
fakeCapacity({ [LootCategory.HIDE]: 1 }),
|
||||
).grantVictoryRewards(fakeManager(state), combat());
|
||||
|
||||
// §10: HIDE 4/5 with a Tough Hide x2 reward grants 1, leaves 1.
|
||||
expect(reward.items[0]).toMatchObject({
|
||||
quantity: 1,
|
||||
quantityLeftBehind: 1,
|
||||
});
|
||||
expect(state.characterItems[0].quantity).toBe(1);
|
||||
});
|
||||
|
||||
it('spends one budget across two goods of the same category', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(
|
||||
state,
|
||||
fakeLoot(
|
||||
{ itemDefinitionId: ASH_PELT, quantity: 1 },
|
||||
{ itemDefinitionId: TOUGH_HIDE, quantity: 1 },
|
||||
),
|
||||
fakeCapacity({ [LootCategory.HIDE]: 1 }),
|
||||
).grantVictoryRewards(fakeManager(state), combat());
|
||||
|
||||
// Both are HIDE and only one slot is free, so the second must not slip
|
||||
// through the same gap the first already took.
|
||||
const byKey = new Map(reward.items.map((i) => [i.item.key, i]));
|
||||
expect(byKey.get('ash-pelt')).toMatchObject({
|
||||
quantity: 1,
|
||||
quantityLeftBehind: 0,
|
||||
});
|
||||
expect(byKey.get('tough-hide')).toMatchObject({
|
||||
quantity: 0,
|
||||
quantityLeftBehind: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('still grants equipment when the trade-good category is full', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(
|
||||
state,
|
||||
fakeLoot(
|
||||
{ itemDefinitionId: ASH_PELT, quantity: 1 },
|
||||
{ itemDefinitionId: BANDIT_BLADE, quantity: 1 },
|
||||
),
|
||||
fakeCapacity({ [LootCategory.HIDE]: 0 }),
|
||||
).grantVictoryRewards(fakeManager(state), combat());
|
||||
|
||||
// §9: equipment must never be lost because the hide bag is full.
|
||||
const blade = reward.items.find((i) => i.item.key === 'bandit-blade');
|
||||
expect(blade).toMatchObject({ quantity: 1, quantityLeftBehind: 0 });
|
||||
expect(state.characterItems).toEqual([
|
||||
expect.objectContaining({
|
||||
itemDefinitionId: BANDIT_BLADE,
|
||||
quantity: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports the carrying state alongside the reward', async () => {
|
||||
const state = createState();
|
||||
|
||||
const reward = await service(
|
||||
state,
|
||||
fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }),
|
||||
fakeCapacity({ [LootCategory.HIDE]: 5 }),
|
||||
).grantVictoryRewards(fakeManager(state), combat());
|
||||
|
||||
// §11: the victory screen must be able to show capacity without a
|
||||
// second request.
|
||||
expect(reward.capacities.map((entry) => entry.category)).toEqual(
|
||||
Object.values(LootCategory),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency', () => {
|
||||
it('grants once and returns the same persisted reward on a repeat call', async () => {
|
||||
const state = createState();
|
||||
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
|
||||
const subject = service(state, loot, fixedRandom(0));
|
||||
const subject = service(state, loot);
|
||||
const manager = fakeManager(state);
|
||||
|
||||
const first = await subject.grantVictoryRewards(manager, combat());
|
||||
@@ -377,7 +574,6 @@ describe('CombatRewardService', () => {
|
||||
expect(state.combatRewardItems).toHaveLength(1);
|
||||
expect(state.characterItems).toHaveLength(1);
|
||||
expect(state.characterItems[0].quantity).toBe(1);
|
||||
expect(state.characters[0].silver).toBe(7);
|
||||
expect(loot.rollLoot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -394,7 +590,7 @@ describe('CombatRewardService', () => {
|
||||
it('replays the persisted reward without rerolling', async () => {
|
||||
const state = createState();
|
||||
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
|
||||
const subject = service(state, loot, fixedRandom(0));
|
||||
const subject = service(state, loot);
|
||||
const manager = fakeManager(state);
|
||||
const granted = await subject.grantVictoryRewards(manager, combat());
|
||||
|
||||
@@ -411,7 +607,7 @@ describe('CombatRewardService', () => {
|
||||
const subject = new CombatRewardService(
|
||||
dataSource as never,
|
||||
loot,
|
||||
fixedRandom(0),
|
||||
fakeCapacity({ [LootCategory.HIDE]: 99 }),
|
||||
);
|
||||
const manager = fakeManager(state);
|
||||
|
||||
@@ -439,7 +635,7 @@ describe('CombatRewardService', () => {
|
||||
// Every rolled item definition is resolved before any mutation, so a
|
||||
// missing one must leave no reward row and no character grant behind.
|
||||
expect(state.combatRewards).toHaveLength(0);
|
||||
expect(state.characters[0].silver).toBe(3);
|
||||
expect(state.characterItems).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -473,7 +669,7 @@ describe('CombatRewardService', () => {
|
||||
{ itemDefinitionId: BANDIT_BLADE, quantity: 1 },
|
||||
{ itemDefinitionId: BANDIT_HOOD, quantity: 1 },
|
||||
);
|
||||
const subject = service(state, loot, fixedRandom(0));
|
||||
const subject = service(state, loot);
|
||||
const manager = fakeManager(state);
|
||||
|
||||
const granted = await subject.grantVictoryRewards(manager, banditCombat);
|
||||
|
||||
@@ -1,34 +1,53 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { CombatStatus } from '../combat/combat-status.enum';
|
||||
import { Combat } from '../combat/entities/combat.entity';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||
import { ItemRarity } from '../items/item-rarity.enum';
|
||||
import { ItemType } from '../items/item-type.enum';
|
||||
import { LootCategory } from '../items/loot-category.enum';
|
||||
import { LootService } from '../loot/loot.service';
|
||||
import { LootCapacityService } from '../loot-bags/loot-capacity.service';
|
||||
import type { LootCapacityDto } from '../loot-bags/loot-capacity.service';
|
||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||
import { RANDOM_SOURCE } from '../shared/random-source';
|
||||
import type { RandomSource } from '../shared/random-source';
|
||||
import { rollInclusive } from '../shared/roll-range';
|
||||
import { CombatReward } from './entities/combat-reward.entity';
|
||||
import { CombatRewardItem } from './entities/combat-reward-item.entity';
|
||||
import { combatNotWon, rewardStateInvalid } from './rewards.errors';
|
||||
|
||||
export interface CombatRewardItemDto {
|
||||
characterItemId: string;
|
||||
/** Null when the whole drop was left behind — no stack was created. */
|
||||
characterItemId: string | null;
|
||||
item: {
|
||||
key: string;
|
||||
name: string;
|
||||
// The loot summary groups by this (Trade Goods / Equipment /
|
||||
// Consumables, 0.7 §9) instead of hard-coding item keys in the UI.
|
||||
type: ItemType;
|
||||
rarity: ItemRarity;
|
||||
iconPath: string;
|
||||
/** The carrying bucket this counts against; null when uncapped. */
|
||||
lootCategory: LootCategory | null;
|
||||
};
|
||||
/** How much reached the character. 0 when the bag was already full. */
|
||||
quantity: number;
|
||||
/** How much the bag refused, so the summary can say so (0.7.5 §9, §10). */
|
||||
quantityLeftBehind: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a victory actually hands over (0.7 §7, 0.7.5 §11).
|
||||
*
|
||||
* Items only. A normal kill grants no XP, Silver, regional reputation or
|
||||
* World Renown -- those reach the player through merchant exchange and
|
||||
* milestones instead.
|
||||
*
|
||||
* `capacities` is the carrying state *after* this reward, so the victory
|
||||
* screen can show the player they are now full without a second request.
|
||||
*/
|
||||
export interface CombatRewardDto {
|
||||
silver: number;
|
||||
items: CombatRewardItemDto[];
|
||||
capacities: LootCapacityDto[];
|
||||
}
|
||||
|
||||
// Both DataSource and EntityManager expose this; naming it keeps the read path
|
||||
@@ -40,7 +59,7 @@ export class CombatRewardService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly lootService: LootService,
|
||||
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource,
|
||||
private readonly lootCapacity: LootCapacityService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -50,8 +69,12 @@ export class CombatRewardService {
|
||||
* already holds a pessimistic write lock on the combat row — so either
|
||||
* everything below commits or nothing does.
|
||||
*
|
||||
* Roll order is fixed: silver first, then the loot table in `position`
|
||||
* order. Tests depend on it.
|
||||
* The loot table is rolled in `position` order and nothing else is rolled:
|
||||
* a normal victory grants no Silver (0.7 §7). Tests depend on that order.
|
||||
*
|
||||
* A full loot bag never invalidates the victory (0.7.5 §9): the roll still
|
||||
* happens, equipment still lands, and only the trade goods that do not fit
|
||||
* are recorded as left behind.
|
||||
*/
|
||||
async grantVictoryRewards(
|
||||
manager: EntityManager,
|
||||
@@ -75,17 +98,12 @@ export class CombatRewardService {
|
||||
throw rewardStateInvalid();
|
||||
}
|
||||
|
||||
const silver = rollInclusive(
|
||||
this.randomSource,
|
||||
monster.silverMin,
|
||||
monster.silverMax,
|
||||
);
|
||||
const roll = await this.lootService.rollLoot(monster.lootTableId, manager);
|
||||
|
||||
// Resolve every rolled item definition up front, before any mutation, so
|
||||
// a missing definition throws `rewardStateInvalid()` before the
|
||||
// character's silver is touched or a `CombatReward` row is created.
|
||||
// This keeps a failed grant from leaving partial writes behind.
|
||||
// a missing definition throws `rewardStateInvalid()` before a
|
||||
// `CombatReward` row or any character item is created. This keeps a
|
||||
// failed grant from leaving partial writes behind.
|
||||
const definitions = manager.getRepository(ItemDefinition);
|
||||
const resolvedDefinitions = new Map<string, ItemDefinition>();
|
||||
for (const rolled of roll.items) {
|
||||
@@ -101,22 +119,13 @@ export class CombatRewardService {
|
||||
resolvedDefinitions.set(rolled.itemDefinitionId, definition);
|
||||
}
|
||||
|
||||
const characters = manager.getRepository(Character);
|
||||
const character = await characters.findOne({
|
||||
where: { id: combat.characterId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!character) {
|
||||
throw rewardStateInvalid();
|
||||
}
|
||||
character.silver += silver;
|
||||
await characters.save(character);
|
||||
|
||||
// No character row is touched here any more: a victory grants items only
|
||||
// (spec §7). `CombatService.performAction` already holds the character
|
||||
// lock for the rest of the round.
|
||||
const reward = await rewards.save(
|
||||
rewards.create({
|
||||
combatId: combat.id,
|
||||
characterId: combat.characterId,
|
||||
silverGranted: silver,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -127,40 +136,64 @@ export class CombatRewardService {
|
||||
dto: CombatRewardItemDto;
|
||||
}> = [];
|
||||
|
||||
// One budget for the whole reward, spent down item by item, so two hides
|
||||
// in the same drop cannot both claim the last free slot (0.7.5 §10).
|
||||
// Taken inside the caller's transaction, which already holds the
|
||||
// character lock, so a concurrent fight cannot fill the bag underneath us.
|
||||
const budget = await this.lootCapacity.createBudget(
|
||||
combat.characterId,
|
||||
manager,
|
||||
);
|
||||
|
||||
for (const rolled of roll.items) {
|
||||
const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!;
|
||||
|
||||
const existingStack = await characterItems.findOne({
|
||||
where: {
|
||||
characterId: combat.characterId,
|
||||
itemDefinitionId: rolled.itemDefinitionId,
|
||||
},
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
// Duplicates stack; Slice 0.4 adds no duplicate protection (spec §28).
|
||||
const characterItem = existingStack
|
||||
? Object.assign(existingStack, {
|
||||
quantity: existingStack.quantity + rolled.quantity,
|
||||
})
|
||||
: characterItems.create({
|
||||
const grantedQuantity = budget.take(
|
||||
definition.lootCategory,
|
||||
rolled.quantity,
|
||||
);
|
||||
const leftBehind = rolled.quantity - grantedQuantity;
|
||||
|
||||
let characterItem: CharacterItem | null = null;
|
||||
if (grantedQuantity > 0) {
|
||||
const existingStack = await characterItems.findOne({
|
||||
where: {
|
||||
characterId: combat.characterId,
|
||||
itemDefinitionId: rolled.itemDefinitionId,
|
||||
quantity: rolled.quantity,
|
||||
});
|
||||
await characterItems.save(characterItem);
|
||||
},
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
// Duplicates stack; Slice 0.4 adds no duplicate protection (0.4 §28).
|
||||
characterItem = existingStack
|
||||
? Object.assign(existingStack, {
|
||||
quantity: existingStack.quantity + grantedQuantity,
|
||||
})
|
||||
: characterItems.create({
|
||||
characterId: combat.characterId,
|
||||
itemDefinitionId: rolled.itemDefinitionId,
|
||||
quantity: grantedQuantity,
|
||||
});
|
||||
await characterItems.save(characterItem);
|
||||
}
|
||||
|
||||
await rewardItems.save(
|
||||
rewardItems.create({
|
||||
combatRewardId: reward.id,
|
||||
characterItemId: characterItem.id,
|
||||
characterItemId: characterItem?.id ?? null,
|
||||
itemDefinitionId: definition.id,
|
||||
quantity: rolled.quantity,
|
||||
quantity: grantedQuantity,
|
||||
quantityLeftBehind: leftBehind,
|
||||
}),
|
||||
);
|
||||
|
||||
granted.push({
|
||||
itemDefinitionId: rolled.itemDefinitionId,
|
||||
dto: this.toItemDto(characterItem.id, definition, rolled.quantity),
|
||||
dto: this.toItemDto(
|
||||
characterItem?.id ?? null,
|
||||
definition,
|
||||
grantedQuantity,
|
||||
leftBehind,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,9 +203,14 @@ export class CombatRewardService {
|
||||
granted.sort((a, b) =>
|
||||
a.itemDefinitionId.localeCompare(b.itemDefinitionId),
|
||||
);
|
||||
const items = granted.map((entry) => entry.dto);
|
||||
|
||||
return { silver, items };
|
||||
return {
|
||||
items: granted.map((entry) => entry.dto),
|
||||
capacities: await this.lootCapacity.getCapacities(
|
||||
combat.characterId,
|
||||
manager,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads a persisted reward so a refresh replays it (spec §25, §48). */
|
||||
@@ -215,32 +253,42 @@ export class CombatRewardService {
|
||||
rewardItem.characterItemId,
|
||||
definition,
|
||||
rewardItem.quantity,
|
||||
rewardItem.quantityLeftBehind,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
silver: reward.silverGranted,
|
||||
items,
|
||||
// Read live rather than snapshotted: a replay should show what the
|
||||
// character can carry now, which is what the player acts on.
|
||||
capacities: await this.lootCapacity.getCapacities(
|
||||
reward.characterId,
|
||||
scope,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private toItemDto(
|
||||
characterItemId: string,
|
||||
characterItemId: string | null,
|
||||
definition: ItemDefinition,
|
||||
quantity: number,
|
||||
quantityLeftBehind: number,
|
||||
): CombatRewardItemDto {
|
||||
// Drop chance, roll results, and loot-table ids never leave the server
|
||||
// (spec §26).
|
||||
// (0.4 §26).
|
||||
return {
|
||||
characterItemId,
|
||||
item: {
|
||||
key: definition.key,
|
||||
name: definition.name,
|
||||
type: definition.type,
|
||||
rarity: definition.rarity,
|
||||
iconPath: definition.iconPath,
|
||||
lootCategory: definition.lootCategory,
|
||||
},
|
||||
quantity,
|
||||
quantityLeftBehind,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ import { CombatReward } from './combat-reward.entity';
|
||||
* Needed because `CharacterItem.quantity` is a running stack total: after a
|
||||
* duplicate drop it no longer says how much *this* victory granted, and a
|
||||
* refreshed reward screen must replay the original result (spec §25, §48).
|
||||
*
|
||||
* Since Playable Slice 0.7.5 a row also records what did *not* fit: loot
|
||||
* refused by a full bag is still part of what happened in this fight, and the
|
||||
* summary has to say so after a refresh as much as immediately (0.7.5 §9).
|
||||
*/
|
||||
@Entity({ name: 'combat_reward_items' })
|
||||
@Index('IDX_combat_reward_items_reward', ['combatRewardId'])
|
||||
@@ -34,15 +38,23 @@ export class CombatRewardItem {
|
||||
@Column({ name: 'combat_reward_id', type: 'uuid' })
|
||||
combatRewardId!: string;
|
||||
|
||||
@Column({ name: 'character_item_id', type: 'uuid' })
|
||||
characterItemId!: string;
|
||||
// Null when the whole drop was left behind: no stack was created, so there
|
||||
// is no character item to point at (0.7.5 §9). Also nulled later if the
|
||||
// stack is spent -- see the relation below.
|
||||
@Column({ name: 'character_item_id', type: 'uuid', nullable: true })
|
||||
characterItemId!: string | null;
|
||||
|
||||
@Column({ name: 'item_definition_id', type: 'uuid' })
|
||||
itemDefinitionId!: string;
|
||||
|
||||
/** How much actually reached the character. May be 0 on a full bag. */
|
||||
@Column({ name: 'quantity', type: 'integer' })
|
||||
quantity!: number;
|
||||
|
||||
/** How much the bag refused. 0 for everything that fit (0.7.5 §10). */
|
||||
@Column({ name: 'quantity_left_behind', type: 'integer', default: 0 })
|
||||
quantityLeftBehind!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@@ -50,9 +62,13 @@ export class CombatRewardItem {
|
||||
@JoinColumn({ name: 'combat_reward_id' })
|
||||
combatReward!: CombatReward;
|
||||
|
||||
@ManyToOne(() => CharacterItem, { onDelete: 'RESTRICT' })
|
||||
// SET NULL rather than RESTRICT: Slice 0.8 introduced the first code path
|
||||
// that consumes a stack entirely (trading the last pelt to a merchant), and
|
||||
// RESTRICT made that fail outright. The reward record still says what
|
||||
// dropped; it just no longer points at a stack that is gone.
|
||||
@ManyToOne(() => CharacterItem, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'character_item_id' })
|
||||
characterItem!: CharacterItem;
|
||||
characterItem!: CharacterItem | null;
|
||||
|
||||
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'item_definition_id' })
|
||||
|
||||
@@ -13,6 +13,9 @@ import { Combat } from '../../combat/entities/combat.entity';
|
||||
/**
|
||||
* Proof that one combat has already been rewarded (spec §8).
|
||||
*
|
||||
* Carries no currency column: a normal victory grants items only, never
|
||||
* Silver (Playable Slice 0.7 V2 spec §7).
|
||||
*
|
||||
* The unique index on `combatId` is the database half of the idempotency
|
||||
* invariant; `CombatRewardService` is the service half.
|
||||
*/
|
||||
@@ -29,9 +32,6 @@ export class CombatReward {
|
||||
@Column({ name: 'character_id', type: 'uuid' })
|
||||
characterId!: string;
|
||||
|
||||
@Column({ name: 'silver_granted', type: 'integer' })
|
||||
silverGranted!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Character } from '../characters/entities/character.entity';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||
import { LootModule } from '../loot/loot.module';
|
||||
import { LootBagsModule } from '../loot-bags/loot-bags.module';
|
||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
|
||||
import { CombatRewardService } from './combat-reward.service';
|
||||
@@ -21,6 +22,7 @@ import { CombatRewardItem } from './entities/combat-reward-item.entity';
|
||||
CombatRewardItem,
|
||||
]),
|
||||
LootModule,
|
||||
LootBagsModule,
|
||||
],
|
||||
providers: [
|
||||
CombatRewardService,
|
||||
|
||||
Reference in New Issue
Block a user