Files
ashen-realms/apps/api/src/equipment/equipment.service.ts
Bastian Wagner cedd87f648 Merge branch 'master' into worktree-playable-slice-0.6.5-renown-reputation
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>
2026-08-21 19:23:10 +02:00

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