feat(story2): level-based damage modifiers — diff method, 50% scaling for level gaps, transcripts
This commit is contained in:
@@ -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, _)
|
||||
}
|
||||
+12
-2
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user