Compare commits
3 Commits
fb61fb85ed
...
39839dc594
| Author | SHA1 | Date | |
|---|---|---|---|
| 39839dc594 | |||
| 692bd7305b | |||
| 1c9d4ad66b |
@ -6,9 +6,22 @@
|
|||||||
-- Value Types
|
-- Value Types
|
||||||
------------------------------------------------------------
|
------------------------------------------------------------
|
||||||
|
|
||||||
type Faction {
|
value Faction {
|
||||||
name: String
|
name: String
|
||||||
requires: trimmed(name).length > 0
|
}
|
||||||
|
|
||||||
|
value Health {
|
||||||
|
value: Integer
|
||||||
|
requires: value >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
value Level {
|
||||||
|
value: Integer
|
||||||
|
requires: value >= 1 and value <= 10
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Status {
|
||||||
|
alive | dead
|
||||||
}
|
}
|
||||||
|
|
||||||
------------------------------------------------------------
|
------------------------------------------------------------
|
||||||
@ -18,7 +31,7 @@ type Faction {
|
|||||||
entity Character {
|
entity Character {
|
||||||
name: String
|
name: String
|
||||||
health: Health
|
health: Health
|
||||||
status: alive | dead
|
status: Status
|
||||||
level: Level
|
level: Level
|
||||||
factions: Set<Faction>
|
factions: Set<Faction>
|
||||||
}
|
}
|
||||||
@ -83,12 +96,7 @@ rule DeadCannotLeaveFaction {
|
|||||||
invariant FactionsAlwaysValid {
|
invariant FactionsAlwaysValid {
|
||||||
for c in Characters:
|
for c in Characters:
|
||||||
for f in c.factions:
|
for f in c.factions:
|
||||||
f.name.trim().length > 0
|
f.name.length > 0
|
||||||
}
|
|
||||||
|
|
||||||
invariant AllyRelationIsSymmetric {
|
|
||||||
for a in Characters, b in Characters:
|
|
||||||
a.isAllyOf(b) implies b.isAllyOf(a)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant SelfNotAlly {
|
invariant SelfNotAlly {
|
||||||
|
|||||||
183
specs/magical-objects.allium
Normal file
183
specs/magical-objects.allium
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
-- allium: 3
|
||||||
|
-- allium: magical-objects
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Value Types
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
value Health {
|
||||||
|
value: Integer
|
||||||
|
requires: value >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
value Faction {
|
||||||
|
name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
value Level {
|
||||||
|
value: Integer
|
||||||
|
requires: value >= 1 and value <= 10
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Enumerations
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
enum Status {
|
||||||
|
alive | destroyed
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Entities
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
entity Character {
|
||||||
|
name: String
|
||||||
|
health: Health
|
||||||
|
status: Status
|
||||||
|
level: Level
|
||||||
|
factions: Set<Faction>
|
||||||
|
}
|
||||||
|
|
||||||
|
entity MagicalObject {
|
||||||
|
health: Health
|
||||||
|
maxHealth: Integer
|
||||||
|
status: Status
|
||||||
|
}
|
||||||
|
|
||||||
|
entity HealingObject {
|
||||||
|
health: Health
|
||||||
|
maxHealth: Integer
|
||||||
|
status: Status
|
||||||
|
}
|
||||||
|
|
||||||
|
entity MagicalWeapon {
|
||||||
|
health: Health
|
||||||
|
maxHealth: Integer
|
||||||
|
status: Status
|
||||||
|
damage: Integer
|
||||||
|
owner: Character
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Rules
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
rule HealingObjectHealsCharacter {
|
||||||
|
when: CharacterUsesHealingObject(character, object, amount)
|
||||||
|
|
||||||
|
requires: object.status = alive
|
||||||
|
requires: character.status = alive
|
||||||
|
requires: amount >= 0
|
||||||
|
|
||||||
|
ensures:
|
||||||
|
character.health.value = character.health.value + min(amount, object.health.value, Level.maxHealthForLevel(character.level) - character.health.value)
|
||||||
|
object.health.value = object.health.value - min(amount, object.health.value, Level.maxHealthForLevel(character.level) - character.health.value)
|
||||||
|
if object.health.value = 0:
|
||||||
|
object.status = destroyed
|
||||||
|
else:
|
||||||
|
object.status = alive
|
||||||
|
}
|
||||||
|
|
||||||
|
rule HealingObjectDestroyedCannotHeal {
|
||||||
|
when: CharacterUsesHealingObject(character, object, amount)
|
||||||
|
|
||||||
|
requires: object.status = destroyed
|
||||||
|
|
||||||
|
ensures:
|
||||||
|
character.health.value = character.health.value
|
||||||
|
object.health.value = object.health.value
|
||||||
|
}
|
||||||
|
|
||||||
|
rule DeadCannotUseHealingObject {
|
||||||
|
when: CharacterUsesHealingObject(character, object, amount)
|
||||||
|
|
||||||
|
requires: character.status = dead
|
||||||
|
|
||||||
|
ensures:
|
||||||
|
character.health.value = character.health.value
|
||||||
|
object.health.value = object.health.value
|
||||||
|
}
|
||||||
|
|
||||||
|
rule HealingObjectZeroHealIsNoOp {
|
||||||
|
when: CharacterUsesHealingObject(character, object, amount)
|
||||||
|
|
||||||
|
requires: object.status = alive
|
||||||
|
requires: character.status = alive
|
||||||
|
requires: amount >= 0
|
||||||
|
requires: min(amount, object.health.value, Level.maxHealthForLevel(character.level) - character.health.value) = 0
|
||||||
|
|
||||||
|
ensures:
|
||||||
|
character.health.value = character.health.value
|
||||||
|
object.health.value = object.health.value
|
||||||
|
}
|
||||||
|
|
||||||
|
rule MagicalWeaponDealsDamage {
|
||||||
|
when: CharacterUsesWeapon(owner, weapon, target)
|
||||||
|
|
||||||
|
requires: weapon.status = alive
|
||||||
|
requires: owner.status = alive
|
||||||
|
requires: owner = weapon.owner
|
||||||
|
|
||||||
|
ensures:
|
||||||
|
target.health.value = max(0, target.health.value - weapon.damage)
|
||||||
|
weapon.health.value = weapon.health.value - 1
|
||||||
|
if weapon.health.value = 0:
|
||||||
|
weapon.status = destroyed
|
||||||
|
else:
|
||||||
|
weapon.status = alive
|
||||||
|
}
|
||||||
|
|
||||||
|
rule DeadCannotUseWeapon {
|
||||||
|
when: CharacterUsesWeapon(owner, weapon, target)
|
||||||
|
|
||||||
|
requires: owner.status = dead
|
||||||
|
|
||||||
|
ensures:
|
||||||
|
target.health.value = target.health.value
|
||||||
|
weapon.health.value = weapon.health.value
|
||||||
|
}
|
||||||
|
|
||||||
|
rule NonOwnerCannotUseWeapon {
|
||||||
|
when: CharacterUsesWeapon(thief, weapon, target)
|
||||||
|
|
||||||
|
requires: thief != weapon.owner
|
||||||
|
|
||||||
|
ensures:
|
||||||
|
target.health.value = target.health.value
|
||||||
|
weapon.health.value = weapon.health.value
|
||||||
|
}
|
||||||
|
|
||||||
|
rule WeaponDestroyedCannotDealDamage {
|
||||||
|
when: CharacterUsesWeapon(owner, weapon, target)
|
||||||
|
|
||||||
|
requires: weapon.status = destroyed
|
||||||
|
|
||||||
|
ensures:
|
||||||
|
target.health.value = target.health.value
|
||||||
|
weapon.health.value = weapon.health.value
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Invariants
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
invariant MagicalObjectHealthNonNegative {
|
||||||
|
for m in MagicalObjects:
|
||||||
|
m.health.value >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
invariant MagicalObjectHealthNeverExceedsMax {
|
||||||
|
for m in MagicalObjects:
|
||||||
|
m.health.value <= m.maxHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
invariant MagicalObjectDestroyedAtZeroHealth {
|
||||||
|
for m in MagicalObjects:
|
||||||
|
m.health.value = 0 implies m.status = destroyed
|
||||||
|
}
|
||||||
|
|
||||||
|
invariant MagicalObjectAliveAtPositiveHealth {
|
||||||
|
for m in MagicalObjects:
|
||||||
|
m.health.value > 0 implies m.status = alive
|
||||||
|
}
|
||||||
120
src/changing-level.allium
Normal file
120
src/changing-level.allium
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
-- allium: 3
|
||||||
|
|
||||||
|
-- allium: changing-level
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Value Types
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
value Faction {
|
||||||
|
name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
value Health {
|
||||||
|
value: Integer
|
||||||
|
requires: value >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
value Level {
|
||||||
|
value: Integer
|
||||||
|
requires: value >= 1 and value <= 10
|
||||||
|
}
|
||||||
|
|
||||||
|
value Damage {
|
||||||
|
value: Integer
|
||||||
|
requires: value >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Enumerations
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
enum Status {
|
||||||
|
alive | dead
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Entities
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
entity Character {
|
||||||
|
name: String
|
||||||
|
health: Health
|
||||||
|
status: Status
|
||||||
|
level: Level
|
||||||
|
factions: Set<Faction>
|
||||||
|
totalDamageTaken: Damage
|
||||||
|
factionsJoined: Set<Faction>
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Rules
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
rule DamageIsAccumulated {
|
||||||
|
when: Character.dealDamage(attacker, target, damage)
|
||||||
|
requires: target.status = alive
|
||||||
|
ensures: target.totalDamageTaken.value = old(target.totalDamageTaken.value) + damage.value
|
||||||
|
}
|
||||||
|
|
||||||
|
rule LevelUpFromDamage {
|
||||||
|
when: Character.dealDamage(attacker, target, damage)
|
||||||
|
requires: target.status = alive
|
||||||
|
requires: old(target.totalDamageTaken.value) + damage.value >= 1000 * (target.level.value + 1) * (target.level.value + 2) / 2
|
||||||
|
ensures: target.level.value = target.level.value + 1
|
||||||
|
ensures: target.totalDamageTaken.value = old(target.totalDamageTaken.value) + damage.value
|
||||||
|
}
|
||||||
|
|
||||||
|
rule LevelUpFromFaction {
|
||||||
|
when: Character.joinFaction(character, faction)
|
||||||
|
requires: character.status = alive
|
||||||
|
requires: old(character.factionsJoined.size) + 1 >= 3 * (character.level.value + 1)
|
||||||
|
ensures: character.level.value = character.level.value + 1
|
||||||
|
ensures: character.factionsJoined = old(character.factionsJoined) + {faction}
|
||||||
|
}
|
||||||
|
|
||||||
|
rule MaxLevelCappedOnDamage {
|
||||||
|
when: Character.dealDamage(attacker, target, damage)
|
||||||
|
requires: target.status = alive
|
||||||
|
requires: target.level.value = 10
|
||||||
|
ensures: target.level.value = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
rule MaxLevelCappedOnFaction {
|
||||||
|
when: Character.joinFaction(character, faction)
|
||||||
|
requires: character.status = alive
|
||||||
|
requires: character.level.value = 10
|
||||||
|
ensures: character.level.value = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
rule DeadCannotLevelUpFromDamage {
|
||||||
|
when: Character.dealDamage(attacker, target, damage)
|
||||||
|
requires: target.status = dead
|
||||||
|
ensures: target.level.value = old(target.level.value)
|
||||||
|
ensures: target.totalDamageTaken.value = old(target.totalDamageTaken.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule DeadCannotLevelUpFromFaction {
|
||||||
|
when: Character.joinFaction(character, faction)
|
||||||
|
requires: character.status = dead
|
||||||
|
ensures: character.level.value = old(character.level.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Invariants
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
invariant LevelBounded {
|
||||||
|
for c in Characters:
|
||||||
|
c.level.value >= 1 and c.level.value <= 10
|
||||||
|
}
|
||||||
|
|
||||||
|
invariant DamageTotalNonNegative {
|
||||||
|
for c in Characters:
|
||||||
|
c.totalDamageTaken.value >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
invariant FactionsJoinedNonNegative {
|
||||||
|
for c in Characters:
|
||||||
|
c.factionsJoined.size >= 0
|
||||||
|
}
|
||||||
131
src/changing-level.spec.ts
Normal file
131
src/changing-level.spec.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import fc from 'fast-check';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Character } from './characters/Character.ts';
|
||||||
|
import { Level } from './value-objects/Level.ts';
|
||||||
|
import { Damage } from './value-objects/Damage.ts';
|
||||||
|
import { Faction } from './factions/Faction.ts';
|
||||||
|
|
||||||
|
describe('ChangingLevel', () => {
|
||||||
|
describe('MaxLevelCappedOnDamage (example)', () => {
|
||||||
|
it('L10 takes 99999 damage → stays L10', () => {
|
||||||
|
const attacker = Character.create({ name: 'dragons', level: Level.create(1) });
|
||||||
|
const target = Character.create({ name: 'hero', level: Level.create(10) });
|
||||||
|
const result = attacker.dealDamage(target, Damage.create(99999));
|
||||||
|
expect(result.level.value).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MaxLevelCappedOnFaction (example)', () => {
|
||||||
|
it('L10 joins 100 factions → stays L10', () => {
|
||||||
|
const hero = Character.create({ name: 'hero', level: Level.create(10) });
|
||||||
|
let char = hero;
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
char = char.joinFaction(Faction.create(`faction-${i}`));
|
||||||
|
}
|
||||||
|
expect(char.level.value).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeadCannotLevelUpFromDamage (example)', () => {
|
||||||
|
it('dead char takes 1000 damage → stays same level', () => {
|
||||||
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||||
|
const target = Character.create({ name: 'hero', level: Level.create(1) });
|
||||||
|
const dead = attacker.dealDamage(target, Damage.create(10000));
|
||||||
|
const result = attacker.dealDamage(dead, Damage.create(1000));
|
||||||
|
expect(result.level.value).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeadCannotLevelUpFromFaction (example)', () => {
|
||||||
|
it('dead char joins 5 factions → stays same level', () => {
|
||||||
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||||
|
const target = Character.create({ name: 'hero', level: Level.create(1) });
|
||||||
|
const dead = attacker.dealDamage(target, Damage.create(10000));
|
||||||
|
let char = dead;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
char = char.joinFaction(Faction.create(`faction-${i}`));
|
||||||
|
}
|
||||||
|
expect(char.level.value).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LevelUpFromDamage (property)', () => {
|
||||||
|
it('property: cumulative damage triggers level-up at threshold', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.integer({ min: 1, max: 9 }),
|
||||||
|
fc.integer({ min: 1, max: 500 }),
|
||||||
|
fc.integer({ min: 1, max: 500 }),
|
||||||
|
(level, dmg1, dmg2) => {
|
||||||
|
const currentLevel = Level.create(level);
|
||||||
|
const threshold = Level.damageThresholdForLevel(level + 1);
|
||||||
|
const total = dmg1 + dmg2;
|
||||||
|
|
||||||
|
// Only test when total meets threshold AND target survives
|
||||||
|
// Target starts with 1000 health, needs health > total after damage
|
||||||
|
if (total >= threshold && total < 1000) {
|
||||||
|
const attacker = Character.create({ name: 'a', level: Level.create(1) });
|
||||||
|
const target = Character.create({ name: 't', level: currentLevel });
|
||||||
|
const afterFirst = attacker.dealDamage(target, Damage.create(dmg1));
|
||||||
|
const afterSecond = attacker.dealDamage(afterFirst, Damage.create(dmg2));
|
||||||
|
const expectedLevel = Math.min(10, level + 1);
|
||||||
|
return afterSecond.level.value === expectedLevel;
|
||||||
|
}
|
||||||
|
return true; // skip non-applicable cases
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LevelUpFromFaction (property)', () => {
|
||||||
|
it('property: faction count triggers level-up at thresholds', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.integer({ min: 1, max: 9 }),
|
||||||
|
fc.integer({ min: 1, max: 30 }),
|
||||||
|
(level, totalFactions) => {
|
||||||
|
// Compute expected level: how many thresholds are crossed?
|
||||||
|
let currentLevel = level;
|
||||||
|
for (let f = 1; f <= totalFactions; f++) {
|
||||||
|
if (f >= 3 * (currentLevel + 1) && currentLevel < 10) {
|
||||||
|
currentLevel++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hero = Character.create({ name: 'hero', level: Level.create(level) });
|
||||||
|
let char = hero;
|
||||||
|
for (let i = 0; i < totalFactions; i++) {
|
||||||
|
char = char.joinFaction(Faction.create(`faction-${i}`));
|
||||||
|
}
|
||||||
|
return char.level.value === currentLevel;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DamageAccumulation (property)', () => {
|
||||||
|
it('property: totalDamageTaken accumulates across multiple damage events', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(fc.integer({ min: 1, max: 200 }), { minLength: 2, maxLength: 10 }),
|
||||||
|
(damageEvents) => {
|
||||||
|
const expectedTotal = damageEvents.reduce((sum, d) => sum + d, 0);
|
||||||
|
// Ensure target survives: total damage must be < 1000 (starting health)
|
||||||
|
if (expectedTotal >= 1000) return true;
|
||||||
|
|
||||||
|
const attacker = Character.create({ name: 'a', level: Level.create(1) });
|
||||||
|
const target = Character.create({ name: 't', level: Level.create(1) });
|
||||||
|
|
||||||
|
let current = target;
|
||||||
|
for (const dmg of damageEvents) {
|
||||||
|
current = attacker.dealDamage(current, Damage.create(dmg));
|
||||||
|
}
|
||||||
|
return current.totalDamageTaken.value === expectedTotal;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { Health } from '../value-objects/Health.ts';
|
import { Health } from '../value-objects/Health.ts';
|
||||||
import { Level } from '../value-objects/Level.ts';
|
import { Level } from '../value-objects/Level.ts';
|
||||||
|
import { Damage } from '../value-objects/Damage.ts';
|
||||||
import type { Status } from '../value-objects/Status.ts';
|
import type { Status } from '../value-objects/Status.ts';
|
||||||
import { StatusAlive, StatusDead } from '../value-objects/Status.ts';
|
import { StatusAlive, StatusDead } from '../value-objects/Status.ts';
|
||||||
import type { CharacterState } from './CharacterState.ts';
|
import type { CharacterState } from './CharacterState.ts';
|
||||||
@ -48,6 +49,8 @@ export class Character {
|
|||||||
status: StatusAlive,
|
status: StatusAlive,
|
||||||
level,
|
level,
|
||||||
factions: new Set(),
|
factions: new Set(),
|
||||||
|
totalDamageTaken: Damage.create(0),
|
||||||
|
factionsJoined: new Set(),
|
||||||
};
|
};
|
||||||
return new Character(state);
|
return new Character(state);
|
||||||
}
|
}
|
||||||
@ -60,6 +63,8 @@ export class Character {
|
|||||||
status: StatusAlive,
|
status: StatusAlive,
|
||||||
level,
|
level,
|
||||||
factions: new Set(),
|
factions: new Set(),
|
||||||
|
totalDamageTaken: Damage.create(0),
|
||||||
|
factionsJoined: new Set(),
|
||||||
};
|
};
|
||||||
return new Character(state);
|
return new Character(state);
|
||||||
}
|
}
|
||||||
@ -77,6 +82,8 @@ export class Character {
|
|||||||
status,
|
status,
|
||||||
level,
|
level,
|
||||||
factions: new Set(),
|
factions: new Set(),
|
||||||
|
totalDamageTaken: Damage.create(0),
|
||||||
|
factionsJoined: new Set(),
|
||||||
};
|
};
|
||||||
return new Character(state);
|
return new Character(state);
|
||||||
}
|
}
|
||||||
@ -101,6 +108,14 @@ export class Character {
|
|||||||
return this.#state.factions;
|
return this.#state.factions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get totalDamageTaken(): Damage {
|
||||||
|
return this.#state.totalDamageTaken;
|
||||||
|
}
|
||||||
|
|
||||||
|
get factionsJoined(): ReadonlySet<Faction> {
|
||||||
|
return this.#state.factionsJoined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if this character is an ally of another.
|
* Check if this character is an ally of another.
|
||||||
* Two characters are allies if they share at least one faction.
|
* Two characters are allies if they share at least one faction.
|
||||||
@ -122,12 +137,24 @@ export class Character {
|
|||||||
if (this.status.kind === 'dead') return this;
|
if (this.status.kind === 'dead') return this;
|
||||||
const newFactions = new Set(this.#state.factions);
|
const newFactions = new Set(this.#state.factions);
|
||||||
newFactions.add(faction);
|
newFactions.add(faction);
|
||||||
|
|
||||||
|
const newFactionsJoined = new Set(this.#state.factionsJoined);
|
||||||
|
newFactionsJoined.add(faction);
|
||||||
|
|
||||||
|
// Level-up from faction count
|
||||||
|
let newLevel = this.level;
|
||||||
|
if (newFactionsJoined.size >= 3 * (newLevel.value + 1)) {
|
||||||
|
newLevel = Level.create(Math.min(10, newLevel.value + 1));
|
||||||
|
}
|
||||||
|
|
||||||
return new Character({
|
return new Character({
|
||||||
name: this.name,
|
name: this.name,
|
||||||
health: this.health,
|
health: this.health,
|
||||||
status: this.status,
|
status: this.status,
|
||||||
level: this.level,
|
level: newLevel,
|
||||||
factions: newFactions,
|
factions: newFactions,
|
||||||
|
totalDamageTaken: this.totalDamageTaken,
|
||||||
|
factionsJoined: newFactionsJoined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,6 +174,8 @@ export class Character {
|
|||||||
status: this.status,
|
status: this.status,
|
||||||
level: this.level,
|
level: this.level,
|
||||||
factions: newFactions,
|
factions: newFactions,
|
||||||
|
totalDamageTaken: this.totalDamageTaken,
|
||||||
|
factionsJoined: this.factionsJoined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,6 +198,8 @@ export class Character {
|
|||||||
status: ally.status,
|
status: ally.status,
|
||||||
level: ally.level,
|
level: ally.level,
|
||||||
factions: ally.factions,
|
factions: ally.factions,
|
||||||
|
totalDamageTaken: ally.totalDamageTaken,
|
||||||
|
factionsJoined: ally.factionsJoined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,34 +207,46 @@ export class Character {
|
|||||||
* Deal damage to another character. Returns a new Character with updated state.
|
* Deal damage to another character. Returns a new Character with updated state.
|
||||||
* Does not mutate the attacker or the original target reference.
|
* Does not mutate the attacker or the original target reference.
|
||||||
*/
|
*/
|
||||||
dealDamage(target: Character, damage: number): Character {
|
dealDamage(target: Character, damage: Damage): Character {
|
||||||
// Self-damage is forbidden — use reference equality, not name
|
// Self-damage is forbidden — use reference equality, not name
|
||||||
if (this === target) return target;
|
if (this === target) return target;
|
||||||
// Allies cannot deal damage to each other
|
// Allies cannot deal damage to each other
|
||||||
if (this.isAllyOf(target)) return target;
|
if (this.isAllyOf(target)) return target;
|
||||||
// Dead characters cannot take damage
|
// Dead characters cannot take damage
|
||||||
if (target.status.kind === 'dead') return target;
|
if (target.status.kind === 'dead') return target;
|
||||||
// Negative damage is invalid
|
|
||||||
if (damage < 0) throw new Error(`Damage must be non-negative, got ${damage}`);
|
|
||||||
// Level-based damage modifier
|
// Level-based damage modifier
|
||||||
const levelDiff = this.level.diff(target.level); // = this.level - target.level
|
const levelDiff = this.level.diff(target.level); // = this.level - target.level
|
||||||
let actualDamage = damage;
|
let actualDamage = damage.value;
|
||||||
if (levelDiff <= -5) {
|
if (levelDiff <= -5) {
|
||||||
// Target is ≥5 levels above → damage reduced by 50%
|
// Target is ≥5 levels above → damage reduced by 50%
|
||||||
actualDamage = Math.floor(damage * 0.5);
|
actualDamage = Math.floor(damage.value * 0.5);
|
||||||
} else if (levelDiff >= 5) {
|
} else if (levelDiff >= 5) {
|
||||||
// Target is ≥5 levels below → damage increased by 50%
|
// Target is ≥5 levels below → damage increased by 50%
|
||||||
actualDamage = Math.floor(damage * 1.5);
|
actualDamage = Math.floor(damage.value * 1.5);
|
||||||
}
|
}
|
||||||
// Reduce health by the (possibly modified) damage amount
|
// Reduce health by the (possibly modified) damage amount
|
||||||
const newHealth = target.health.sub(actualDamage);
|
const newHealth = target.health.sub(actualDamage);
|
||||||
const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive;
|
const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive;
|
||||||
|
|
||||||
|
// Level-up from cumulative damage
|
||||||
|
let newLevel = target.level;
|
||||||
|
const newTotalDamage = target.totalDamageTaken.add(Damage.create(actualDamage));
|
||||||
|
|
||||||
|
if (newStatus.kind === 'alive') {
|
||||||
|
const threshold = Level.damageThresholdForLevel(newLevel.value + 1);
|
||||||
|
if (newTotalDamage.value >= threshold) {
|
||||||
|
newLevel = Level.create(Math.min(10, newLevel.value + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new Character({
|
return new Character({
|
||||||
name: target.name,
|
name: target.name,
|
||||||
health: newHealth,
|
health: newHealth,
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
level: target.level,
|
level: newLevel,
|
||||||
factions: target.factions,
|
factions: target.factions,
|
||||||
|
totalDamageTaken: newTotalDamage,
|
||||||
|
factionsJoined: target.factionsJoined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,6 +268,8 @@ export class Character {
|
|||||||
status: this.status,
|
status: this.status,
|
||||||
level: this.level,
|
level: this.level,
|
||||||
factions: this.factions,
|
factions: this.factions,
|
||||||
|
totalDamageTaken: this.totalDamageTaken,
|
||||||
|
factionsJoined: this.factionsJoined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type { Health } from '../value-objects/Health.ts';
|
|||||||
import type { Level } from '../value-objects/Level.ts';
|
import type { Level } from '../value-objects/Level.ts';
|
||||||
import type { Status } from '../value-objects/Status.ts';
|
import type { Status } from '../value-objects/Status.ts';
|
||||||
import type { Faction } from '../factions/Faction.ts';
|
import type { Faction } from '../factions/Faction.ts';
|
||||||
|
import type { Damage } from '../value-objects/Damage.ts';
|
||||||
|
|
||||||
export type CharacterState = {
|
export type CharacterState = {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@ -15,4 +16,6 @@ export type CharacterState = {
|
|||||||
readonly status: Status;
|
readonly status: Status;
|
||||||
readonly level: Level;
|
readonly level: Level;
|
||||||
readonly factions: ReadonlySet<Faction>;
|
readonly factions: ReadonlySet<Faction>;
|
||||||
|
readonly totalDamageTaken: Damage;
|
||||||
|
readonly factionsJoined: ReadonlySet<Faction>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import fc from 'fast-check';
|
|||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
import { Character } from './characters/Character.ts';
|
import { Character } from './characters/Character.ts';
|
||||||
import { Level } from './value-objects/Level.ts';
|
import { Level } from './value-objects/Level.ts';
|
||||||
|
import { Damage } from './value-objects/Damage.ts';
|
||||||
|
|
||||||
describe('DamageAndHealth', () => {
|
describe('DamageAndHealth', () => {
|
||||||
describe('DamageReducesHealth', () => {
|
describe('DamageReducesHealth', () => {
|
||||||
@ -18,7 +19,7 @@ describe('DamageAndHealth', () => {
|
|||||||
health,
|
health,
|
||||||
});
|
});
|
||||||
const expected = Math.max(0, health - damage);
|
const expected = Math.max(0, health - damage);
|
||||||
const result = attacker.dealDamage(target, damage);
|
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||||
return result.health.value === expected;
|
return result.health.value === expected;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -39,7 +40,7 @@ describe('DamageAndHealth', () => {
|
|||||||
level: Level.create(1),
|
level: Level.create(1),
|
||||||
health,
|
health,
|
||||||
});
|
});
|
||||||
const result = attacker.dealDamage(target, damage);
|
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||||
return result.health.value >= 0;
|
return result.health.value >= 0;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -60,7 +61,7 @@ describe('DamageAndHealth', () => {
|
|||||||
level: Level.create(1),
|
level: Level.create(1),
|
||||||
health,
|
health,
|
||||||
});
|
});
|
||||||
const result = attacker.dealDamage(target, damage);
|
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||||
const expected = Math.max(0, health - damage);
|
const expected = Math.max(0, health - damage);
|
||||||
if (expected === 0) {
|
if (expected === 0) {
|
||||||
return result.status.kind === 'dead';
|
return result.status.kind === 'dead';
|
||||||
@ -82,7 +83,7 @@ describe('DamageAndHealth', () => {
|
|||||||
const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health });
|
const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health });
|
||||||
const healthBefore = c.health.value;
|
const healthBefore = c.health.value;
|
||||||
const statusBefore = c.status.kind;
|
const statusBefore = c.status.kind;
|
||||||
const result = c.dealDamage(c, damage);
|
const result = c.dealDamage(c, Damage.create(damage));
|
||||||
// Should return the same reference
|
// Should return the same reference
|
||||||
return (
|
return (
|
||||||
result === c &&
|
result === c &&
|
||||||
@ -102,11 +103,11 @@ describe('DamageAndHealth', () => {
|
|||||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||||
const target = Character.create({ name: 'target', level: Level.create(1) });
|
const target = Character.create({ name: 'target', level: Level.create(1) });
|
||||||
// Kill the target first — capture the returned (dead) character
|
// Kill the target first — capture the returned (dead) character
|
||||||
const deadTarget = attacker.dealDamage(target, 10000);
|
const deadTarget = attacker.dealDamage(target, Damage.create(10000));
|
||||||
const healthBefore = deadTarget.health.value;
|
const healthBefore = deadTarget.health.value;
|
||||||
const statusBefore = deadTarget.status.kind;
|
const statusBefore = deadTarget.status.kind;
|
||||||
// Then try to deal more damage to the dead character
|
// Then try to deal more damage to the dead character
|
||||||
const result = attacker.dealDamage(deadTarget, damage);
|
const result = attacker.dealDamage(deadTarget, Damage.create(damage));
|
||||||
return (
|
return (
|
||||||
result === deadTarget &&
|
result === deadTarget &&
|
||||||
result.health.value === healthBefore &&
|
result.health.value === healthBefore &&
|
||||||
@ -118,14 +119,12 @@ describe('DamageAndHealth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('NegativeDamageForbidden', () => {
|
describe('NegativeDamageForbidden', () => {
|
||||||
it('property: negative damage throws an error', () => {
|
it('property: negative damage throws an error via Damage.create', () => {
|
||||||
fc.assert(
|
fc.assert(
|
||||||
fc.property(fc.integer({ min: -10000, max: -1 }), (negativeDamage) => {
|
fc.property(fc.integer({ min: -10000, max: -1 }), (negativeDamage) => {
|
||||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
|
||||||
const target = Character.create({ name: 'target', level: Level.create(1) });
|
|
||||||
let threw = false;
|
let threw = false;
|
||||||
try {
|
try {
|
||||||
attacker.dealDamage(target, negativeDamage);
|
Damage.create(negativeDamage);
|
||||||
} catch {
|
} catch {
|
||||||
threw = true;
|
threw = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { describe, it } from 'vitest';
|
|||||||
import { Character } from './characters/Character.ts';
|
import { Character } from './characters/Character.ts';
|
||||||
import { Faction } from './factions/Faction.ts';
|
import { Faction } from './factions/Faction.ts';
|
||||||
import { Level } from './value-objects/Level.ts';
|
import { Level } from './value-objects/Level.ts';
|
||||||
|
import { Damage } from './value-objects/Damage.ts';
|
||||||
|
|
||||||
describe('Factions', () => {
|
describe('Factions', () => {
|
||||||
const hero = () => Character.create({ name: 'hero', level: Level.create(1) });
|
const hero = () => Character.create({ name: 'hero', level: Level.create(1) });
|
||||||
@ -189,7 +190,7 @@ describe('Factions', () => {
|
|||||||
(factionName) => {
|
(factionName) => {
|
||||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||||
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
||||||
const deadHero = attacker.dealDamage(hero, 10000);
|
const deadHero = attacker.dealDamage(hero, Damage.create(10000));
|
||||||
const f = Faction.create(factionName);
|
const f = Faction.create(factionName);
|
||||||
const result = deadHero.joinFaction(f);
|
const result = deadHero.joinFaction(f);
|
||||||
return result === deadHero && result.factions.size === 0;
|
return result === deadHero && result.factions.size === 0;
|
||||||
@ -209,7 +210,7 @@ describe('Factions', () => {
|
|||||||
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
||||||
const f = Faction.create(factionName);
|
const f = Faction.create(factionName);
|
||||||
const withFaction = hero.joinFaction(f);
|
const withFaction = hero.joinFaction(f);
|
||||||
const deadHero = attacker.dealDamage(withFaction, 10000);
|
const deadHero = attacker.dealDamage(withFaction, Damage.create(10000));
|
||||||
const result = deadHero.leaveFaction(f);
|
const result = deadHero.leaveFaction(f);
|
||||||
return result === deadHero && result.factions.has(f);
|
return result === deadHero && result.factions.has(f);
|
||||||
},
|
},
|
||||||
@ -302,7 +303,7 @@ describe('Factions', () => {
|
|||||||
const target = ally().joinFaction(faction);
|
const target = ally().joinFaction(faction);
|
||||||
const healthBefore = target.health.value;
|
const healthBefore = target.health.value;
|
||||||
const statusBefore = target.status.kind;
|
const statusBefore = target.status.kind;
|
||||||
const result = attacker.dealDamage(target, 500);
|
const result = attacker.dealDamage(target, Damage.create(500));
|
||||||
return result.health.value === healthBefore && result.status.kind === statusBefore;
|
return result.health.value === healthBefore && result.status.kind === statusBefore;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -314,7 +315,7 @@ describe('Factions', () => {
|
|||||||
const faction = Faction.create('guard');
|
const faction = Faction.create('guard');
|
||||||
const attacker = hero().joinFaction(faction);
|
const attacker = hero().joinFaction(faction);
|
||||||
const target = ally().joinFaction(faction);
|
const target = ally().joinFaction(faction);
|
||||||
const result = attacker.dealDamage(target, 500);
|
const result = attacker.dealDamage(target, Damage.create(500));
|
||||||
return result === target;
|
return result === target;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -326,7 +327,7 @@ describe('Factions', () => {
|
|||||||
const attacker = hero();
|
const attacker = hero();
|
||||||
const target = enemy();
|
const target = enemy();
|
||||||
const healthBefore = target.health.value;
|
const healthBefore = target.health.value;
|
||||||
const result = attacker.dealDamage(target, 100);
|
const result = attacker.dealDamage(target, Damage.create(100));
|
||||||
return result.health.value === healthBefore - 100;
|
return result.health.value === healthBefore - 100;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -414,7 +415,7 @@ describe('Factions', () => {
|
|||||||
const target = ally().joinFaction(faction);
|
const target = ally().joinFaction(faction);
|
||||||
// Kill the target with a non-ally attacker
|
// Kill the target with a non-ally attacker
|
||||||
const killer = enemy();
|
const killer = enemy();
|
||||||
const deadTarget = killer.dealDamage(target, 10000);
|
const deadTarget = killer.dealDamage(target, Damage.create(10000));
|
||||||
const healthBefore = deadTarget.health.value;
|
const healthBefore = deadTarget.health.value;
|
||||||
const statusBefore = deadTarget.status.kind;
|
const statusBefore = deadTarget.status.kind;
|
||||||
const result = healer.healAlly(deadTarget, 500);
|
const result = healer.healAlly(deadTarget, 500);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import fc from 'fast-check';
|
|||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
import { Character } from './characters/Character.ts';
|
import { Character } from './characters/Character.ts';
|
||||||
import { Level } from './value-objects/Level.ts';
|
import { Level } from './value-objects/Level.ts';
|
||||||
|
import { Damage } from './value-objects/Damage.ts';
|
||||||
|
|
||||||
describe('Healing', () => {
|
describe('Healing', () => {
|
||||||
describe('SelfHealIncreasesHealth', () => {
|
describe('SelfHealIncreasesHealth', () => {
|
||||||
@ -88,7 +89,7 @@ describe('Healing', () => {
|
|||||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||||
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
||||||
// Kill the hero first using a different attacker
|
// Kill the hero first using a different attacker
|
||||||
const deadHero = attacker.dealDamage(hero, 10000);
|
const deadHero = attacker.dealDamage(hero, Damage.create(10000));
|
||||||
const healthBefore = deadHero.health.value;
|
const healthBefore = deadHero.health.value;
|
||||||
const statusBefore = deadHero.status.kind;
|
const statusBefore = deadHero.status.kind;
|
||||||
// Try to heal the dead character
|
// Try to heal the dead character
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import fc from 'fast-check';
|
|||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
import { Character } from './characters/Character.ts';
|
import { Character } from './characters/Character.ts';
|
||||||
import { Level } from './value-objects/Level.ts';
|
import { Level } from './value-objects/Level.ts';
|
||||||
|
import { Damage } from './value-objects/Damage.ts';
|
||||||
|
|
||||||
describe('Levels', () => {
|
describe('Levels', () => {
|
||||||
describe('CloseLevelNoModifier', () => {
|
describe('CloseLevelNoModifier', () => {
|
||||||
@ -21,7 +22,7 @@ describe('Levels', () => {
|
|||||||
level: Level.create(targetLevel),
|
level: Level.create(targetLevel),
|
||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const result = attacker.dealDamage(target, baseDamage);
|
const result = attacker.dealDamage(target, Damage.create(baseDamage));
|
||||||
return result.health.value === Math.max(0, 1000 - baseDamage);
|
return result.health.value === Math.max(0, 1000 - baseDamage);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -47,7 +48,7 @@ describe('Levels', () => {
|
|||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const expectedDamage = Math.floor(baseDamage * 0.5);
|
const expectedDamage = Math.floor(baseDamage * 0.5);
|
||||||
const result = attacker.dealDamage(target, baseDamage);
|
const result = attacker.dealDamage(target, Damage.create(baseDamage));
|
||||||
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -66,7 +67,7 @@ describe('Levels', () => {
|
|||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const expectedDamage = Math.floor(oddDamage * 0.5);
|
const expectedDamage = Math.floor(oddDamage * 0.5);
|
||||||
const result = attacker.dealDamage(target, oddDamage);
|
const result = attacker.dealDamage(target, Damage.create(oddDamage));
|
||||||
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -91,7 +92,7 @@ describe('Levels', () => {
|
|||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const expectedDamage = Math.floor(baseDamage * 1.5);
|
const expectedDamage = Math.floor(baseDamage * 1.5);
|
||||||
const result = attacker.dealDamage(target, baseDamage);
|
const result = attacker.dealDamage(target, Damage.create(baseDamage));
|
||||||
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -111,7 +112,7 @@ describe('Levels', () => {
|
|||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const expectedDamage = Math.floor(damage * 0.5);
|
const expectedDamage = Math.floor(damage * 0.5);
|
||||||
const result = attacker.dealDamage(target, damage);
|
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||||
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -128,7 +129,7 @@ describe('Levels', () => {
|
|||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const expectedDamage = Math.floor(damage * 1.5);
|
const expectedDamage = Math.floor(damage * 1.5);
|
||||||
const result = attacker.dealDamage(target, damage);
|
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||||
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -144,7 +145,7 @@ describe('Levels', () => {
|
|||||||
level: Level.create(5),
|
level: Level.create(5),
|
||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const result = attacker.dealDamage(target, damage);
|
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||||
return result.health.value === Math.max(0, 1000 - damage);
|
return result.health.value === Math.max(0, 1000 - damage);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -160,7 +161,7 @@ describe('Levels', () => {
|
|||||||
level: Level.create(1),
|
level: Level.create(1),
|
||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const result = attacker.dealDamage(target, damage);
|
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||||
return result.health.value === Math.max(0, 1000 - damage);
|
return result.health.value === Math.max(0, 1000 - damage);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import fc from 'fast-check';
|
|||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
import { Character } from './characters/Character.ts';
|
import { Character } from './characters/Character.ts';
|
||||||
import { Level } from './value-objects/Level.ts';
|
import { Level } from './value-objects/Level.ts';
|
||||||
|
import { Damage } from './value-objects/Damage.ts';
|
||||||
import { MagicalWeapon } from './magical-objects/MagicalWeapon.ts';
|
import { MagicalWeapon } from './magical-objects/MagicalWeapon.ts';
|
||||||
import { HealingObject } from './magical-objects/HealingObject.ts';
|
import { HealingObject } from './magical-objects/HealingObject.ts';
|
||||||
|
|
||||||
@ -85,7 +86,7 @@ describe('Magical Objects', () => {
|
|||||||
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
||||||
// Kill the attacker using a separate killer
|
// Kill the attacker using a separate killer
|
||||||
const killer = Character.create({ name: 'boss', level: Level.create(1) });
|
const killer = Character.create({ name: 'boss', level: Level.create(1) });
|
||||||
const deadAttacker = killer.dealDamage(attacker, 10000);
|
const deadAttacker = killer.dealDamage(attacker, Damage.create(10000));
|
||||||
const weaponHPBefore = weapon.health.value;
|
const weaponHPBefore = weapon.health.value;
|
||||||
const targetHealthBefore = target.health.value;
|
const targetHealthBefore = target.health.value;
|
||||||
const result = deadAttacker.useWeapon(weapon, target);
|
const result = deadAttacker.useWeapon(weapon, target);
|
||||||
@ -256,7 +257,7 @@ describe('Magical Objects', () => {
|
|||||||
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
||||||
// Kill the character using a separate killer
|
// Kill the character using a separate killer
|
||||||
const killer = Character.create({ name: 'boss', level: Level.create(1) });
|
const killer = Character.create({ name: 'boss', level: Level.create(1) });
|
||||||
const deadCharacter = killer.dealDamage(character, 10000);
|
const deadCharacter = killer.dealDamage(character, Damage.create(10000));
|
||||||
const objectHPBefore = object.health.value;
|
const objectHPBefore = object.health.value;
|
||||||
const characterHealthBefore = deadCharacter.health.value;
|
const characterHealthBefore = deadCharacter.health.value;
|
||||||
const result = deadCharacter.useHealingObject(object, 100);
|
const result = deadCharacter.useHealingObject(object, 100);
|
||||||
|
|||||||
@ -7,18 +7,19 @@
|
|||||||
*/
|
*/
|
||||||
import { Character } from '../characters/Character.ts';
|
import { Character } from '../characters/Character.ts';
|
||||||
import { Health } from '../value-objects/Health.ts';
|
import { Health } from '../value-objects/Health.ts';
|
||||||
|
import { Damage } from '../value-objects/Damage.ts';
|
||||||
import { MagicalObject } from './MagicalObject.ts';
|
import { MagicalObject } from './MagicalObject.ts';
|
||||||
import type { DamageDealer } from './magical-object-types.ts';
|
import type { DamageDealer } from './magical-object-types.ts';
|
||||||
|
|
||||||
export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
||||||
readonly #damage: number;
|
readonly #damage: Damage;
|
||||||
readonly #owner: Character;
|
readonly #owner: Character;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
health: Health,
|
health: Health,
|
||||||
maxHealth: Health,
|
maxHealth: Health,
|
||||||
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
|
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
|
||||||
damage: number,
|
damage: Damage,
|
||||||
owner: Character,
|
owner: Character,
|
||||||
) {
|
) {
|
||||||
super(health, maxHealth, status);
|
super(health, maxHealth, status);
|
||||||
@ -36,17 +37,16 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
|||||||
owner: Character;
|
owner: Character;
|
||||||
}): MagicalWeapon {
|
}): MagicalWeapon {
|
||||||
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
|
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
|
||||||
if (damage < 0) throw new Error('Damage cannot be negative');
|
|
||||||
return new MagicalWeapon(
|
return new MagicalWeapon(
|
||||||
Health.create(maxHealth),
|
Health.create(maxHealth),
|
||||||
Health.create(maxHealth),
|
Health.create(maxHealth),
|
||||||
{ kind: 'alive' },
|
{ kind: 'alive' },
|
||||||
damage,
|
Damage.create(damage),
|
||||||
owner,
|
owner,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get damage(): number {
|
get damage(): Damage {
|
||||||
return this.#damage;
|
return this.#damage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
|||||||
return { weapon: this, target };
|
return { weapon: this, target };
|
||||||
}
|
}
|
||||||
// Deal fixed damage
|
// Deal fixed damage
|
||||||
const newTargetHealth = Math.max(0, target.health.value - this.#damage);
|
const newTargetHealth = Math.max(0, target.health.value - this.#damage.value);
|
||||||
const newTargetStatus = newTargetHealth === 0 ? { kind: 'dead' as const } : target.status;
|
const newTargetStatus = newTargetHealth === 0 ? { kind: 'dead' as const } : target.status;
|
||||||
const newTarget = Character.createWithHealthAndStatus({
|
const newTarget = Character.createWithHealthAndStatus({
|
||||||
name: target.name,
|
name: target.name,
|
||||||
|
|||||||
26
src/value-objects/Damage.ts
Normal file
26
src/value-objects/Damage.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Damage value object — non-negative, immutable.
|
||||||
|
*
|
||||||
|
* Invariant enforced at construction: n >= 0
|
||||||
|
*/
|
||||||
|
export class Damage {
|
||||||
|
readonly #value: number;
|
||||||
|
|
||||||
|
private constructor(n: number) {
|
||||||
|
this.#value = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(n: number): Damage {
|
||||||
|
if (n < 0) throw new Error(`Damage must be non-negative, got ${n}`);
|
||||||
|
return new Damage(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): number {
|
||||||
|
return this.#value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add another damage amount — cumulative damage is still a Damage. */
|
||||||
|
add(other: Damage): Damage {
|
||||||
|
return Damage.create(this.#value + other.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,4 +31,12 @@ export class Level {
|
|||||||
diff(target: Level): number {
|
diff(target: Level): number {
|
||||||
return this.value - target.value;
|
return this.value - target.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Damage threshold to reach the given level.
|
||||||
|
* Level 1→2: 1000, Level 2→3: 3000, Level 3→4: 6000, etc.
|
||||||
|
* Formula: 1000 * N * (N+1) / 2
|
||||||
|
*/
|
||||||
|
static damageThresholdForLevel(level: number): number {
|
||||||
|
return (1000 * level * (level + 1)) / 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -50,6 +50,7 @@ This is a description of the business rules we should support in the game engine
|
|||||||
- Healing Magical Objects cannot deal Damage
|
- Healing Magical Objects cannot deal Damage
|
||||||
|
|
||||||
3. Characters can deal Damage by using a Magical Weapon.
|
3. Characters can deal Damage by using a Magical Weapon.
|
||||||
|
- A Character can only use a Magical Weapon they own
|
||||||
- These Magical Objects deal a fixed amount of damage when they are used
|
- These Magical Objects deal a fixed amount of damage when they are used
|
||||||
- The amount of damage is fixed at the time the weapon is created
|
- The amount of damage is fixed at the time the weapon is created
|
||||||
- Every time the weapon is used, the Health is reduced by 1
|
- Every time the weapon is used, the Health is reduced by 1
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user