feat: add hunt/monster domain entities and pure danger-rating helper
Adds Task 1 of Playable Slice 0.2: pure entity/enum/helper definitions for the Hunt/Encounter system (MonsterDefinition, LocationMonster, Hunt, HuntEncounter, EncounterType, HuntStatus, RandomSource, DangerRating). No DB migration, module wiring, or seed data yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
78
apps/api/src/hunting/danger-rating.spec.ts
Normal file
78
apps/api/src/hunting/danger-rating.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
apps/api/src/hunting/danger-rating.ts
Normal file
31
apps/api/src/hunting/danger-rating.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
39
apps/api/src/hunting/entities/hunt-encounter.entity.ts
Normal file
39
apps/api/src/hunting/entities/hunt-encounter.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
42
apps/api/src/hunting/entities/hunt.entity.ts
Normal file
42
apps/api/src/hunting/entities/hunt.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
4
apps/api/src/hunting/hunt-status.enum.ts
Normal file
4
apps/api/src/hunting/hunt-status.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum HuntStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
SUPERSEDED = 'SUPERSEDED',
|
||||||
|
}
|
||||||
9
apps/api/src/hunting/random-source.ts
Normal file
9
apps/api/src/hunting/random-source.ts
Normal file
@@ -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(),
|
||||||
|
};
|
||||||
6
apps/api/src/monsters/entities/encounter-type.enum.ts
Normal file
6
apps/api/src/monsters/entities/encounter-type.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export enum EncounterType {
|
||||||
|
NORMAL = 'NORMAL',
|
||||||
|
RARE = 'RARE',
|
||||||
|
ELITE = 'ELITE',
|
||||||
|
BOSS = 'BOSS',
|
||||||
|
}
|
||||||
45
apps/api/src/monsters/entities/location-monster.entity.ts
Normal file
45
apps/api/src/monsters/entities/location-monster.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
51
apps/api/src/monsters/entities/monster-definition.entity.ts
Normal file
51
apps/api/src/monsters/entities/monster-definition.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user