feat(story2): level-based damage modifiers — diff method, 50% scaling for level gaps, transcripts
This commit is contained in:
parent
39983a30c9
commit
38ea78f91e
44
.pi/specs/story-3-healing.allium
Normal file
44
.pi/specs/story-3-healing.allium
Normal 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, _)
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
122
src/healing.spec.ts
Normal file
122
src/healing.spec.ts
Normal 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
44
src/levels.allium
Normal 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
181
src/levels.spec.ts
Normal 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;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
13112
transcripts/forgot-to-mention-the-story.html
Normal file
13112
transcripts/forgot-to-mention-the-story.html
Normal file
File diff suppressed because one or more lines are too long
4256
transcripts/story-2-(re?)-done.html
Normal file
4256
transcripts/story-2-(re?)-done.html
Normal file
File diff suppressed because one or more lines are too long
13112
transcripts/story-2-refactored.html
Normal file
13112
transcripts/story-2-refactored.html
Normal file
File diff suppressed because one or more lines are too long
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user