3
\$\begingroup\$

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/3\n[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{$rules\n$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.

\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Browse other questions tagged or ask your own question.