feat(story2): level-based damage modifiers — diff method, 50% scaling for level gaps, transcripts

This commit is contained in:
Willem van den Ende 2026-06-13 16:08:50 +01:00
parent 39983a30c9
commit 38ea78f91e
10 changed files with 30890 additions and 4 deletions

View File

@ -0,0 +1,44 @@
-- allium: 3
-- allium: healing
------------------------------------------------------------
-- Entities and Variants
------------------------------------------------------------
entity Character {
name: String
health: Health
status: alive | dead
level: Level
factions: Set<Faction>
}
------------------------------------------------------------
-- 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, _)
}

View File

@ -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),

View File

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

122
src/healing.spec.ts Normal file
View File

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

44
src/levels.allium Normal file
View File

@ -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
}

181
src/levels.spec.ts Normal file
View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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