From 38ea78f91edb95721159d794e8ad9f979cb56e59 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Sat, 13 Jun 2026 16:08:50 +0100 Subject: [PATCH] =?UTF-8?q?feat(story2):=20level-based=20damage=20modifier?= =?UTF-8?q?s=20=E2=80=94=20diff=20method,=2050%=20scaling=20for=20level=20?= =?UTF-8?q?gaps,=20transcripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pi/specs/story-3-healing.allium | 44 + src/Character.ts | 14 +- src/Level.ts | 5 + src/healing.spec.ts | 122 + src/levels.allium | 44 + src/levels.spec.ts | 181 + transcripts/forgot-to-mention-the-story.html | 13112 +++++++++++++++++ transcripts/story-2-(re?)-done.html | 4256 ++++++ transcripts/story-2-refactored.html | 13112 +++++++++++++++++ user-stories.md | 4 +- 10 files changed, 30890 insertions(+), 4 deletions(-) create mode 100644 .pi/specs/story-3-healing.allium create mode 100644 src/healing.spec.ts create mode 100644 src/levels.allium create mode 100644 src/levels.spec.ts create mode 100644 transcripts/forgot-to-mention-the-story.html create mode 100644 transcripts/story-2-(re?)-done.html create mode 100644 transcripts/story-2-refactored.html diff --git a/.pi/specs/story-3-healing.allium b/.pi/specs/story-3-healing.allium new file mode 100644 index 0000000..5e8473a --- /dev/null +++ b/.pi/specs/story-3-healing.allium @@ -0,0 +1,44 @@ +-- allium: 3 + +-- allium: healing + +------------------------------------------------------------ +-- Entities and Variants +------------------------------------------------------------ + +entity Character { + name: String + health: Health + status: alive | dead + level: Level + factions: Set +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule SelfHealIncreasesHealth { + when: CharacterHealsSelf(character, amount) + requires: character.status = alive + ensures: character.health = min(character.health + amount, maxHealthForLevel(character.level)) +} + +------------------------------------------------------------ +-- Invariants +------------------------------------------------------------ + +invariant HealthNonNegative { + for c in Characters: + c.health >= 0 +} + +invariant HealthNeverExceedsLevelCap { + for c in Characters: + c.health <= maxHealthForLevel(c.level) +} + +invariant DeadCannotHeal { + for c in Characters: + c.status = dead implies not CharacterHealsSelf(c, _) +} diff --git a/src/Character.ts b/src/Character.ts index c97ace8..c3b2a8c 100644 --- a/src/Character.ts +++ b/src/Character.ts @@ -74,8 +74,18 @@ export class Character { if (target.status.kind === 'dead') return target; // Negative damage is invalid if (damage < 0) throw new Error(`Damage must be non-negative, got ${damage}`); - // Reduce health - const newHealth = target.health.sub(damage); + // Level-based damage modifier + const levelDiff = this.level.diff(target.level); // = this.level - target.level + let actualDamage = damage; + if (levelDiff <= -5) { + // Target is ≥5 levels above → damage reduced by 50% + actualDamage = Math.floor(damage * 0.5); + } else if (levelDiff >= 5) { + // Target is ≥5 levels below → damage increased by 50% + actualDamage = Math.floor(damage * 1.5); + } + // Reduce health by the (possibly modified) damage amount + const newHealth = target.health.sub(actualDamage); const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive; return new Character( new CharacterState(target.name, newHealth, newStatus, target.level, target.factions), diff --git a/src/Level.ts b/src/Level.ts index 40b8957..e1d0faf 100644 --- a/src/Level.ts +++ b/src/Level.ts @@ -26,4 +26,9 @@ export class Level { static maxHealthForLevel(level: number): number { return level >= 6 ? 1500 : 1000; } + + /** Signed level difference: this level minus target level. Positive = this is higher. */ + diff(target: Level): number { + return this.value - target.value; + } } diff --git a/src/healing.spec.ts b/src/healing.spec.ts new file mode 100644 index 0000000..05a5169 --- /dev/null +++ b/src/healing.spec.ts @@ -0,0 +1,122 @@ +import fc from 'fast-check'; +import { describe, it } from 'vitest'; +import { Character } from './Character.ts'; +import { Level } from './Level.ts'; + +describe('Healing', () => { + describe('SelfHealIncreasesHealth', () => { + it('property: healing increases health by the heal amount (when below cap)', () => { + fc.assert( + fc.property( + fc.integer({ min: 0, max: 999 }), + fc.integer({ min: 1, max: 500 }), + (health, healAmount) => { + // Constrain: ensure health + healAmount <= 1000 (level 1 cap) + fc.pre(health + healAmount <= 1000); + const c = Character.createWithHealth({ + name: 'hero', + level: Level.create(1), + health, + }); + const result = c.healSelf(healAmount); + return result.health.value === health + healAmount; + }, + ), + ); + }); + + it('property: healing is capped at max health for level', () => { + fc.assert( + fc.property( + fc.integer({ min: 900, max: 1000 }), + fc.integer({ min: 1, max: 500 }), + (health, healAmount) => { + const c = Character.createWithHealth({ + name: 'hero', + level: Level.create(1), + health, + }); + const result = c.healSelf(healAmount); + const maxForLevel = Level.maxHealthForLevel(c.level.value); + return result.health.value === Math.min(health + healAmount, maxForLevel); + }, + ), + ); + }); + + it('property: healing at level 6+ is capped at 1500', () => { + fc.assert( + fc.property( + fc.integer({ min: 1400, max: 1500 }), + fc.integer({ min: 1, max: 500 }), + (health, healAmount) => { + // Constrain: ensure health + healAmount > 1500 + fc.pre(health + healAmount > 1500); + const c = Character.createWithHealth({ + name: 'hero', + level: Level.create(6), + health, + }); + const result = c.healSelf(healAmount); + return result.health.value === 1500; + }, + ), + ); + }); + }); + + describe('ZeroHeal', () => { + it('property: zero heal changes nothing', () => { + fc.assert( + fc.property(fc.integer({ min: 0, max: 1000 }), (health) => { + const c = Character.createWithHealth({ + name: 'hero', + level: Level.create(1), + health, + }); + const result = c.healSelf(0); + return result.health.value === health && result.status.kind === 'alive'; + }), + ); + }); + }); + + describe('DeadCannotHeal', () => { + it('property: dead characters cannot heal — returns same reference, state unchanged', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 500 }), (healAmount) => { + const attacker = Character.create({ name: 'attacker', level: Level.create(1) }); + const hero = Character.create({ name: 'hero', level: Level.create(1) }); + // Kill the hero first using a different attacker + const deadHero = attacker.dealDamage(hero, 10000); + const healthBefore = deadHero.health.value; + const statusBefore = deadHero.status.kind; + // Try to heal the dead character + const result = deadHero.healSelf(healAmount); + return ( + result === deadHero && + result.health.value === healthBefore && + result.status.kind === statusBefore + ); + }), + ); + }); + }); + + describe('NegativeHealForbidden', () => { + it('property: negative heal amount throws an error', () => { + fc.assert( + fc.property(fc.integer({ min: -10000, max: -1 }), (negativeHeal) => { + const c = Character.create({ name: 'hero', level: Level.create(1) }); + let threw = false; + try { + c.healSelf(negativeHeal); + } catch { + threw = true; + } + return threw; + }), + ); + }); + }); +}); diff --git a/src/levels.allium b/src/levels.allium new file mode 100644 index 0000000..ec3be92 --- /dev/null +++ b/src/levels.allium @@ -0,0 +1,44 @@ +-- allium: 3 + +-- allium: levels + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule LevelDiff { + for attacker in Characters, target in Characters: + diff = target.level - attacker.level +} + +rule HighLevelTargetModifier { + when: CharacterDealsDamage(attacker, target, baseDamage) + requires: target.level - attacker.level >= 5 + ensures: actualDamage = floor(baseDamage * 0.5) +} + +rule LowLevelTargetModifier { + when: CharacterDealsDamage(attacker, target, baseDamage) + requires: attacker.level - target.level >= 5 + ensures: actualDamage = floor(baseDamage * 1.5) +} + +rule CloseLevelNoModifier { + when: CharacterDealsDamage(attacker, target, baseDamage) + requires: abs(target.level - attacker.level) < 5 + ensures: actualDamage = baseDamage +} + +------------------------------------------------------------ +-- Invariants +------------------------------------------------------------ + +invariant DamageModifierComputationComplete { + for a in Characters, t in Characters, d in NonNegativeIntegers: + let diff = t.level - a.level + let actualDamage = + if diff >= 5 then floor(d * 0.5) + else if diff <= -5 then floor(d * 1.5) + else d + a.dealDamage(t, d) implies t.health = old(t.health) - actualDamage +} diff --git a/src/levels.spec.ts b/src/levels.spec.ts new file mode 100644 index 0000000..3ae288f --- /dev/null +++ b/src/levels.spec.ts @@ -0,0 +1,181 @@ +import fc from 'fast-check'; +import { describe, it } from 'vitest'; +import { Character } from './Character.ts'; +import { Level } from './Level.ts'; + +describe('Levels', () => { + describe('CloseLevelNoModifier', () => { + it('property: level gap < 5 → no modifier applied', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 10 }), + fc.integer({ min: 1, max: 10 }), + fc.integer({ min: 0, max: 10000 }), + (attackerLevel, targetLevel, baseDamage) => { + const diff = Math.abs(attackerLevel - targetLevel); + if (diff >= 5) return true; // skip non-applicable cases + + const attacker = Character.create({ name: 'a', level: Level.create(attackerLevel) }); + const target = Character.createWithHealth({ + name: 't', + level: Level.create(targetLevel), + health: 1000, + }); + const result = attacker.dealDamage(target, baseDamage); + return result.health.value === Math.max(0, 1000 - baseDamage); + }, + ), + ); + }); + }); + + describe('HighLevelTargetModifier', () => { + it('property: target ≥5 levels above → damage halved (floored)', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 5 }), + fc.integer({ min: 6, max: 10 }), + fc.integer({ min: 0, max: 10000 }), + (attackerLevel, targetLevel, baseDamage) => { + const diff = targetLevel - attackerLevel; + if (diff < 5) return true; // skip non-applicable cases + + const attacker = Character.create({ name: 'a', level: Level.create(attackerLevel) }); + const target = Character.createWithHealth({ + name: 't', + level: Level.create(targetLevel), + health: 1000, + }); + const expectedDamage = Math.floor(baseDamage * 0.5); + const result = attacker.dealDamage(target, baseDamage); + return result.health.value === Math.max(0, 1000 - expectedDamage); + }, + ), + ); + }); + + it('property: odd damage halved floors correctly', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 9999 }), (oddDamage) => { + if (oddDamage % 2 === 0) return true; // only odd values + + const attacker = Character.create({ name: 'a', level: Level.create(1) }); + const target = Character.createWithHealth({ + name: 't', + level: Level.create(6), + health: 1000, + }); + const expectedDamage = Math.floor(oddDamage * 0.5); + const result = attacker.dealDamage(target, oddDamage); + return result.health.value === Math.max(0, 1000 - expectedDamage); + }), + ); + }); + }); + + describe('LowLevelTargetModifier', () => { + it('property: target ≥5 levels below → damage ×1.5 (floored)', () => { + fc.assert( + fc.property( + fc.integer({ min: 6, max: 10 }), + fc.integer({ min: 1, max: 5 }), + fc.integer({ min: 0, max: 10000 }), + (attackerLevel, targetLevel, baseDamage) => { + const diff = attackerLevel - targetLevel; + if (diff < 5) return true; // skip non-applicable cases + + const attacker = Character.create({ name: 'a', level: Level.create(attackerLevel) }); + const target = Character.createWithHealth({ + name: 't', + level: Level.create(targetLevel), + health: 1000, + }); + const expectedDamage = Math.floor(baseDamage * 1.5); + const result = attacker.dealDamage(target, baseDamage); + return result.health.value === Math.max(0, 1000 - expectedDamage); + }, + ), + ); + }); + }); + + describe('LevelDiffBoundary', () => { + it('property: exactly 5 levels above → modifier applies (halved)', () => { + fc.assert( + fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => { + // Level 1 vs Level 6 → diff = -5 + const attacker = Character.create({ name: 'a', level: Level.create(1) }); + const target = Character.createWithHealth({ + name: 't', + level: Level.create(6), + health: 1000, + }); + const expectedDamage = Math.floor(damage * 0.5); + const result = attacker.dealDamage(target, damage); + return result.health.value === Math.max(0, 1000 - expectedDamage); + }), + ); + }); + + it('property: exactly 5 levels below → modifier applies (×1.5)', () => { + fc.assert( + fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => { + // Level 6 vs Level 1 → diff = 5 + const attacker = Character.create({ name: 'a', level: Level.create(6) }); + const target = Character.createWithHealth({ + name: 't', + level: Level.create(1), + health: 1000, + }); + const expectedDamage = Math.floor(damage * 1.5); + const result = attacker.dealDamage(target, damage); + return result.health.value === Math.max(0, 1000 - expectedDamage); + }), + ); + }); + + it('property: 4 levels → no modifier', () => { + fc.assert( + fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => { + // Level 1 vs Level 5 → diff = 4 + const attacker = Character.create({ name: 'a', level: Level.create(1) }); + const target = Character.createWithHealth({ + name: 't', + level: Level.create(5), + health: 1000, + }); + const result = attacker.dealDamage(target, damage); + return result.health.value === Math.max(0, 1000 - damage); + }), + ); + }); + + it('property: 4 levels below → no modifier', () => { + fc.assert( + fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => { + // Level 5 vs Level 1 → diff = 4 + const attacker = Character.create({ name: 'a', level: Level.create(5) }); + const target = Character.createWithHealth({ + name: 't', + level: Level.create(1), + health: 1000, + }); + const result = attacker.dealDamage(target, damage); + return result.health.value === Math.max(0, 1000 - damage); + }), + ); + }); + }); + + describe('LevelDiff', () => { + it('property: diff(target) = this.value - target.value', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 10 }), fc.integer({ min: 1, max: 10 }), (a, b) => { + const levelA = Level.create(a); + const levelB = Level.create(b); + return levelA.diff(levelB) === a - b; + }), + ); + }); + }); +}); diff --git a/transcripts/forgot-to-mention-the-story.html b/transcripts/forgot-to-mention-the-story.html new file mode 100644 index 0000000..5c6cee1 --- /dev/null +++ b/transcripts/forgot-to-mention-the-story.html @@ -0,0 +1,13112 @@ + + + + + + Session Export + + + + + +
+ + +
+
+
+
+
+ +
+
+ + + + + + + + + + + + + diff --git a/transcripts/story-2-(re?)-done.html b/transcripts/story-2-(re?)-done.html new file mode 100644 index 0000000..4558cfa --- /dev/null +++ b/transcripts/story-2-(re?)-done.html @@ -0,0 +1,4256 @@ + + + + + + Session Export + + + + + +
+ + +
+
+
+
+
+ +
+
+ + + + + + + + + + + + + diff --git a/transcripts/story-2-refactored.html b/transcripts/story-2-refactored.html new file mode 100644 index 0000000..e5c8371 --- /dev/null +++ b/transcripts/story-2-refactored.html @@ -0,0 +1,13112 @@ + + + + + + Session Export + + + + + +
+ + +
+
+
+
+
+ +
+
+ + + + + + + + + + + + + diff --git a/user-stories.md b/user-stories.md index bd08c21..b2db9c1 100644 --- a/user-stories.md +++ b/user-stories.md @@ -2,7 +2,7 @@ This is a description of the business rules we should support in the game engine. -## Damage and Health +## Damage and Health ✅ DONE 1. All Characters, when created, have: - Health, starting at 1000 @@ -16,7 +16,7 @@ This is a description of the business rules we should support in the game engine 3. A Character can Heal themselves. - Dead characters cannot heal -## Levels +## Levels ✅ DONE 1. All characters have a Level, starting at 1 - A Character cannot have a health above 1000 until they reach level 6, when the maximum increases to 1500