This commit is contained in:
Bastian Wagner
2026-08-22 16:41:47 +02:00
parent dfa62fd152
commit 081c9f83f9
137 changed files with 11594 additions and 1302 deletions

View File

@@ -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);

View File

@@ -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,
};
}
}

View File

@@ -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' })

View File

@@ -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;

View File

@@ -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,