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:
Bastian Wagner
2026-08-19 11:41:09 +02:00
parent 5b662aad4c
commit 0c2f079a6e
9 changed files with 305 additions and 0 deletions

View 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);
});
});

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

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

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

View File

@@ -0,0 +1,4 @@
export enum HuntStatus {
ACTIVE = 'ACTIVE',
SUPERSEDED = 'SUPERSEDED',
}

View 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(),
};

View File

@@ -0,0 +1,6 @@
export enum EncounterType {
NORMAL = 'NORMAL',
RARE = 'RARE',
ELITE = 'ELITE',
BOSS = 'BOSS',
}

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

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