diff --git a/apps/api/src/hunting/danger-rating.spec.ts b/apps/api/src/hunting/danger-rating.spec.ts new file mode 100644 index 0000000..062eb3f --- /dev/null +++ b/apps/api/src/hunting/danger-rating.spec.ts @@ -0,0 +1,78 @@ +import { calculateDangerRating, DangerRating } from './danger-rating'; + +// Demo character seed stats (Task 3): baseAttack: 6, baseHp: 100, no armor. +// power(character) = 6*4 + 0*2 + floor(100/5) = 24 + 0 + 20 = 44 +const CHARACTER = { attack: 6, armor: 0, hp: 100 }; +const CHARACTER_POWER = 44; + +describe('calculateDangerRating', () => { + it('rates the seeded Aschenratte as MATCH', () => { + // Aschenratte: attack 5, armor 0, maxHp 45 + // power(monster) = 5*4 + 0*2 + floor(45/5) = 20 + 0 + 9 = 29 + // ratio = 29 / 44 = 0.6590909... -> not < 0.65, and < 1.0 -> MATCH + const monster = { attack: 5, armor: 0, hp: 45 }; + + expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.MATCH); + }); + + it('rates the seeded Straßenräuber as STRONG', () => { + // Straßenräuber: attack 9, armor 5, maxHp 75 + // power(monster) = 9*4 + 5*2 + floor(75/5) = 36 + 10 + 15 = 61 + // ratio = 61 / 44 = 1.3863636... -> not < 1.0, and < 1.7 -> STRONG + const monster = { attack: 9, armor: 5, hp: 75 }; + + expect(calculateDangerRating(CHARACTER, monster)).toBe( + DangerRating.STRONG, + ); + }); + + it('rates a monster just below the WEAK/MATCH boundary as WEAK', () => { + // Hand-picked monster: attack 4, armor 4, hp 20 + // power(monster) = 4*4 + 4*2 + floor(20/5) = 16 + 8 + 4 = 28 + // ratio = 28 / 44 = 0.6363636... + // WEAK/MATCH boundary is ratio < 0.65, i.e. power < 0.65 * 44 = 28.6. + // 28 is the largest integer power below that boundary -> WEAK. + // (One power higher, 29, is the Aschenratte case above, which is MATCH.) + const monster = { attack: 4, armor: 4, hp: 20 }; + + expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.WEAK); + }); + + it('rates a monster just at the STRONG/VERY_DANGEROUS boundary as VERY_DANGEROUS', () => { + // Hand-picked monster: attack 15, armor 5, hp 25 + // power(monster) = 15*4 + 5*2 + floor(25/5) = 60 + 10 + 5 = 75 + // ratio = 75 / 44 = 1.7045454... + // STRONG/VERY_DANGEROUS boundary is ratio < 1.7, i.e. power < 1.7 * 44 = 74.8. + // 75 is the smallest integer power at/above that boundary -> VERY_DANGEROUS. + // (One power lower, 74, gives ratio 74/44 = 1.6818..., which is STRONG.) + const monster = { attack: 15, armor: 5, hp: 25 }; + + expect(calculateDangerRating(CHARACTER, monster)).toBe( + DangerRating.VERY_DANGEROUS, + ); + }); + + it('rates a monster just at the VERY_DANGEROUS/DEADLY boundary as DEADLY', () => { + // Hand-picked monster: attack 20, armor 10, hp 10 + // power(monster) = 20*4 + 10*2 + floor(10/5) = 80 + 20 + 2 = 102 + // ratio = 102 / 44 = 2.3181818... + // VERY_DANGEROUS/DEADLY boundary is ratio < 2.3, i.e. power < 2.3 * 44 = 101.2. + // 102 is the smallest integer power at/above that boundary -> DEADLY. + // (One power lower, 101, gives ratio 101/44 = 2.2954..., which is VERY_DANGEROUS.) + const monster = { attack: 20, armor: 10, hp: 10 }; + + expect(calculateDangerRating(CHARACTER, monster)).toBe( + DangerRating.DEADLY, + ); + }); +}); + +// Sanity check that CHARACTER_POWER documented above matches the formula. +describe('CHARACTER_POWER sanity check', () => { + it('matches power(CHARACTER) as computed by the same formula', () => { + const power = + CHARACTER.attack * 4 + CHARACTER.armor * 2 + Math.floor(CHARACTER.hp / 5); + + expect(power).toBe(CHARACTER_POWER); + }); +}); diff --git a/apps/api/src/hunting/danger-rating.ts b/apps/api/src/hunting/danger-rating.ts new file mode 100644 index 0000000..bb2d48c --- /dev/null +++ b/apps/api/src/hunting/danger-rating.ts @@ -0,0 +1,31 @@ +export enum DangerRating { + WEAK = 'WEAK', + MATCH = 'MATCH', + STRONG = 'STRONG', + VERY_DANGEROUS = 'VERY_DANGEROUS', + DEADLY = 'DEADLY', +} + +export interface CombatantStats { + attack: number; + armor: number; + hp: number; +} + +// power(entity) = attack*4 + armor*2 + floor(hp/5) — a deliberately small, +// provisional server-side stand-in for a future CombatPower system (none +// exists yet in this codebase). ratio = monsterPower / characterPower. +export function calculateDangerRating( + character: CombatantStats, + monster: CombatantStats, +): DangerRating { + const power = (stats: CombatantStats) => + stats.attack * 4 + stats.armor * 2 + Math.floor(stats.hp / 5); + const ratio = power(monster) / power(character); + + if (ratio < 0.65) return DangerRating.WEAK; + if (ratio < 1.0) return DangerRating.MATCH; + if (ratio < 1.7) return DangerRating.STRONG; + if (ratio < 2.3) return DangerRating.VERY_DANGEROUS; + return DangerRating.DEADLY; +} diff --git a/apps/api/src/hunting/entities/hunt-encounter.entity.ts b/apps/api/src/hunting/entities/hunt-encounter.entity.ts new file mode 100644 index 0000000..2a76fe4 --- /dev/null +++ b/apps/api/src/hunting/entities/hunt-encounter.entity.ts @@ -0,0 +1,39 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; +import { Hunt } from './hunt.entity'; + +@Entity({ name: 'hunt_encounters' }) +export class HuntEncounter { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'hunt_id', type: 'uuid' }) + huntId!: string; + + @Column({ name: 'monster_definition_id', type: 'uuid' }) + monsterDefinitionId!: string; + + @Column({ name: 'position', type: 'integer' }) + position!: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + // CASCADE (unlike the other FKs in this file, which use RESTRICT): a + // HuntEncounter is owned/composed by its parent Hunt and has no + // independent lifecycle, so it should be removed along with its Hunt. + @ManyToOne(() => Hunt, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'hunt_id' }) + hunt!: Hunt; + + @ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'monster_definition_id' }) + monster!: MonsterDefinition; +} diff --git a/apps/api/src/hunting/entities/hunt.entity.ts b/apps/api/src/hunting/entities/hunt.entity.ts new file mode 100644 index 0000000..a4f35a6 --- /dev/null +++ b/apps/api/src/hunting/entities/hunt.entity.ts @@ -0,0 +1,42 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { LocationDefinition } from '../../world/entities/location-definition.entity'; +import { HuntStatus } from '../hunt-status.enum'; + +@Entity({ name: 'hunts' }) +export class Hunt { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'character_id', type: 'uuid' }) + characterId!: string; + + @Column({ name: 'location_id', type: 'uuid' }) + locationId!: string; + + @Column({ + name: 'status', + type: 'enum', + enum: HuntStatus, + enumName: 'hunt_status_enum', + }) + status!: HuntStatus; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @ManyToOne(() => Character, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'character_id' }) + character!: Character; + + @ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'location_id' }) + location!: LocationDefinition; +} diff --git a/apps/api/src/hunting/hunt-status.enum.ts b/apps/api/src/hunting/hunt-status.enum.ts new file mode 100644 index 0000000..93ad3d5 --- /dev/null +++ b/apps/api/src/hunting/hunt-status.enum.ts @@ -0,0 +1,4 @@ +export enum HuntStatus { + ACTIVE = 'ACTIVE', + SUPERSEDED = 'SUPERSEDED', +} diff --git a/apps/api/src/hunting/random-source.ts b/apps/api/src/hunting/random-source.ts new file mode 100644 index 0000000..e76b468 --- /dev/null +++ b/apps/api/src/hunting/random-source.ts @@ -0,0 +1,9 @@ +export interface RandomSource { + next(): number; // uniform value in [0, 1) +} + +export const RANDOM_SOURCE = Symbol('RANDOM_SOURCE'); + +export const systemRandomSource: RandomSource = { + next: () => Math.random(), +}; diff --git a/apps/api/src/monsters/entities/encounter-type.enum.ts b/apps/api/src/monsters/entities/encounter-type.enum.ts new file mode 100644 index 0000000..3192328 --- /dev/null +++ b/apps/api/src/monsters/entities/encounter-type.enum.ts @@ -0,0 +1,6 @@ +export enum EncounterType { + NORMAL = 'NORMAL', + RARE = 'RARE', + ELITE = 'ELITE', + BOSS = 'BOSS', +} diff --git a/apps/api/src/monsters/entities/location-monster.entity.ts b/apps/api/src/monsters/entities/location-monster.entity.ts new file mode 100644 index 0000000..a2ff09e --- /dev/null +++ b/apps/api/src/monsters/entities/location-monster.entity.ts @@ -0,0 +1,45 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { LocationDefinition } from '../../world/entities/location-definition.entity'; +import { EncounterType } from './encounter-type.enum'; +import { MonsterDefinition } from './monster-definition.entity'; + +@Entity({ name: 'location_monsters' }) +export class LocationMonster { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'location_id', type: 'uuid' }) + locationId!: string; + + @Column({ name: 'monster_id', type: 'uuid' }) + monsterId!: string; + + @Column({ name: 'weight', type: 'integer' }) + weight!: number; + + @Column({ + name: 'encounter_type', + type: 'enum', + enum: EncounterType, + enumName: 'location_monster_encounter_type_enum', + default: EncounterType.NORMAL, + }) + encounterType!: EncounterType; + + @Column({ name: 'enabled', type: 'boolean', default: true }) + enabled!: boolean; + + @ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'location_id' }) + location!: LocationDefinition; + + @ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'monster_id' }) + monster!: MonsterDefinition; +} diff --git a/apps/api/src/monsters/entities/monster-definition.entity.ts b/apps/api/src/monsters/entities/monster-definition.entity.ts new file mode 100644 index 0000000..2b953ee --- /dev/null +++ b/apps/api/src/monsters/entities/monster-definition.entity.ts @@ -0,0 +1,51 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity({ name: 'monster_definitions' }) +@Index('IDX_monster_definitions_key', ['key'], { unique: true }) +export class MonsterDefinition { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'key', type: 'varchar', length: 100 }) + key!: string; + + @Column({ name: 'name', type: 'varchar', length: 150 }) + name!: string; + + @Column({ name: 'level', type: 'integer' }) + level!: number; + + @Column({ name: 'max_hp', type: 'integer' }) + maxHp!: number; + + @Column({ name: 'attack', type: 'integer' }) + attack!: number; + + @Column({ name: 'armor', type: 'integer' }) + armor!: number; + + @Column({ name: 'experience_reward', type: 'integer' }) + experienceReward!: number; + + @Column({ name: 'silver_min', type: 'integer' }) + silverMin!: number; + + @Column({ name: 'silver_max', type: 'integer' }) + silverMax!: number; + + @Column({ name: 'artwork_path', type: 'varchar', length: 255 }) + artworkPath!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +}