⚔ MonsterBattles Docs
Phuc PhamGitHub ↗
WEEK 03 · KOTLIN

Advanced OO & Functional Programming

Begrijp inheritance, polymorphism, SOLID, UML en functionele Kotlin aan de hand van kleine voorbeelden. Bouw daarnaast één echte battle-engine die voortgaat op het definitieve domeinmodel uit Week 02.

36 onderwerpenOO & Functional ProgrammingBattle-engine zonder HTTP

Wat bouwen we deze week?

De vaste Monster, Stats en Element uit Week 02 blijven ongewijzigd. De battle-engine voegt DamageCalculator, BattleEvent, BattleResult en BattleService toe. Het gevecht rekent met tijdelijke HP, waardoor opgeslagen monsters na een battle hun basisstats behouden.

Vaste regels: hoogste Speed begint, gelijke Speed betekent eerst geselecteerd; damage is maxOf(1, attack - defense); HP stopt bij 0; geen elementbonussen of critical hits. Pyron verslaat Aquara na 7 rondes en 13 aanvallen met 10 HP over.

Waarom geen drie vaste monstersubclasses? FIRE, WATER en WIND hebben in onze basisgame geen verschillend gedrag. De overervingsvoorbeelden zijn daarom zelfstandige onderwijsdemonstraties en vormen niet een tweede domeinmodel. Een formele AIP-eis voor concrete subtypes moet afzonderlijk worden aangetoond.

De 36 onderwerpen

Elk onderwerp bevat een definitie, werking, code, verwachte uitvoer, reden, alternatief en toepassing. De korte voorbeelden zijn afzonderlijke leerdemo's; identiek genaamde voorbeeldclasses horen niet samen in één Kotlin-bestand. De geïntegreerde projectcode staat verderop met echte paden en packages.

01Inheritance

Wat is het? Inheritance (overerving) laat een subclass eigenschappen en gedrag van een superclass gebruiken. In Kotlin is een class standaard niet overerfbaar; de superclass moet open of abstract zijn.

Hoe werkt het? Een subclass erft toegankelijke members van een open of abstracte superclass. De primaire constructor van een subclass roept de constructor van de superclass aan.

Inheritance.ktKOTLIN
open class Monster(val name: String)
class FireMonster(name: String) : Monster(name)

fun main() {
    val pyron = FireMonster("Pyron")
    println(pyron.name)
}
▸ Terminal / outputVerwachte uitvoer
Pyron

Waarom deze aanpak? Een FireMonster kan gedeelde monstergegevens overnemen. Gebruik overerving alleen als het echt een IS-A-relatie is.

Wanneer kies je iets anders? Kies composition wanneer een Monster alleen een elementwaarde heeft en geen verschillend subclassgedrag nodig is.

Toepassing in MonsterBattles: Dit voorbeeld demonstreert OO/FP, maar wijzigt de definitieve `Monster`-class niet.

02open en final

Wat is het? Een gewone Kotlin-class is final. Met open sta je overerving toe. Ook members zijn standaard final en hebben open nodig als een subclass ze mag overschrijven.

Hoe werkt het? Een Kotlin-class en haar functies zijn standaard final. Markeer alleen expliciet uitbreidbare onderdelen met open en gebruik override in het subtype.

OpenFinal.ktKOTLIN
open class Monster {
    open fun sound(): String = "Monster sound"
    fun category(): String = "Creature" // final by default
}
class FireMonster : Monster() {
    override fun sound(): String = "Roar"
}
fun main() {
    println(FireMonster().sound())
    println(FireMonster().category())
}
▸ Terminal / outputVerwachte uitvoer
Roar
Creature

Waarom deze aanpak? De basisclass bepaalt expliciet welke monsterfunctionaliteit uitbreidbaar is; niet alles wordt automatisch overschrijfbaar.

Wanneer kies je iets anders? Gebruik een interface bij alleen gedeeld gedrag; houd classes final zolang uitbreidbaarheid niet nodig is.

Toepassing in MonsterBattles: Dit voorbeeld demonstreert OO/FP, maar wijzigt de definitieve `Monster`-class niet.

03Abstract classes

Wat is het? Een abstract class kun je niet direct instantiëren. Ze kan gedeelde implementatie en abstracte members bevatten die subclasses moeten invullen.

Hoe werkt het? Een abstracte class kan zelf niet worden geïnstantieerd. Subclasses vullen abstracte members in; gewone members kunnen al een gedeelde implementatie bevatten.

AbstractMonster.ktKOTLIN
abstract class Monster(val name: String) {
    abstract val element: String
    fun describe(): String = "$name is $element"
}
class FireMonster(name: String) : Monster(name) {
    override val element = "FIRE"
}
fun main() = println(FireMonster("Pyron").describe())
▸ Terminal / outputVerwachte uitvoer
Pyron is FIRE

Waarom deze aanpak? Een algemeen monstertype kan gedeelde HP-logica bevatten, terwijl concrete monsters hun element bepalen.

Wanneer kies je iets anders? Gebruik een interface als je geen gedeelde state of implementatie nodig hebt.

Toepassing in MonsterBattles: Dit voorbeeld demonstreert OO/FP, maar wijzigt de definitieve `Monster`-class niet.

04Overriding: override en super

Wat is het? Met override vervang je een open of abstract member. Via super roep je de implementatie van de superclass aan.

Hoe werkt het? override vervangt overerfbaar gedrag; super verwijst naar de implementatie in de ouderclass. Dit is iets anders dan een overload met andere parameters.

OverrideSuper.ktKOTLIN
open class Monster(val name: String) {
    open fun describe(): String = "Monster: $name"
}
class FireMonster(name: String) : Monster(name) {
    override fun describe(): String = "${super.describe()} (FIRE)"
}
fun main() = println(FireMonster("Pyron").describe())
▸ Terminal / outputVerwachte uitvoer
Monster: Pyron (FIRE)

Waarom deze aanpak? Een specifiek monster kan een algemene beschrijving uitbreiden zonder de gedeelde tekst te dupliceren.

Wanneer kies je iets anders? Gebruik overloading als juist verschillende parameterlijsten gewenst zijn, niet een andere runtime-implementatie.

Toepassing in MonsterBattles: Dit voorbeeld demonstreert OO/FP, maar wijzigt de definitieve `Monster`-class niet.

05Overloading

Wat is het? Overloading betekent meerdere functies met dezelfde naam maar een andere parameterlijst. De compiler kiest op basis van de argumenten; dit is iets anders dan overriding.

Hoe werkt het? Overloading kiest tijdens compilatie een passende functiedeclaratie op basis van de argumenten. Overriding kiest bij runtime de implementatie van het object.

Overloading.ktKOTLIN
fun damage(attack: Int): Int = maxOf(1, attack)
fun damage(attack: Int, defense: Int): Int = maxOf(1, attack - defense)

fun main() {
    println(damage(30))
    println(damage(30, 12))
}
▸ Terminal / outputVerwachte uitvoer
30
18

Waarom deze aanpak? Een damagefunctie kan zowel een standaarddefense als een expliciete defensewaarde ondersteunen.

Wanneer kies je iets anders? Gebruik andere functienamen als overloads de API onduidelijk maken.

Toepassing in MonsterBattles: Dit voorbeeld demonstreert OO/FP, maar wijzigt de definitieve `Monster`-class niet.

06Polymorphism

Wat is het? Polymorphism laat je objecten via hetzelfde algemene type aanspreken terwijl hun eigen implementatie wordt uitgevoerd. De concrete methode wordt tijdens runtime gekozen.

Hoe werkt het? Een variabele met een gemeenschappelijk supertype of interface kan naar verschillende concrete objecten verwijzen; de juiste override wordt dynamisch gekozen.

Polymorphism.ktKOTLIN
abstract class Monster(val name: String) {
    abstract fun element(): String
}
class FireMonster(name: String) : Monster(name) {
    override fun element() = "FIRE"
}
class WaterMonster(name: String) : Monster(name) {
    override fun element() = "WATER"
}
fun main() {
    val monsters: List<Monster> = listOf(FireMonster("Pyron"), WaterMonster("Aquara"))
    monsters.forEach { println("${it.name}: ${it.element()}") }
}
▸ Terminal / outputVerwachte uitvoer
Pyron: FIRE
Aquara: WATER

Waarom deze aanpak? De battle-engine kan alle monstertypen via Monster verwerken in plaats van een aparte battlefunctie per element te maken.

Wanneer kies je iets anders? Een enum volstaat wanneer varianten enkel data-labels zijn en geen polymorf gedrag hebben.

Toepassing in MonsterBattles: Dit voorbeeld demonstreert OO/FP, maar wijzigt de definitieve `Monster`-class niet.

07Subtyping

Wat is het? Een subtype mag gebruikt worden waar een supertype wordt verwacht. Kotlin controleert het type statisch, zodat functies niet afhankelijk hoeven te zijn van de concrete subclass.

Hoe werkt het? Een subtype kan worden gebruikt waar zijn supertype verwacht wordt. Correct gedrag moet passen bij de beloften van dat algemene contract.

Subtyping.ktKOTLIN
open class Monster(val name: String)
class FireMonster(name: String) : Monster(name)

fun announce(monster: Monster) = println("Entering: ${monster.name}")
fun main() = announce(FireMonster("Pyron"))
▸ Terminal / outputVerwachte uitvoer
Entering: Pyron

Waarom deze aanpak? Een functie die een Monster verwacht accepteert ook een FireMonster of WaterMonster.

Wanneer kies je iets anders? Gebruik composition wanneer er geen echte substitueerbare IS-A-relatie bestaat.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

08IS-A versus HAS-A

Wat is het? IS-A duidt op overerving: FireMonster is een Monster. HAS-A duidt op composition: Monster heeft Stats. De tweede relatie vraagt geen subclass.

Hoe werkt het? IS-A betekent dat iets een speciaal geval van een ander type is; HAS-A betekent dat een object een ander object bevat of gebruikt.

IsAHasA.ktKOTLIN
data class Stats(val attack: Int)
open class Monster(val name: String, val stats: Stats)
class FireMonster(name: String, stats: Stats) : Monster(name, stats)

fun main() {
    val pyron = FireMonster("Pyron", Stats(30))
    println("${pyron.name}: ${pyron.stats.attack} ATK")
}
▸ Terminal / outputVerwachte uitvoer
Pyron: 30 ATK

Waarom deze aanpak? Monster heeft een Stats-object; Stats overerven zou de verkeerde domeinrelatie uitdrukken.

Wanneer kies je iets anders? Kies HAS-A voor Monster en Stats; FIRE/WATER/WIND blijven enumwaarden.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

09Composition

Wat is het? Bij composition bestaat een object uit andere objecten. Je bouwt functionaliteit door kleine onderdelen samen te voegen in plaats van lange overervingsketens te maken.

Hoe werkt het? Composition koppelt objecten via properties of constructorparameters. Zo verwissel je bijvoorbeeld een rekencomponent zonder de hele objecthiërarchie te wijzigen.

Composition.ktKOTLIN
class DamageCalculator {
    fun calculate(attack: Int, defense: Int) = maxOf(1, attack - defense)
}
class BattleService(private val calculator: DamageCalculator) {
    fun attack() = calculator.calculate(30, 12)
}
fun main() = println(BattleService(DamageCalculator()).attack())
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? BattleService kan een DamageCalculator bevatten; beide onderdelen zijn afzonderlijk te begrijpen en te testen.

Wanneer kies je iets anders? Een gewone functie is eenvoudiger als geen state of afhankelijkheid nodig is.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

10Delegation

Wat is het? Delegation betekent dat een object werk laat uitvoeren door een ander object. Kotlin ondersteunt expliciete delegation én interface delegation met by.

Hoe werkt het? Bij interface-delegatie met by laat een class de interfaceleden door een ander object uitvoeren. Kotlin kan die forwarding genereren.

Delegation.ktKOTLIN
interface DamageRule {
    fun calculate(attack: Int, defense: Int): Int
}
class NormalDamage : DamageRule {
    override fun calculate(attack: Int, defense: Int) = maxOf(1, attack - defense)
}
class BattleDamage(rule: DamageRule) : DamageRule by rule

fun main() = println(BattleDamage(NormalDamage()).calculate(30, 12))
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? Een battle-object kan het damagecontract doorgeven aan een losse calculator zonder die logica zelf te implementeren.

Wanneer kies je iets anders? Gebruik constructor injection zonder by voor slechts één of twee methoden wanneer dat leesbaarder is.

Toepassing in MonsterBattles: Dit voorbeeld demonstreert OO/FP, maar wijzigt de definitieve `Monster`-class niet.

11UML: association

Wat is het? Association is een relatie waarbij twee objecten elkaar kennen of gebruiken. In UML wordt dit doorgaans getekend als een doorgetrokken lijn; eigenaarschap is niet noodzakelijk.

Hoe werkt het? Association is een relatie tussen objecten die elkaar kennen of gebruiken. De levensduur van beide is meestal onafhankelijk.

Association.ktKOTLIN
class Monster(val name: String)
class BattleService {
    fun versus(first: Monster, second: Monster): String =
        "${first.name} vs ${second.name}"
}
fun main() = println(BattleService().versus(Monster("Pyron"), Monster("Aquara")))
▸ Terminal / outputVerwachte uitvoer
Pyron vs Aquara

Waarom deze aanpak? Een BattleService gebruikt twee Monster-objecten tijdens een gevecht, maar bezit ze niet noodzakelijk.

Wanneer kies je iets anders? Een lokale parameterrelatie hoeft niet als duurzaam vastgelegde UML-associatie te worden getekend.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

12UML: aggregation

Wat is het? Aggregation is een zwakke geheel-deelrelatie: onderdelen kunnen onafhankelijk van het geheel bestaan. UML gebruikt een open ruit aan de kant van het geheel.

Hoe werkt het? Aggregation drukt een zwakke geheel-deelrelatie uit: het onderdeel kan ook buiten het geheel blijven bestaan.

Aggregation.ktKOTLIN
class Monster(val name: String)
class MonsterTeam(val members: List<Monster>)

fun main() {
    val pyron = Monster("Pyron")
    val team = MonsterTeam(listOf(pyron))
    println(team.members.first().name)
    println(pyron.name) // Exists independently of the team
}
▸ Terminal / outputVerwachte uitvoer
Pyron
Pyron

Waarom deze aanpak? Een MonsterTeam kan verwijzen naar monsters die ook zonder dat team blijven bestaan.

Wanneer kies je iets anders? Gebruik association wanneer geen geheel-deelbetekenis bestaat.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

13UML: composition

Wat is het? UML-composition is een sterke geheel-deelrelatie waarbij een onderdeel conceptueel bij precies één geheel hoort. UML gebruikt een gevulde ruit aan de kant van het geheel. Een Kotlin-property op zichzelf bewijst nog geen ownership.

Hoe werkt het? UML-composition drukt sterke ownership uit. In code moet eigenaarschap uit de levenscyclus blijken; een val-property alleen bewijst geen composition.

UmlComposition.ktKOTLIN
data class BattleEvent(val text: String)
class BattleResult {
    private val events = mutableListOf<BattleEvent>()
    fun record(text: String) { events += BattleEvent(text) }
    fun log(): List<BattleEvent> = events.toList()
}
fun main() {
    val result = BattleResult()
    result.record("Pyron attacks")
    println(result.log().single().text)
}
▸ Terminal / outputVerwachte uitvoer
Pyron attacks

Waarom deze aanpak? Een BattleResult kan zijn eigen battlelog-items beheren. Het ontwerp bepaalt de lifecycle, niet alleen de syntax.

Wanneer kies je iets anders? Gebruik aggregation wanneer het onderdeel een onafhankelijk bestaan heeft.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

14High cohesion

Wat is het? High cohesion betekent dat onderdelen van een class sterk bij dezelfde verantwoordelijkheid horen. Een calculator berekent damage en regelt bijvoorbeeld geen HTTP of database.

Hoe werkt het? High cohesion betekent dat de verantwoordelijkheden van één class bij elkaar horen. Berekening, HTTP en SQL verdelen we daarom over aparte onderdelen.

HighCohesion.ktKOTLIN
class DamageCalculator {
    fun normal(attack: Int, defense: Int): Int = maxOf(1, attack - defense)
    fun critical(attack: Int, defense: Int): Int = normal(attack, defense) * 2
}
fun main() = println(DamageCalculator().normal(30, 12))
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? DamageCalculator bevat alleen berekeningslogica en is daardoor eenvoudig te hergebruiken.

Wanneer kies je iets anders? Splits pas op wanneer de verantwoordelijkheden werkelijk verschillende redenen tot wijziging hebben.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

15Low coupling

Wat is het? Low coupling betekent dat onderdelen zo min mogelijk van concrete implementatiedetails afhangen. Een interface kan de afhankelijkheid beperken.

Hoe werkt het? Low coupling betekent dat een onderdeel zo weinig mogelijk concrete andere onderdelen moet kennen, bijvoorbeeld via een interface.

LowCoupling.ktKOTLIN
interface DamageCalculator {
    fun calculate(attack: Int, defense: Int): Int
}
class NormalDamage : DamageCalculator {
    override fun calculate(attack: Int, defense: Int) = maxOf(1, attack - defense)
}
class BattleService(private val calculator: DamageCalculator) {
    fun hit() = calculator.calculate(30, 12)
}
fun main() = println(BattleService(NormalDamage()).hit())
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? BattleService hoeft niet te weten welke calculatorimplementatie wordt gebruikt.

Wanneer kies je iets anders? Direct een concrete class gebruiken is prima voor kleine, stabiele interne hulpfuncties.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

16SOLID: overzicht

Wat is het? SOLID is een verzameling van vijf ontwerpprincipes: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation en Dependency Inversion. Het zijn richtlijnen, geen harde syntaxisregels.

Hoe werkt het? SOLID zijn vijf heuristieken voor aanpasbare OO-software. Ze zijn geen reden om voor elke functie extra abstracties te introduceren.

SolidOverview.ktKOTLIN
// Small, focused contract used by battle logic.
fun interface DamageRule {
    fun calculate(attack: Int, defense: Int): Int
}
fun main() {
    val normal = DamageRule { attack, defense -> maxOf(1, attack - defense) }
    println(normal.calculate(30, 12))
}
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? We gebruiken SOLID om battlelogica uitbreidbaar, testbaar en overzichtelijk te houden zonder onnodige abstracties.

Wanneer kies je iets anders? Gebruik ontwerpprincipes als afweging, niet als automatische generator van interfaces.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

17SRP: Single Responsibility Principle

Wat is het? SRP stelt dat een onderdeel één reden tot veranderen hoort te hebben. Berekening en presentatie zijn verschillende verantwoordelijkheden.

Hoe werkt het? SRP vraagt om één samenhangende reden voor verandering per onderdeel. BattleService organiseert een gevecht, DamageCalculator rekent.

SRP.ktKOTLIN
class DamageCalculator {
    fun calculate(attack: Int, defense: Int) = maxOf(1, attack - defense)
}
fun formatHit(name: String, damage: Int) = "$name deals $damage damage"
fun main() {
    val amount = DamageCalculator().calculate(30, 12)
    println(formatHit("Pyron", amount))
}
▸ Terminal / outputVerwachte uitvoer
Pyron deals 18 damage

Waarom deze aanpak? De calculator bepaalt damage; een andere functie formatteert de battlelog.

Wanneer kies je iets anders? Splits een class niet puur om het aantal regels; kies een samenhangende verantwoordelijkheid.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

18OCP: Open/Closed Principle

Wat is het? OCP betekent dat bestaande code bij nieuwe varianten bij voorkeur uitgebreid kan worden zonder de kern te wijzigen. Een strategie-interface is één mogelijke aanpak.

Hoe werkt het? OCP vraagt uitbreidbaarheid via stabiele contracten waar variatie verwacht wordt. Bij drie simpele elementen is een klassehiërarchie niet automatisch nodig.

OCP.ktKOTLIN
fun interface DamageRule {
    fun calculate(attack: Int, defense: Int): Int
}
class BattleService(private val rule: DamageRule) {
    fun hit() = rule.calculate(30, 12)
}
fun main() {
    val normal = DamageRule { a, d -> maxOf(1, a - d) }
    println(BattleService(normal).hit())
}
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? Een nieuwe damageberekening kan worden toegevoegd zonder de BattleService aan te passen. De basisgame gebruikt alleen normale damage.

Wanneer kies je iets anders? Voeg geen extensiepunt toe als er geen realistische varianten zijn.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

19LSP: Liskov Substitution Principle

Wat is het? LSP zegt dat een subtype gebruikt moet kunnen worden waar het basistype verwacht wordt, zonder het afgesproken gedrag te schenden. Een subtype mag bijvoorbeeld geen onverwacht ongeldige HP toestaan.

Hoe werkt het? LSP betekent dat een subtype de verwachtingen van de supertypegebruikers behoudt, inclusief geldige invoer en uitvoer.

LSP.ktKOTLIN
abstract class Monster(val name: String) {
    abstract fun isAlive(): Boolean
}
class FireMonster(name: String, private val hp: Int) : Monster(name) {
    override fun isAlive() = hp > 0
}
fun status(monster: Monster) = "${monster.name} alive: ${monster.isAlive()}"
fun main() = println(status(FireMonster("Pyron", 100)))
▸ Terminal / outputVerwachte uitvoer
Pyron alive: true

Waarom deze aanpak? FireMonster en WaterMonster moeten allebei de afspraken van Monster respecteren.

Wanneer kies je iets anders? Gebruik composition in plaats van inheritance wanneer de gedragsovereenkomst niet klopt.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

20ISP: Interface Segregation Principle

Wat is het? ISP stelt dat clients niet afhankelijk moeten zijn van methoden die ze niet gebruiken. Kleine, doelgerichte interfaces voorkomen onnodige verplichtingen.

Hoe werkt het? ISP verdeelt grote interfaces in kleine, op gebruikers afgestemde contracten. Zo hoeft geen component methoden te implementeren die hij niet nodig heeft.

ISP.ktKOTLIN
interface Attacker { fun attackPower(): Int }
interface Damageable { fun takeDamage(amount: Int) }
class FireMonster : Attacker {
    override fun attackPower() = 30
}
fun main() {
    val attacker: Attacker = FireMonster()
    println(attacker.attackPower())
}
▸ Terminal / outputVerwachte uitvoer
30

Waarom deze aanpak? Een object dat alleen damage kan berekenen hoeft geen opslag- of logfuncties te implementeren.

Wanneer kies je iets anders? Een compacte interface is voldoende als alle afnemers exact hetzelfde contract nodig hebben.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

21DIP: Dependency Inversion Principle

Wat is het? DIP stelt dat high-level logica afhankelijk hoort te zijn van abstracties in plaats van concrete low-level implementaties. Constructor injection maakt dit zichtbaar.

Hoe werkt het? DIP laat hoog-niveau logica afhankelijk zijn van abstracties. Later kan dezelfde service met een in-memory of Exposed repository werken.

DIP.ktKOTLIN
interface DamageRule { fun hit(): Int }
class NormalDamage : DamageRule { override fun hit() = 18 }
class BattleService(private val rule: DamageRule) {
    fun run() = "Damage: ${rule.hit()}"
}
fun main() = println(BattleService(NormalDamage()).run())
▸ Terminal / outputVerwachte uitvoer
Damage: 18

Waarom deze aanpak? De battle-engine kan een echte calculator of een fake gebruiken zonder zijn eigen code te wijzigen.

Wanneer kies je iets anders? Concrete afhankelijkheden zijn acceptabel binnen puur interne en stabiele hulpmiddelen.

Toepassing in MonsterBattles: Deze ontwerpafweging helpt bepalen waarom de battle-engine in kleine onderdelen is gescheiden, terwijl `Monster` een eenvoudige class blijft.

22KISS en DRY

Wat is het? KISS betekent: houd een oplossing eenvoudig. DRY betekent: voorkom dat dezelfde kennis op meerdere plekken wordt vastgelegd. Te veel abstracties kunnen de code juist ingewikkelder maken.

Hoe werkt het? KISS houdt code zo eenvoudig mogelijk; DRY voorkomt onnodig gedupliceerde kennis. Een kleine herhaling is niet altijd slechter dan een zware abstractie.

KissDry.ktKOTLIN
fun calculateDamage(attack: Int, defense: Int): Int = maxOf(1, attack - defense)
fun main() {
    println(calculateDamage(30, 12))
    println(calculateDamage(25, 10))
}
▸ Terminal / outputVerwachte uitvoer
18
15

Waarom deze aanpak? Eén kleine damagefunctie is voldoende voor de basisregels; we bouwen geen complexe skill-engine voordat die nodig is.

Wanneer kies je iets anders? Herhaal een simpele expressie liever dan een abstractie te introduceren die het begrip belemmert.

Toepassing in MonsterBattles: Dit voorbeeld demonstreert OO/FP, maar wijzigt de definitieve `Monster`-class niet.

23Pure functions

Wat is het? Een pure function geeft bij dezelfde invoer altijd dezelfde uitvoer en veroorzaakt geen waarneembare side-effects. Daardoor is het resultaat voorspelbaar en makkelijk te testen.

Hoe werkt het? Een pure functie geeft voor dezelfde argumenten steeds dezelfde waarde terug en heeft geen waarneembare neveneffecten, zoals state wijzigen.

PureFunctions.ktKOTLIN
fun calculateDamage(attack: Int, defense: Int): Int = maxOf(1, attack - defense)
fun main() {
    println(calculateDamage(30, 12))
    println(calculateDamage(30, 12))
}
▸ Terminal / outputVerwachte uitvoer
18
18

Waarom deze aanpak? calculateDamage is puur: de functie berekent een getal zonder de HP van een monster te veranderen.

Wanneer kies je iets anders? Een stateful service is passend wanneer de taak echt coördinatie of mutatie vereist.

Toepassing in MonsterBattles: Dezelfde Kotlin-techniek kan damage- of battle-data verwerken; we forceren geen ingewikkelde abstrahering in de basisengine.

24Side-effects

Wat is het? Een side-effect is een waarneembare verandering buiten de geretourneerde waarde, bijvoorbeeld een object wijzigen, iets opslaan of output schrijven. Side-effects zijn niet per definitie verkeerd, maar horen op een duidelijke plek.

Hoe werkt het? Een side-effect is bijvoorbeeld het bijwerken van een collectie of een variabele buiten een functie. De battle-loop verandert alleen tijdelijke HP.

SideEffects.ktKOTLIN
class Monster(var hp: Int) {
    fun takeDamage(amount: Int) { hp = (hp - amount).coerceAtLeast(0) }
}
fun main() {
    val aquara = Monster(110)
    aquara.takeDamage(18) // Changes object state
    println(aquara.hp)
}
▸ Terminal / outputVerwachte uitvoer
92

Waarom deze aanpak? Damage berekenen kan puur blijven; takeDamage wijzigt bewust de HP van een monster.

Wanneer kies je iets anders? Gebruik immutabele returnwaarden en pure functies waar mogelijk.

Toepassing in MonsterBattles: Dezelfde Kotlin-techniek kan damage- of battle-data verwerken; we forceren geen ingewikkelde abstrahering in de basisengine.

25Function types

Wat is het? Een functie is in Kotlin een waarde met een type, bijvoorbeeld (Int, Int) -> Int. Zo kun je gedrag bewaren in een variabele of doorgeven aan een andere functie.

Hoe werkt het? Een functietype beschrijft de parameter- en returntypen van een functie, zoals (Int, Int) -> Int. Een functie kan als waarde worden doorgegeven.

FunctionTypes.ktKOTLIN
fun main() {
    val calculate: (Int, Int) -> Int = { attack, defense ->
        maxOf(1, attack - defense)
    }
    println(calculate(30, 12))
}
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? De damageformule kan als functieparameter aan de battle-engine worden doorgegeven.

Wanneer kies je iets anders? Gebruik een gewone functie bij een vaste berekening die niet verwisselbaar hoeft te zijn.

Toepassing in MonsterBattles: Dezelfde Kotlin-techniek kan damage- of battle-data verwerken; we forceren geen ingewikkelde abstrahering in de basisengine.

26Lambda expressions

Wat is het? Een lambda is een anonieme functie tussen accolades. De parameters staan vóór ->; de laatste expressie bepaalt meestal de returnwaarde.

Hoe werkt het? Een lambda is een anonieme functie tussen accolades; parameterlijst en body worden gescheiden door ->. De laatste expressie bepaalt de returnwaarde.

Lambdas.ktKOTLIN
fun main() {
    val hit = { attack: Int, defense: Int -> maxOf(1, attack - defense) }
    println(hit(30, 12))
}
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? Met een lambda beschrijven we een korte berekening zonder voor elk geval een aparte named function te schrijven.

Wanneer kies je iets anders? Gebruik een benoemde functie als de body lang is of semantisch een duidelijke naam verdient.

Toepassing in MonsterBattles: Dezelfde Kotlin-techniek kan damage- of battle-data verwerken; we forceren geen ingewikkelde abstrahering in de basisengine.

27Higher-order functions

Wat is het? Een higher-order function ontvangt een functie als parameter of retourneert een functie. Zo scheid je het algemene proces van de specifieke berekening.

Hoe werkt het? Een higher-order function accepteert een functieparameter of retourneert een functie. Zo kun je bijvoorbeeld een aanpasbare berekening injecteren.

HigherOrder.ktKOTLIN
fun resolveHit(attack: Int, defense: Int, rule: (Int, Int) -> Int): Int =
    rule(attack, defense)

fun main() {
    println(resolveHit(30, 12) { a, d -> maxOf(1, a - d) })
}
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? Battlelogica kan dezelfde aanvalsstappen gebruiken met een verwisselbare damageberekening.

Wanneer kies je iets anders? Een directe aanroep is eenvoudiger als configuratie van de functie nergens nodig is.

Toepassing in MonsterBattles: Dezelfde Kotlin-techniek kan damage- of battle-data verwerken; we forceren geen ingewikkelde abstrahering in de basisengine.

28Trailing lambdas

Wat is het? Als de laatste functieparameter een functie is, mag je de lambda buiten de haakjes schrijven. Dit maakt aanroepen van hogere-ordefuncties vaak leesbaarder.

Hoe werkt het? Wanneer de laatste parameter een lambda is, mag deze buiten de haakjes staan. Met maar één lambdaparameter mogen de haakjes vaak helemaal weg.

TrailingLambdas.ktKOTLIN
fun hit(attack: Int, rule: (Int) -> Int): Int = rule(attack)
fun main() {
    val damage = hit(30) { attack -> attack - 12 }
    println(damage)
}
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? We kunnen een damage rule duidelijk achter een resolveHit-aanroep plaatsen.

Wanneer kies je iets anders? Gebruik haakjes wanneer dat de leesbaarheid van meerdere argumenten verbetert.

Toepassing in MonsterBattles: Dezelfde Kotlin-techniek kan damage- of battle-data verwerken; we forceren geen ingewikkelde abstrahering in de basisengine.

29Implicit it

Wat is het? Bij een lambda met één parameter mag Kotlin de naam it gebruiken. Bij meerdere parameters of complexe code zijn expliciete namen vaak duidelijker.

Hoe werkt het? Bij precies één lambda-parameter mag de impliciete naam it worden gebruikt; benoem de parameter expliciet als dat de leesbaarheid verbetert.

ImplicitIt.ktKOTLIN
fun main() {
    val attacks = listOf(30, 25, 20)
    val boosted = attacks.map { it + 5 }
    println(boosted)
}
▸ Terminal / outputVerwachte uitvoer
[35, 30, 25]

Waarom deze aanpak? Een lijst met monsterstats kan met een korte lambda worden omgezet naar attackwaarden.

Wanneer kies je iets anders? Gebruik expliciete namen bij meerdere geneste lambdas.

Toepassing in MonsterBattles: Dezelfde Kotlin-techniek kan damage- of battle-data verwerken; we forceren geen ingewikkelde abstrahering in de basisengine.

30Lambda return values

Wat is het? De laatste expressie van een lambda is de resultaatwaarde. Met een gelabelde return, bijvoorbeeld return@map, kun je vroeg uit die specifieke lambda-aanroep terugkeren.

Hoe werkt het? Een lambda retourneert standaard de laatste expressie. Met gelabelde returns verlaat je alleen de lambda die bij de betreffende aanroep hoort.

LambdaReturn.ktKOTLIN
fun main() {
    val attacks = listOf(30, -1, 25)
    val valid = attacks.map {
        if (it < 0) return@map 0
        it // Last expression becomes the result
    }
    println(valid)
}
▸ Terminal / outputVerwachte uitvoer
[30, 0, 25]

Waarom deze aanpak? Een lambda kan per monster bepalen of een damagewaarde geldig is zonder de hele omringende functie te verlaten.

Wanneer kies je iets anders? Gebruik een gewone function return buiten lambda’s als de control flow onduidelijk wordt.

Toepassing in MonsterBattles: Dezelfde Kotlin-techniek kan damage- of battle-data verwerken; we forceren geen ingewikkelde abstrahering in de basisengine.

31Function references

Wat is het? Met :: verwijs je naar een bestaande functie zonder deze direct uit te voeren. Een function reference kan dezelfde rol vervullen als een lambda.

Hoe werkt het? Met :: verwijs je naar een bestaande functie als waarde. Het is een alternatief voor een lambda die enkel dezelfde functie doorroept.

FunctionReferences.ktKOTLIN
fun calculateDamage(attack: Int, defense: Int) = maxOf(1, attack - defense)
fun resolve(rule: (Int, Int) -> Int) = rule(30, 12)
fun main() = println(resolve(::calculateDamage))
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? We geven een bestaande calculateDamage-functie door aan een hogere-ordefunctie.

Wanneer kies je iets anders? Gebruik een lambda wanneer je argumenten moet aanpassen of extra gedrag toevoegt.

Toepassing in MonsterBattles: Dezelfde Kotlin-techniek kan damage- of battle-data verwerken; we forceren geen ingewikkelde abstrahering in de basisengine.

32Lambda with receiver

Wat is het? Een function type met receiver, zoals Monster.() -> String, voert de lambda uit alsof het receiver-object de context is. Dit zie je vaak bij Kotlin-DSLs en configuratie.

Hoe werkt het? Een lambda met receiver heeft de vorm T.() -> R; binnen de body kun je members van T via de impliciete receiver benaderen.

ReceiverLambda.ktKOTLIN
class Monster(val name: String, val attack: Int)
fun describe(monster: Monster, block: Monster.() -> String): String = monster.block()
fun main() {
    val pyron = Monster("Pyron", 30)
    println(describe(pyron) { "$name has $attack ATK" })
}
▸ Terminal / outputVerwachte uitvoer
Pyron has 30 ATK

Waarom deze aanpak? We kunnen een monsterbeschrijving opbouwen met directe toegang tot de properties van dat monster.

Wanneer kies je iets anders? Gebruik gewone parameters als een DSL-achtige receiver geen meerwaarde biedt.

Toepassing in MonsterBattles: Dezelfde Kotlin-techniek kan damage- of battle-data verwerken; we forceren geen ingewikkelde abstrahering in de basisengine.

33BattleResult

Wat is het? Een BattleResult is een waardetype dat de uitkomst van een gevecht vastlegt. Met een data class kunnen we winnaar, rondes en resterende HP als resultaat teruggeven.

Hoe werkt het? BattleResult bundelt de uitkomst en het log zonder de opgeslagen monsters te wijzigen. Het is een immutable resultaatwaarde.

BattleResult.ktKOTLIN
data class BattleResult(val winner: String, val rounds: Int, val remainingHp: Int)
fun main() {
    val result = BattleResult("Pyron", 7, 10)
    println("${result.winner} wins in ${result.rounds} rounds")
    println("Remaining HP: ${result.remainingHp}")
}
▸ Terminal / outputVerwachte uitvoer
Pyron wins in 7 rounds
Remaining HP: 10

Waarom deze aanpak? De battle-engine retourneert een resultaat dat later eenvoudig naar een JSON-response kan worden omgezet.

Wanneer kies je iets anders? Retourneer een eenvoudige waarde voor één enkele berekening; voor een battle zijn de meerdere resultaten een data class waard.

Toepassing in MonsterBattles: Dit onderdeel sluit direct aan op de vaste `BattleService`, `BattleResult` en `BattleEvent` van de geïntegreerde Week 03-demo.

34BattleService

Wat is het? BattleService coördineert het gevecht: de snelste begint, beide monsters vallen om beurten aan en HP wordt nooit negatief. De damageformule is maxOf(1, attack - defense).

Hoe werkt het? BattleService verwerkt de volledige aanvalvolgorde. De functie ontvangt twee bestaande Monster-objecten en retourneert een BattleResult.

BattleService.ktKOTLIN
data class Stats(val attack: Int, val defense: Int, val speed: Int)
class Monster(val name: String, val stats: Stats, var hp: Int)

class BattleService {
    fun fight(a: Monster, b: Monster): String {
        val first = if (a.stats.speed >= b.stats.speed) a else b
        val second = if (first === a) b else a
        while (a.hp > 0 && b.hp > 0) {
            second.hp = (second.hp - maxOf(1, first.stats.attack - second.stats.defense))
                .coerceAtLeast(0)
            if (second.hp == 0) break
            first.hp = (first.hp - maxOf(1, second.stats.attack - first.stats.defense))
                .coerceAtLeast(0)
        }
        return if (a.hp > 0) a.name else b.name
    }
}
fun main() {
    val pyron = Monster("Pyron", Stats(30, 10, 110), 100)
    val aquara = Monster("Aquara", Stats(25, 12, 95), 110)
    println("Winner: ${BattleService().fight(pyron, aquara)}")
    println("Pyron HP: ${pyron.hp}")
}
▸ Terminal / outputVerwachte uitvoer
Winner: Pyron
Pyron HP: 10

Waarom deze aanpak? De gevechtsregels horen bij de domein- en servicelogica, niet bij een toekomstige Ktor-route.

Wanneer kies je iets anders? Houd damage als afzonderlijke pure functie in plaats van iedere regel in BattleService te stoppen.

Toepassing in MonsterBattles: Dit onderdeel sluit direct aan op de vaste `BattleService`, `BattleResult` en `BattleEvent` van de geïntegreerde Week 03-demo.

35Battlelog

Wat is het? Een battlelog is een geordende lijst met gebeurtenissen, bijvoorbeeld wie aanvalt, hoeveel damage wordt gedaan en hoeveel HP overblijft. De log kan later onderdeel van een API-response worden.

Hoe werkt het? Elke aanval resulteert in een BattleEvent met turn, deelnemer-IDs, schade en HP na de aanval.

BattleLog.ktKOTLIN
data class BattleEvent(val attacker: String, val damage: Int, val remainingHp: Int)
fun main() {
    val log = mutableListOf<BattleEvent>()
    log += BattleEvent("Pyron", 18, 92)
    log += BattleEvent("Aquara", 15, 85)
    log.forEach { println("${it.attacker}: ${it.damage} damage, target ${it.remainingHp} HP") }
}
▸ Terminal / outputVerwachte uitvoer
Pyron: 18 damage, target 92 HP
Aquara: 15 damage, target 85 HP

Waarom deze aanpak? Door acties als waarden vast te leggen kunnen we een gevecht achteraf tonen en testen.

Wanneer kies je iets anders? Bewaar geen battlehistorie in H2: de minimale game retourneert alleen het log van de huidige simulatie.

Toepassing in MonsterBattles: Dit onderdeel sluit direct aan op de vaste `BattleService`, `BattleResult` en `BattleEvent` van de geïntegreerde Week 03-demo.

36Eerste unit tests

Wat is het? Een unit test controleert een kleine eenheid gedrag onafhankelijk van HTTP of database. In dit voorbeeld gebruiken we kotlin.test met @Test en assertEquals. Het voorbeeld hoort in src/test/kotlin en wordt uitgevoerd met de Amper-testtaak.

Hoe werkt het? Een unit test controleert de feitelijke output met assertions. Test normale waarden, grensgevallen en afwijkend gedrag; een printregel is geen assertion.

DamageCalculatorTest.ktKOTLIN
import kotlin.test.Test
import kotlin.test.assertEquals

fun calculateDamage(attack: Int, defense: Int) = maxOf(1, attack - defense)

class DamageCalculatorTest {
    @Test
    fun `normal damage uses attack minus defense`() {
        assertEquals(18, calculateDamage(30, 12))
    }

    @Test
    fun `damage is always at least one`() {
        assertEquals(1, calculateDamage(5, 20))
    }
}
▸ Terminal / outputVerwachte uitvoer
Verwacht: 2 geslaagde tests (via ./kotlin test)

Waarom deze aanpak? We bewijzen dat normale damage minimaal 1 is en dat Pyron tegen Aquara 18 damage doet. In Week 07 werken we testing uitgebreider uit.

Wanneer kies je iets anders? Een demonstratie met println is nuttig om te leren, maar geen vervanging voor kotlin.test.

Toepassing in MonsterBattles: Dit onderdeel sluit direct aan op de vaste `BattleService`, `BattleResult` en `BattleEvent` van de geïntegreerde Week 03-demo.

Geïntegreerd voorbeeld: de volledige battle-engine

De volgende bestanden zijn gezamenlijk bedoeld voor de echte MonsterBattles-codebase. Plaats ze onder src/nl/monsterbattles/api/ volgens de Amper-projectstructuur. Het domeinmodel uit Week 02 wordt hier hergebruikt; de eerste codebox hieronder is alleen een geheugensteun en hoeft niet opnieuw te worden toegevoegd.

features/monsters/model/Monster.kt + Stats.kt + Element.kt (reeds Week 02)KOTLIN
package nl.monsterbattles.api.features.monsters.model

enum class Element { FIRE, WATER, WIND }

data class Stats(val hp: Int, val attack: Int, val defense: Int, val speed: Int) {
    init {
        require(hp > 0) { "HP must be positive" }
        require(attack >= 0) { "Attack cannot be negative" }
        require(defense >= 0) { "Defense cannot be negative" }
        require(speed >= 0) { "Speed cannot be negative" }
    }
}

class Monster(val id: Int, val name: String, val element: Element, val stats: Stats) {
    init {
        require(id > 0) { "ID must be positive" }
        require(name.isNotBlank()) { "Name is required" }
    }
}
features/battles/model/BattleEvent.kt + BattleResult.ktKOTLIN
package nl.monsterbattles.api.features.battles.model

data class BattleEvent(
    val turn: Int,
    val attackerId: Int,
    val defenderId: Int,
    val damage: Int,
    val defenderRemainingHp: Int
)

data class BattleResult(
    val winnerId: Int,
    val rounds: Int,
    val turns: Int,
    val attackerRemainingHp: Int,
    val defenderRemainingHp: Int,
    val log: List<BattleEvent>
)
features/battles/DamageCalculator.ktKOTLIN
package nl.monsterbattles.api.features.battles

object DamageCalculator {
    fun calculate(attack: Int, defense: Int): Int {
        require(attack >= 0 && defense >= 0)
        return maxOf(1, attack - defense)
    }
}
features/battles/BattleService.ktKOTLIN
package nl.monsterbattles.api.features.battles

import nl.monsterbattles.api.features.monsters.model.Monster
import nl.monsterbattles.api.features.battles.model.BattleEvent
import nl.monsterbattles.api.features.battles.model.BattleResult

class BattleService {
    fun fight(attacker: Monster, defender: Monster): BattleResult {
        require(attacker.id != defender.id) { "Choose two different monsters" }
        val monsters = listOf(attacker, defender)
        val hp = mutableMapOf(attacker.id to attacker.stats.hp, defender.id to defender.stats.hp)
        var active = if (attacker.stats.speed >= defender.stats.speed) 0 else 1
        var turns = 0
        val log = mutableListOf<BattleEvent>()

        while (hp.getValue(attacker.id) > 0 && hp.getValue(defender.id) > 0) {
            val source = monsters[active]
            val target = monsters[1 - active]
            val damage = DamageCalculator.calculate(source.stats.attack, target.stats.defense)
            val remaining = maxOf(0, hp.getValue(target.id) - damage)
            hp[target.id] = remaining
            turns++
            log += BattleEvent(turns, source.id, target.id, damage, remaining)
            active = 1 - active
        }

        val winnerId = if (hp.getValue(attacker.id) > 0) attacker.id else defender.id
        return BattleResult(
            winnerId = winnerId,
            rounds = (turns + 1) / 2,
            turns = turns,
            attackerRemainingHp = hp.getValue(attacker.id),
            defenderRemainingHp = hp.getValue(defender.id),
            log = log.toList()
        )
    }
}
BattleDemo.kt (tijdelijk entrypoint)KOTLIN
package nl.monsterbattles.api

import nl.monsterbattles.api.features.monsters.model.*
import nl.monsterbattles.api.features.battles.BattleService

fun main() {
    val pyron = Monster(1, "Pyron", Element.FIRE, Stats(100, 30, 10, 110))
    val aquara = Monster(2, "Aquara", Element.WATER, Stats(110, 25, 12, 95))
    val result = BattleService().fight(pyron, aquara)
    println("Winner: ${if (result.winnerId == pyron.id) pyron.name else aquara.name}")
    println("Rounds: ${result.rounds} | Turns: ${result.turns}")
    println("Final HP: Pyron=${result.attackerRemainingHp}, Aquara=${result.defenderRemainingHp}")
    println("Log entries: ${result.log.size}")
    println("Stored base HP: Pyron=${pyron.stats.hp}, Aquara=${aquara.stats.hp}")
}
▸ Terminal / outputVerwachte uitvoer
Winner: Pyron
Rounds: 7 | Turns: 13
Final HP: Pyron=10, Aquara=0
Log entries: 13
Stored base HP: Pyron=100, Aquara=110

Ontwerpafweging: BattleService coördineert de simulatie; DamageCalculator berekent schade als pure functie. De tijdelijke hp-map is lokale battletoestand, niet een mutatie van de opgeslagen Monster.stats. De service ontvangt hier twee Monster-objecten; vanaf Week 06 krijgt BattleService via constructor injection toegang tot MonsterRepository.

De geïntegreerde uitvoer is deterministisch. Een ronde telt maximaal twee individuele aanvallen. De log bevat één event per aanval en kan later zonder domeinwijziging naar het vastgelegde JSON-contract worden gemapt.

Activitydiagram van een battle: controle, Speed, aanvallen, damage, HP en winconditie
Activitydiagram v5.0 — besluitpunten en herhaling in de battle-loop. Afbeeldingspad: assets/images/diagrams/08-activity-battle-algorithm.png.

Unit tests voor de battle-engine

Dit is een contextafhankelijk testbestand: het hoort bij een Amper-project met kotlin.test en een geschikte test-runner. Met ./kotlin test controleer je damage, de canonieke battle en gelijke Speed. De code hieronder toont verwachte assertions en mag pas als geslaagd worden aangemerkt na een daadwerkelijke testuitvoering.

test/.../features/battles/BattleServiceTest.ktKOTLIN
package nl.monsterbattles.api.features.battles

import nl.monsterbattles.api.features.monsters.model.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

class BattleServiceTest {
    @Test fun minimumDamage() {
        assertEquals(1, DamageCalculator.calculate(5, 10))
        assertEquals(18, DamageCalculator.calculate(30, 12))
    }

    @Test fun pyronWinsCanonicalBattle() {
        val pyron = Monster(1, "Pyron", Element.FIRE, Stats(100, 30, 10, 110))
        val aquara = Monster(2, "Aquara", Element.WATER, Stats(110, 25, 12, 95))
        val result = BattleService().fight(pyron, aquara)
        assertEquals(1, result.winnerId)
        assertEquals(7, result.rounds)
        assertEquals(13, result.turns)
        assertEquals(10, result.attackerRemainingHp)
        assertEquals(0, result.defenderRemainingHp)
        assertEquals(13, result.log.size)
        assertEquals(100, pyron.stats.hp)
    }

    @Test fun equalSpeedFirstSelectedAttacks() {
        val first = Monster(10, "A", Element.FIRE, Stats(20, 20, 0, 50))
        val second = Monster(11, "B", Element.WATER, Stats(20, 20, 0, 50))
        assertEquals(10, BattleService().fight(first, second).log.first().attackerId)
        assertFailsWith<IllegalArgumentException> { BattleService().fight(first, first) }
    }
}

OO en UML in MonsterBattles

De UML-relaties uit onderwerp 11–13 leggen verschillende betekenissen vast. In de echte v5.0-code heeft Monster een Stats-waarde en een Element-enum; BattleService gebruikt monsters en produceert een resultaat met events. Inheritance en aggregation staan alleen in de zelfstandige onderwijsvoorbeelden en worden niet als bestaande projectrelaties gepresenteerd.

ConceptVoorbeeldOntwerpbetekenis
CompositionMonster → StatsMonster bestaat uit vaste statwaarden
Dependency / associationBattleService gebruikt MonsterService gebruikt de deelnemers
Result ownershipBattleResult → eventsEen simulatieresultaat bevat zijn log
InheritanceOnderwijsvoorbeeld: FireMonster : MonsterNiet in het definitieve basisdomein

Cheat Sheet

Concept / syntaxGebruik
open / finalOvererving expliciet toestaan / standaard verbieden
abstract classOnvolledige superclass met eventueel gedeelde code
override / superGedrag overschrijven / ouderimplementatie gebruiken
Overloading versus overridingAndere argumenten versus andere implementatie
IS-A / HAS-AOvererving versus composition
Association / aggregation / compositionUML: gebruik / zwak geheel-deel / sterk ownership
SOLIDSRP, OCP, LSP, ISP, DIP
High cohesion / low couplingSamenhangende classes, beperkte concrete afhankelijkheden
Pure function / side-effectVoorspelbare berekening / waarneembare toestandswijziging
(Int, Int) -> IntFunction type
{ a, b -> a - b }Lambda
list.map { it + 1 }Higher-order function, trailing lambda en it
::calculateDamageFunction reference
T.() -> RLambda met receiver
maxOf(1, attack - defense)Minstens 1 damage
Round / turnRonde: maximaal twee aanvallen / turn: één aanval
BattleResult / BattleEventImmutable resultaat / logitem per aanval
MonsterBattles Docs · Code, comments & output