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