486 lines
15 KiB
TypeScript
486 lines
15 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { DataSource, Repository } from 'typeorm';
|
|
import { CharacterStatsService } from '../characters/character-stats.service';
|
|
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
|
import { Character } from '../characters/entities/character.entity';
|
|
import { Hunt } from '../hunting/entities/hunt.entity';
|
|
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
|
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
|
|
import { HuntStatus } from '../hunting/hunt-status.enum';
|
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
|
import { CombatRewardService } from '../rewards/combat-reward.service';
|
|
import type { CombatRewardDto } from '../rewards/combat-reward.service';
|
|
import { TravelService } from '../travel/travel.service';
|
|
import { TravelStatus } from '../travel/travel-status.enum';
|
|
import { CombatAction } from './combat-action.enum';
|
|
import { CombatEngineService } from './combat-engine.service';
|
|
import {
|
|
ActiveStatusEffect,
|
|
CombatEngineState,
|
|
CombatIntent,
|
|
} from './combat-engine.types';
|
|
import {
|
|
characterNotFound,
|
|
characterTooWounded,
|
|
characterTravelling,
|
|
combatAlreadyActive,
|
|
combatAlreadyFinished,
|
|
combatNoPotionsRemaining,
|
|
combatNotFound,
|
|
combatStateInvalid,
|
|
huntEncounterAlreadyConsumed,
|
|
huntEncounterNotFound,
|
|
invalidHuntEncounter,
|
|
} from './combat.errors';
|
|
import { CombatStatus } from './combat-status.enum';
|
|
import { CombatEvent } from './entities/combat-event.entity';
|
|
import {
|
|
Combat,
|
|
CombatMonsterState,
|
|
CombatPlayerState,
|
|
} from './entities/combat.entity';
|
|
import { StatusEffectType } from './status-effect.enum';
|
|
|
|
// Playable Slice 0.6 spec §3: fixed at 2 for V1, not yet backed by the
|
|
// persistent consumable inventory.
|
|
const STARTING_POTION_COUNT = 2;
|
|
|
|
export interface CombatStatusEffectDto {
|
|
type: StatusEffectType;
|
|
remainingRounds: number;
|
|
damagePerRound: number;
|
|
}
|
|
|
|
export interface CombatPlayerDto {
|
|
name: string;
|
|
maxHp: number;
|
|
currentHp: number;
|
|
potionsRemaining: number;
|
|
potionsMax: number;
|
|
statusEffects: CombatStatusEffectDto[];
|
|
}
|
|
|
|
export interface CombatMonsterDto {
|
|
key: string;
|
|
name: string;
|
|
level: number;
|
|
maxHp: number;
|
|
currentHp: number;
|
|
artworkPath: string;
|
|
pendingIntent: CombatIntent | null;
|
|
}
|
|
|
|
export interface CombatEventDto {
|
|
round: number;
|
|
sequence: number;
|
|
type: string;
|
|
source: string;
|
|
target: string;
|
|
amount?: number;
|
|
statusEffect?: StatusEffectType;
|
|
}
|
|
|
|
export interface CombatDto {
|
|
id: string;
|
|
status: CombatStatus;
|
|
round: number;
|
|
player: CombatPlayerDto;
|
|
monster: CombatMonsterDto;
|
|
events: CombatEventDto[];
|
|
rewards: CombatRewardDto | null;
|
|
}
|
|
|
|
@Injectable()
|
|
export class CombatService {
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly travelService: TravelService,
|
|
private readonly combatEngine: CombatEngineService,
|
|
private readonly characterStats: CharacterStatsService,
|
|
private readonly characterVitals: CharacterVitalsService,
|
|
private readonly combatRewards: CombatRewardService,
|
|
) {}
|
|
|
|
async startCombat(
|
|
characterId: string,
|
|
encounterId: string,
|
|
): Promise<CombatDto> {
|
|
const travel = await this.travelService.completeTravelIfDue(characterId);
|
|
if (travel.status === TravelStatus.TRAVELLING) {
|
|
throw characterTravelling();
|
|
}
|
|
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const characters = manager.getRepository(Character);
|
|
const encounters = manager.getRepository(HuntEncounter);
|
|
const hunts = manager.getRepository(Hunt);
|
|
const monsters = manager.getRepository(MonsterDefinition);
|
|
const combats = manager.getRepository(Combat);
|
|
|
|
const character = await this.lockCharacter(characters, characterId);
|
|
|
|
const encounter = await encounters.findOne({
|
|
where: { id: encounterId },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (!encounter) {
|
|
throw huntEncounterNotFound();
|
|
}
|
|
if (encounter.status !== HuntEncounterStatus.AVAILABLE) {
|
|
throw huntEncounterAlreadyConsumed();
|
|
}
|
|
|
|
const hunt = await hunts.findOneBy({ id: encounter.huntId });
|
|
if (
|
|
!hunt ||
|
|
hunt.characterId !== characterId ||
|
|
hunt.status !== HuntStatus.ACTIVE
|
|
) {
|
|
throw invalidHuntEncounter();
|
|
}
|
|
|
|
const existingActiveCombat = await combats.findOne({
|
|
where: { characterId, status: CombatStatus.ACTIVE },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (existingActiveCombat) {
|
|
throw combatAlreadyActive();
|
|
}
|
|
|
|
const monster = await monsters.findOneBy({
|
|
id: encounter.monsterDefinitionId,
|
|
});
|
|
if (!monster) {
|
|
throw invalidHuntEncounter();
|
|
}
|
|
|
|
const playerStats = await this.characterStats.calculate(
|
|
character,
|
|
manager,
|
|
);
|
|
if (playerStats.currentHp < 1) {
|
|
throw characterTooWounded();
|
|
}
|
|
this.characterVitals.pause(character, playerStats.currentHp);
|
|
await characters.save(character);
|
|
|
|
const combat = combats.create({
|
|
characterId,
|
|
huntEncounterId: encounter.id,
|
|
monsterDefinitionId: monster.id,
|
|
status: CombatStatus.ACTIVE,
|
|
round: 1,
|
|
playerMaxHp: playerStats.maxHp,
|
|
playerCurrentHp: playerStats.currentHp,
|
|
monsterMaxHp: monster.maxHp,
|
|
monsterCurrentHp: monster.maxHp,
|
|
playerState: {
|
|
attack: playerStats.attack,
|
|
weaponDamage: playerStats.weaponDamage,
|
|
armor: playerStats.armor,
|
|
potionsRemaining: STARTING_POTION_COUNT,
|
|
},
|
|
monsterState: {
|
|
attack: monster.attack,
|
|
armor: monster.armor,
|
|
abilities: monster.abilities ?? {},
|
|
},
|
|
completedAt: null,
|
|
});
|
|
await combats.save(combat);
|
|
|
|
encounter.status = HuntEncounterStatus.IN_PROGRESS;
|
|
await encounters.save(encounter);
|
|
|
|
return this.toCombatDto(combat, character.name, monster, [], null);
|
|
});
|
|
}
|
|
|
|
async getCombat(characterId: string, combatId: string): Promise<CombatDto> {
|
|
const combats = this.dataSource.getRepository(Combat);
|
|
const combat = await combats.findOne({
|
|
where: { id: combatId, characterId },
|
|
});
|
|
if (!combat) {
|
|
throw combatNotFound();
|
|
}
|
|
|
|
const [character, monster, events, rewards] = await Promise.all([
|
|
this.loadCharacter(combat.characterId),
|
|
this.loadMonster(combat.monsterDefinitionId),
|
|
this.loadEvents(combat.id),
|
|
this.combatRewards.loadRewards(combat.id),
|
|
]);
|
|
|
|
return this.toCombatDto(combat, character.name, monster, events, rewards);
|
|
}
|
|
|
|
async getActiveCombat(characterId: string): Promise<CombatDto | null> {
|
|
const combats = this.dataSource.getRepository(Combat);
|
|
const combat = await combats.findOne({
|
|
where: { characterId, status: CombatStatus.ACTIVE },
|
|
});
|
|
if (!combat) {
|
|
return null;
|
|
}
|
|
|
|
const [character, monster, events] = await Promise.all([
|
|
this.loadCharacter(combat.characterId),
|
|
this.loadMonster(combat.monsterDefinitionId),
|
|
this.loadEvents(combat.id),
|
|
]);
|
|
|
|
return this.toCombatDto(combat, character.name, monster, events, null);
|
|
}
|
|
|
|
async performAction(
|
|
characterId: string,
|
|
combatId: string,
|
|
action: CombatAction,
|
|
): Promise<CombatDto> {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const characters = manager.getRepository(Character);
|
|
const combats = manager.getRepository(Combat);
|
|
const combatEvents = manager.getRepository(CombatEvent);
|
|
|
|
// Lock the character before the combat row here, matching the order
|
|
// startCombat already uses (character, then combat). Keeping both code
|
|
// paths on the same order avoids a lock-order inversion that could
|
|
// deadlock two concurrent requests against the same character. Do not
|
|
// reorder this.
|
|
const character = await this.lockCharacter(characters, characterId);
|
|
|
|
const combat = await combats.findOne({
|
|
where: { id: combatId, characterId },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (!combat) {
|
|
throw combatNotFound();
|
|
}
|
|
if (combat.status !== CombatStatus.ACTIVE) {
|
|
throw combatAlreadyFinished();
|
|
}
|
|
if (
|
|
action === CombatAction.POTION &&
|
|
(combat.playerState.potionsRemaining ?? 0) <= 0
|
|
) {
|
|
throw combatNoPotionsRemaining();
|
|
}
|
|
|
|
const actionRound = combat.round;
|
|
const engineState = this.toEngineState(combat);
|
|
const result = this.combatEngine.resolveAction(engineState, { action });
|
|
|
|
combat.round = result.state.round;
|
|
combat.status = result.state.status;
|
|
combat.playerCurrentHp = result.state.player.currentHp;
|
|
combat.monsterCurrentHp = result.state.monster.currentHp;
|
|
combat.playerState = result.state.player.stats as CombatPlayerState;
|
|
combat.monsterState = result.state.monster.stats as CombatMonsterState;
|
|
if (combat.status !== CombatStatus.ACTIVE) {
|
|
combat.completedAt = new Date();
|
|
this.characterVitals.resume(character, combat.playerCurrentHp);
|
|
await this.settleEncounter(
|
|
manager.getRepository(HuntEncounter),
|
|
combat.huntEncounterId,
|
|
combat.status,
|
|
);
|
|
} else {
|
|
this.characterVitals.pause(character, combat.playerCurrentHp);
|
|
}
|
|
await characters.save(character);
|
|
await combats.save(combat);
|
|
|
|
const startingSequence = await combatEvents.count({
|
|
where: { combatId: combat.id },
|
|
});
|
|
for (let index = 0; index < result.events.length; index += 1) {
|
|
const event = result.events[index];
|
|
const entity = combatEvents.create({
|
|
combatId: combat.id,
|
|
round: actionRound,
|
|
sequence: startingSequence + index + 1,
|
|
type: event.type,
|
|
source: event.source,
|
|
target: event.target,
|
|
amount: event.amount ?? null,
|
|
statusEffect: event.statusEffect ?? null,
|
|
});
|
|
await combatEvents.save(entity);
|
|
}
|
|
|
|
// The engine decided the outcome; rewards are resolved here, outside it
|
|
// (spec §30). Running inside this transaction means a reward failure
|
|
// rolls the whole round back rather than leaving a half-granted victory.
|
|
const rewards =
|
|
combat.status === CombatStatus.WON
|
|
? await this.combatRewards.grantVictoryRewards(manager, combat)
|
|
: null;
|
|
|
|
const [reloadedCharacter, monster, events] = await Promise.all([
|
|
this.loadCharacter(
|
|
combat.characterId,
|
|
manager.getRepository(Character),
|
|
),
|
|
this.loadMonster(
|
|
combat.monsterDefinitionId,
|
|
manager.getRepository(MonsterDefinition),
|
|
),
|
|
this.loadEvents(combat.id, combatEvents),
|
|
]);
|
|
|
|
return this.toCombatDto(
|
|
combat,
|
|
reloadedCharacter.name,
|
|
monster,
|
|
events,
|
|
rewards,
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Records the fight's outcome on the encounter that spawned it. A win
|
|
* retires the encounter; a loss hands it back so the player can try again.
|
|
*/
|
|
private async settleEncounter(
|
|
encounters: Repository<HuntEncounter>,
|
|
encounterId: string,
|
|
outcome: CombatStatus,
|
|
): Promise<void> {
|
|
const encounter = await encounters.findOneBy({ id: encounterId });
|
|
if (!encounter) {
|
|
// combats.hunt_encounter_id is a RESTRICT FK; guaranteed to exist.
|
|
throw combatStateInvalid();
|
|
}
|
|
|
|
encounter.status =
|
|
outcome === CombatStatus.WON
|
|
? HuntEncounterStatus.DEFEATED
|
|
: HuntEncounterStatus.AVAILABLE;
|
|
await encounters.save(encounter);
|
|
}
|
|
|
|
private async lockCharacter(
|
|
characters: Repository<Character>,
|
|
characterId: string,
|
|
): Promise<Character> {
|
|
const character = await characters.findOne({
|
|
where: { id: characterId },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (!character) {
|
|
throw characterNotFound();
|
|
}
|
|
return character;
|
|
}
|
|
|
|
private async loadCharacter(
|
|
characterId: string,
|
|
repo?: Repository<Character>,
|
|
): Promise<Character> {
|
|
const characters = repo ?? this.dataSource.getRepository(Character);
|
|
const character = await characters.findOneBy({ id: characterId });
|
|
if (!character) {
|
|
// combats.character_id is a RESTRICT FK; a persisted combat's
|
|
// character is guaranteed to exist.
|
|
throw combatStateInvalid();
|
|
}
|
|
return character;
|
|
}
|
|
|
|
private async loadMonster(
|
|
monsterId: string,
|
|
repo?: Repository<MonsterDefinition>,
|
|
): Promise<MonsterDefinition> {
|
|
const monsters = repo ?? this.dataSource.getRepository(MonsterDefinition);
|
|
const monster = await monsters.findOneBy({ id: monsterId });
|
|
if (!monster) {
|
|
// combats.monster_definition_id is a RESTRICT FK; guaranteed to exist.
|
|
throw combatStateInvalid();
|
|
}
|
|
return monster;
|
|
}
|
|
|
|
private loadEvents(
|
|
combatId: string,
|
|
repo?: Repository<CombatEvent>,
|
|
): Promise<CombatEvent[]> {
|
|
const combatEvents = repo ?? this.dataSource.getRepository(CombatEvent);
|
|
return combatEvents.find({
|
|
where: { combatId },
|
|
order: { sequence: 'ASC' },
|
|
});
|
|
}
|
|
|
|
private toEngineState(combat: Combat): CombatEngineState {
|
|
return {
|
|
status: combat.status,
|
|
round: combat.round,
|
|
player: {
|
|
currentHp: combat.playerCurrentHp,
|
|
maxHp: combat.playerMaxHp,
|
|
stats: combat.playerState,
|
|
},
|
|
monster: {
|
|
currentHp: combat.monsterCurrentHp,
|
|
maxHp: combat.monsterMaxHp,
|
|
stats: combat.monsterState,
|
|
},
|
|
};
|
|
}
|
|
|
|
private toCombatDto(
|
|
combat: Combat,
|
|
playerName: string,
|
|
monster: MonsterDefinition,
|
|
events: CombatEvent[],
|
|
rewards: CombatRewardDto | null,
|
|
): CombatDto {
|
|
return {
|
|
id: combat.id,
|
|
status: combat.status,
|
|
round: combat.round,
|
|
player: {
|
|
name: playerName,
|
|
maxHp: combat.playerMaxHp,
|
|
currentHp: combat.playerCurrentHp,
|
|
potionsRemaining: combat.playerState.potionsRemaining,
|
|
potionsMax: STARTING_POTION_COUNT,
|
|
statusEffects: this.toStatusEffectDtos(
|
|
combat.playerState.statusEffects,
|
|
),
|
|
},
|
|
monster: {
|
|
key: monster.key,
|
|
name: monster.name,
|
|
level: monster.level,
|
|
maxHp: combat.monsterMaxHp,
|
|
currentHp: combat.monsterCurrentHp,
|
|
artworkPath: monster.artworkPath,
|
|
pendingIntent: combat.monsterState.pendingAction ?? null,
|
|
},
|
|
events: events.map((event) => ({
|
|
round: event.round,
|
|
sequence: event.sequence,
|
|
type: event.type,
|
|
source: event.source,
|
|
target: event.target,
|
|
amount: event.amount ?? undefined,
|
|
statusEffect: event.statusEffect ?? undefined,
|
|
})),
|
|
rewards,
|
|
};
|
|
}
|
|
|
|
private toStatusEffectDtos(
|
|
effects: ActiveStatusEffect[] | undefined,
|
|
): CombatStatusEffectDto[] {
|
|
return (effects ?? []).map((effect) => ({
|
|
type: effect.type,
|
|
remainingRounds: effect.remainingRounds,
|
|
damagePerRound: effect.damagePerRound,
|
|
}));
|
|
}
|
|
}
|