Reconciles Slice 0.6.5 (Renown & Reputation Foundation) against master's persistent-HP-and-regeneration slice, which landed independently and touches several of the same files (Character entity, CombatService, EquipmentService, the inventory detail panel). Conflict resolutions: - CharacterStatsService/EquipmentService constructor wiring: kept master's CharacterVitalsService injection, which this branch's version of the same files didn't have yet. - CombatService.performAction: kept master's HP-guard logic (characterTooWounded, vitals pause-on-enter) alongside this branch's multi-line calculate() call style. - Inventory detail panel (.html/.ts/.scss/.spec.ts): master had redesigned the panel (wrapping section, rarity styling, flavour text, a shared inventory.labels.ts) on top of the OLD level-gated component, since this branch's removal of the level gate (R4, Task 8/14) hadn't reached master yet. Kept master's visual redesign in full, but with the level-gate concept removed throughout: no requiredLevel stat block, no meetsLevelRequirement() branch in the equip button, no now-dead .detail__value--unmet SCSS rule. Kept both branches' independent tests (non-equippable-item, flavour-text). - inventory-page.component.ts: dropped master's dead characterLevel computed (nothing in the template read it, and the level concept is gone); kept its independent bagCells/bagUsed/bagCapacity grid feature, which has nothing to do with renown or level. Post-merge fixture repairs (three files failed the Angular bundle compile because they predate master's hpRegenPerSecond/hpRegenSince fields or master's item description field, neither conflict-marked since git considered them non-overlapping edits): - app.spec.ts: a 'renders loaded character values' test added on master after this branch forked still used the abolished level/ experience fields on its decoy fixture -- retargeted to renown. - inventory-detail-panel.component.spec.ts: the ashPelt fixture added by this branch's final-review follow-up predates master's required description field. - top-bar.component.spec.ts: this branch's fixture predates master's required hpRegenPerSecond/hpRegenSince fields. No database migration touches the same column: master's 1792000000000-AddHpRegeneration only adds characters.hp_regen_since, independent of this slice's 1791000000000-CreateRenownAndReputation. Timestamp ordering between the two was already correct with no rename needed. Verified: API 288/288 (267 from this slice + 21 from master), API build zero errors, web 237/237 (230 from this slice + 7 from master). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
5.1 KiB
TypeScript
177 lines
5.1 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { DataSource } from 'typeorm';
|
|
import { CharacterStatsService } from '../characters/character-stats.service';
|
|
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
|
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 { EquipmentSlot } from '../items/equipment-slot.enum';
|
|
import { ItemRarity } from '../items/item-rarity.enum';
|
|
import { CharacterEquipment } from './entities/character-equipment.entity';
|
|
import {
|
|
characterInCombat,
|
|
characterItemNotFound,
|
|
characterNotFound,
|
|
itemNotEquippable,
|
|
itemNotOwned,
|
|
} from './equipment.errors';
|
|
|
|
export interface EquipmentSlotItemDto {
|
|
characterItemId: string;
|
|
item: {
|
|
key: string;
|
|
name: string;
|
|
rarity: ItemRarity;
|
|
iconPath: string;
|
|
};
|
|
}
|
|
|
|
export type EquipmentSlotsDto = Record<
|
|
EquipmentSlot,
|
|
EquipmentSlotItemDto | null
|
|
>;
|
|
|
|
export interface EquipmentStatsDto {
|
|
maxHp: number;
|
|
attack: number;
|
|
weaponDamage: number;
|
|
armor: number;
|
|
}
|
|
|
|
export interface EquipmentResponseDto {
|
|
slots: EquipmentSlotsDto;
|
|
stats: EquipmentStatsDto;
|
|
}
|
|
|
|
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
|
|
|
@Injectable()
|
|
export class EquipmentService {
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly characterStats: CharacterStatsService,
|
|
private readonly characterVitals: CharacterVitalsService,
|
|
) {}
|
|
|
|
async getEquipment(characterId: string): Promise<EquipmentResponseDto> {
|
|
const character = await this.dataSource
|
|
.getRepository(Character)
|
|
.findOneBy({ id: characterId });
|
|
if (!character) {
|
|
throw characterNotFound();
|
|
}
|
|
return this.buildResponse(character, this.dataSource);
|
|
}
|
|
|
|
/**
|
|
* Equips (or replaces) one slot for `characterId` with `characterItemId`
|
|
* (spec §14, §28). Runs in one transaction: the old item is unequipped by
|
|
* being overwritten, never deleted (spec §15).
|
|
*/
|
|
async equip(
|
|
characterId: string,
|
|
characterItemId: string,
|
|
): Promise<EquipmentResponseDto> {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const characters = manager.getRepository(Character);
|
|
const combats = manager.getRepository(Combat);
|
|
const characterItems = manager.getRepository(CharacterItem);
|
|
const equipmentRepo = manager.getRepository(CharacterEquipment);
|
|
|
|
const character = await characters.findOne({
|
|
where: { id: characterId },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (!character) {
|
|
throw characterNotFound();
|
|
}
|
|
|
|
const activeCombat = await combats.findOne({
|
|
where: { characterId, status: CombatStatus.ACTIVE },
|
|
});
|
|
if (activeCombat) {
|
|
throw characterInCombat();
|
|
}
|
|
|
|
const characterItem = await characterItems.findOne({
|
|
where: { id: characterItemId },
|
|
relations: { itemDefinition: true },
|
|
});
|
|
if (!characterItem) {
|
|
throw characterItemNotFound();
|
|
}
|
|
if (characterItem.characterId !== characterId) {
|
|
throw itemNotOwned();
|
|
}
|
|
|
|
const definition = characterItem.itemDefinition;
|
|
if (!definition.equipmentSlot) {
|
|
throw itemNotEquippable();
|
|
}
|
|
|
|
const statsBeforeChange = await this.characterStats.calculate(character, manager);
|
|
this.characterVitals.settle(character, statsBeforeChange.maxHp);
|
|
await characters.save(character);
|
|
|
|
const existing = await equipmentRepo.findOne({
|
|
where: { characterId, slot: definition.equipmentSlot },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (existing) {
|
|
existing.characterItemId = characterItem.id;
|
|
await equipmentRepo.save(existing);
|
|
} else {
|
|
await equipmentRepo.save(
|
|
equipmentRepo.create({
|
|
characterId,
|
|
slot: definition.equipmentSlot,
|
|
characterItemId: characterItem.id,
|
|
}),
|
|
);
|
|
}
|
|
|
|
return this.buildResponse(character, manager);
|
|
});
|
|
}
|
|
|
|
private async buildResponse(
|
|
character: Character,
|
|
scope: RepositoryScope,
|
|
): Promise<EquipmentResponseDto> {
|
|
const equipped = await scope.getRepository(CharacterEquipment).find({
|
|
where: { characterId: character.id },
|
|
relations: { characterItem: { itemDefinition: true } },
|
|
});
|
|
|
|
const slots = Object.fromEntries(
|
|
Object.values(EquipmentSlot).map((slot) => [slot, null]),
|
|
) as EquipmentSlotsDto;
|
|
|
|
for (const row of equipped) {
|
|
const definition = row.characterItem.itemDefinition;
|
|
slots[row.slot] = {
|
|
characterItemId: row.characterItemId,
|
|
item: {
|
|
key: definition.key,
|
|
name: definition.name,
|
|
rarity: definition.rarity,
|
|
iconPath: definition.iconPath,
|
|
},
|
|
};
|
|
}
|
|
|
|
const stats = await this.characterStats.calculate(character, scope);
|
|
|
|
return {
|
|
slots,
|
|
stats: {
|
|
maxHp: stats.maxHp,
|
|
attack: stats.attack,
|
|
weaponDamage: stats.weaponDamage,
|
|
armor: stats.armor,
|
|
},
|
|
};
|
|
}
|
|
}
|