TransWikia.com

Game of Life in Kotlin

Code Review Asked by Christian Hujer on December 31, 2021

I’ve implemented a Game of Life in Kotlin.

There are some major requirements on this solution:

  • Purely object-functional production code.
  • As close to an infinite universe as possible.

File src/test/kotlin/…/PointTest.kt

package com.nelkinda.training.gameoflife

import org.junit.jupiter.api.Test
import kotlin.test.assertEquals

internal class PointTest {
    @Test
    fun testToString() = assertEquals("P(0, 1)", P(0, 1).toString())

    @Test
    fun plus() = assertEquals(P(3, 30), P(2, 20) + P(1, 10))

    @Test
    fun neighbors() = assertEquals(
            setOf(P(4, 49), P(4, 50), P(4, 51), P(5, 49), P(5, 51), P(6, 49), P(6, 50), P(6, 51)),
            P(5, 50).neighbors().toSet()
    )
}

File src/main/kotlin/…/Point.kt

package com.nelkinda.training.gameoflife

private val neighbors = (P(-1, -1)..P(1, 1)).filter { it != P(0, 0) }

typealias P = Point

data class Point constructor(private val x: Int, private val y: Int) {
    operator fun plus(p: Point) = P(x + p.x, y + p.y)
    operator fun rangeTo(p: Point) = (x..p.x).map { x -> (y..p.y).map { y -> P(x, y) } }.flatten()
    fun neighbors() = neighbors.map { this + it }
    fun neighbors(predicate: (Point) -> Boolean) = neighbors().filter(predicate)
    override fun toString() = "P($x, $y)"
}

File src/test/kotlin/…/RulesTest.kt

package com.nelkinda.training.gameoflife

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertAll
import kotlin.test.assertEquals

internal class RulesTest {
    private fun assertSurvival(rules: Rules, liveNeighbors: Set<Int>) = assertAll(
            (0..8).map {
                { assertEquals(it in liveNeighbors, rules.survives(it)) }
            }
    )

    private fun assertBirth(rules: Rules, liveNeighbors: Set<Int>) = assertAll(
            (0..8).map {
                { assertEquals(it in liveNeighbors, rules.born(it)) }
            }
    )

    @Test
    fun testConwayRules() = assertAll(
            { assertEquals("R 23/3", ConwayRules.toString()) },
            { assertSurvival(ConwayRules, setOf(2, 3)) },
            { assertBirth(ConwayRules, setOf(3)) }
    )
}

File src/main/kotlin/…/Rules.kt

package com.nelkinda.training.gameoflife

@Suppress("MagicNumber")
val ConwayRules: Rules = StandardRules(setOf(2, 3), setOf(3))

interface Rules {
    fun survives(liveNeighbors: Int): Boolean
    fun born(liveNeighbors: Int): Boolean
}

data class StandardRules(
    private val liveNeighborsForSurvival: Set<Int>,
    private val liveNeighborsForBirth: Set<Int>
) : Rules {
    private fun Set<Int>.toStr() = sorted().joinToString("")
    override fun survives(liveNeighbors: Int) = liveNeighbors in liveNeighborsForSurvival
    override fun born(liveNeighbors: Int) = liveNeighbors in liveNeighborsForBirth
    override fun toString() = "R ${liveNeighborsForSurvival.toStr()}/${liveNeighborsForBirth.toStr()}"
}

File src/test/kotlin/…/UniverseTest.kt

package com.nelkinda.training.gameoflife

import org.junit.jupiter.api.Test
import kotlin.test.assertEquals

internal class UniverseTest {
    @Test
    fun testToString() = assertEquals("Universe{R 23/3n[P(0, 1)]}", Universe(life = setOf(P(0, 1))).toString())
}

File src/main/kotlin/…/Universe.kt

package com.nelkinda.training.gameoflife

internal typealias Cell = Point

@Suppress("TooManyFunctions")
data class Universe constructor(
        private val rules: Rules = ConwayRules,
        private val life: Set<Cell>
) {
    operator fun inc() = Universe(rules, survivingCells() + bornCells())

    private fun survivingCells() = life.filter { it.survives() }.toSet()
    private fun bornCells() = deadNeighborsOfLivingCells().filter { it.born() }.toSet()
    private fun deadNeighborsOfLivingCells() = life.flatMap { it.deadNeighbors() }

    private fun Cell.isAlive() = this in life
    private fun Cell.survives() = rules.survives(countLiveNeighbors())
    private fun Cell.born() = rules.born(countLiveNeighbors())
    private fun Cell.deadNeighbors() = neighbors { !it.isAlive() }
    private fun Cell.liveNeighbors() = neighbors { it.isAlive() }
    private fun Cell.countLiveNeighbors() = liveNeighbors().count()

    override fun toString() = "Universe{$rulesn$life}"
}

At this point, you may suspect missing tests, but wait until you’ve seen the .feature file.

File src/test/kotlin/…/ParserTest.kt

package com.nelkinda.training.gameoflife

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertAll
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals

internal class ParserTest {
    private fun parses(spec: String, vararg cells: Cell) =
            assertEquals(Universe(life = setOf(*cells)), `parse simplified Life 1_05`(spec))

    @Test
    fun testParses() = assertAll(
            { parses("") },
            { parses("*", P(0, 0)) },
            { parses("**", P(0, 0), P(1, 0)) },
            { parses("*n*", P(0, 0), P(0, 1)) },
            { parses("*.*", P(0, 0), P(2, 0)) }
    )

    @Test
    fun testInvalid() {
        val e = assertThrows<IllegalArgumentException> { parses("o") }
        assertEquals("Unexpected character 'o' at line 1, column 1", e.message)
    }
}

File src/test/kotlin/…/Parser.kt

package com.nelkinda.training.gameoflife

internal fun `parse simplified Life 1_05`(life1_05: String): Universe {
    val cells = HashSet<Cell>()
    var line = 1
    var column = 1
    val syntax = mapOf(
            'n' to { line++; column = 0 },
            '*' to { cells.add(P(column - 1, line - 1)) },
            '.' to { }
    ).withDefault { c -> throw IllegalArgumentException("Unexpected character '$c' at line $line, column $column") }
    life1_05.forEach {
        syntax.getValue(it).invoke()
        column++
    }
    return Universe(life = cells.toSet())
}

File src/test/kotlin/…/GameOfLifeSteps.kt

package com.nelkinda.training.gameoflife

import io.cucumber.java.en.Given
import io.cucumber.java.en.Then
import kotlin.test.assertEquals

class GameOfLifeSteps {
    private lateinit var universe: Universe

    @Given("the following universe:")
    fun defineUniverse(spec: String) {
        universe = `parse simplified Life 1_05`(spec)
    }

    @Then("the next generation MUST be:")
    fun assertNextGenerationEquals(spec: String) {
        ++universe
        assertEquals(`parse simplified Life 1_05`(spec), universe)
    }
}

File src/test/kotlin/…/RunCukesTest.kt

package com.nelkinda.training.gameoflife

import io.cucumber.junit.Cucumber
import io.cucumber.junit.CucumberOptions
import org.junit.runner.RunWith

@CucumberOptions(
        features = ["src/test/resources/features"],
        strict = true
)
@RunWith(Cucumber::class)
class RunCukesTest

File src/test/resources/features/GameOfLife.feature

Feature: Conway Game of Life

  Scenario: Empty universe
    Given the following universe:
    """
    """
    Then the next generation MUST be:
    """
    """

  Scenario: Single cell universe
    Given the following universe:
    """
    *
    """
    Then the next generation MUST be:
    """
    """

  Scenario: Block
    Given the following universe:
    """
    **
    **
    """
    Then the next generation MUST be:
    """
    **
    **
    """

  Scenario: Blinker
    Given the following universe:
    """
    .*
    .*
    .*
    """
    Then the next generation MUST be:
    """

    ***

    """
    Then the next generation MUST be:
    """
    .*
    .*
    .*
    """

Note: The actual feature file includes additional documentation and a scenario for the Glider which I have omitted from here for brevity, and has Conway's Game of Life as feature title, which breaks StackOverflow/Google Prettify Gherkin syntax highlighting, so I’ve removed the 's from it in this copy here.

If you prefer to review the code in GitHub: https://github.com/nelkinda/gameoflife-kotlin

Poke at it, tell me anything you find.

Add your own answers!

Ask a Question

Get help from others!

© 2024 TransWikia.com. All rights reserved. Sites we Love: PCI Database, UKBizDB, Menu Kuliner, Sharing RPP