diff --git a/.gitignore b/.gitignore index 7b42fcf10f..5ffcb45af4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ scripts.txt /data/.temp/* /data/avatars/* +# Scene editor data is deployment-specific; each server may implement its own. +/data/area/scene/ + # tools *.jar /data/dump/ diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/ConfigFiles.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/ConfigFiles.kt index 14f2bbc80a..b0cecf6fb9 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/ConfigFiles.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/ConfigFiles.kt @@ -60,18 +60,20 @@ private fun walkPath( lastUpdated: Long, invalidatedExtensions: MutableSet, ) { - for (path in Files.newDirectoryStream(dir)) { - val name = path.name - if (!name.endsWith(".toml")) { - walkPath(map, path, lastUpdated, invalidatedExtensions) - continue - } - val extension = name.substringAfter('.') - map.getOrPut(extension) { ObjectArrayList() }.add(path.pathString) + Files.newDirectoryStream(dir).use { paths -> + for (path in paths) { + val name = path.name + if (!name.endsWith(".toml")) { + walkPath(map, path, lastUpdated, invalidatedExtensions) + continue + } + val extension = name.substringAfter('.') + map.getOrPut(extension) { ObjectArrayList() }.add(path.pathString) - // Check file-type hasn't been marked as invalidated before checking the last modified time for invalidation - if (!invalidatedExtensions.contains(extension) && Files.getLastModifiedTime(path).toMillis() > lastUpdated) { - invalidatedExtensions.add(extension) + // Check file-type hasn't been marked as invalidated before checking the last modified time for invalidation + if (!invalidatedExtensions.contains(extension) && Files.getLastModifiedTime(path).toMillis() > lastUpdated) { + invalidatedExtensions.add(extension) + } } } } @@ -80,13 +82,15 @@ private fun cacheChanged( lastUpdated: Long, dir: Path = Path.of(Settings["storage.cache.path"]), ): Boolean { - for (path in Files.newDirectoryStream(dir)) { - if (!path.extension.startsWith("dat") && !path.extension.startsWith("idx")) { - continue - } - val lastModified = Files.getLastModifiedTime(path).toMillis() - if (lastModified > lastUpdated) { - return true + Files.newDirectoryStream(dir).use { paths -> + for (path in paths) { + if (!path.extension.startsWith("dat") && !path.extension.startsWith("idx")) { + continue + } + val lastModified = Files.getLastModifiedTime(path).toMillis() + if (lastModified > lastUpdated) { + return true + } } } return false diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPCSpawns.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPCSpawns.kt index 0bb047fb84..9f2de75cc6 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPCSpawns.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPCSpawns.kt @@ -20,8 +20,14 @@ fun loadNpcSpawns(files: ConfigFiles, reload: Boolean = false) { NPCs.clear() val file = File("${Settings["storage.caching.path"]}${Settings["storage.caching.npcSpawns"]}") val extension = Settings["spawns.npcs"] - if (reload || !file.exists() || files.extensions.contains(extension)) { - val paths = files.list(extension) + val editorPath = Settings["spawns.npcs.editor", ""] + val editorFile = if (editorPath.isBlank()) null else File(Settings["storage.data"], editorPath) + val paths = files.list(extension).toMutableList() + if (editorFile?.isFile == true && editorFile.path !in paths) { + paths += editorFile.path + } + val hasEditorSpawns = editorFile?.isFile == true + if (reload || !file.exists() || files.extensions.contains(extension) || hasEditorSpawns) { loadNormal(paths, file, Settings["storage.caching.active", false]) } else { loadFast(file) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/ObjectSpawns.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/ObjectSpawns.kt index 7f8a5a02c9..3a415ff68a 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/ObjectSpawns.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/obj/ObjectSpawns.kt @@ -9,10 +9,19 @@ import world.gregs.voidps.type.Tile private val logger = InlineLogger() -fun loadObjectSpawns(paths: List) = timedLoad("object spawn") { - GameObjects.reset() - val membersWorld = World.members - var count = 0 +data class ObjectSpawn( + val id: String, + val x: Int, + val y: Int, + val level: Int, + val type: Int, + val rotation: Int, + val members: Boolean = false, + val remove: Boolean = false, +) + +fun readObjectSpawns(paths: List): List { + val spawns = mutableListOf() for (path in paths) { Config.fileReader(path) { while (nextPair()) { @@ -25,6 +34,7 @@ fun loadObjectSpawns(paths: List) = timedLoad("object spawn") { var level = 0 var type = 10 var members = false + var remove = false while (nextEntry()) { when (val key = key()) { "id" -> id = string() @@ -34,23 +44,39 @@ fun loadObjectSpawns(paths: List) = timedLoad("object spawn") { "rotation" -> rotation = int() "type" -> type = int() "members" -> members = boolean() + "remove" -> remove = boolean() else -> throw IllegalArgumentException("Unexpected key: '$key' ${exception()}") } } - if (!membersWorld && members) { - continue - } - val tile = Tile(x, y, level) - val definition = ObjectDefinitions.getOrNull(id) - if (definition == null) { - logger.warn { "Invalid object spawn id '$id' in $path." } - } else { - GameObjects.add(GameObject(definition.id, tile.x, tile.y, tile.level, type, rotation)) - count++ - } + spawns += ObjectSpawn(id, x, y, level, type, rotation, members, remove) } } } } + return spawns +} + +fun loadObjectSpawns(paths: List) = timedLoad("object spawn") { + GameObjects.reset() + val membersWorld = World.members + var count = 0 + for (spawn in readObjectSpawns(paths)) { + if (!membersWorld && spawn.members) { + continue + } + val tile = Tile(spawn.x, spawn.y, spawn.level) + val definition = ObjectDefinitions.getOrNull(spawn.id) + if (definition == null) { + logger.warn { "Invalid object spawn id '${spawn.id}'." } + continue + } + val gameObject = GameObject(definition.id, tile.x, tile.y, tile.level, spawn.type, spawn.rotation) + if (spawn.remove) { + GameObjects.remove(gameObject, collision = true) + } else { + GameObjects.add(gameObject) + count++ + } + } count } diff --git a/game/src/main/kotlin/content/entity/player/command/SceneEditorCommands.kt b/game/src/main/kotlin/content/entity/player/command/SceneEditorCommands.kt new file mode 100644 index 0000000000..f253250724 --- /dev/null +++ b/game/src/main/kotlin/content/entity/player/command/SceneEditorCommands.kt @@ -0,0 +1,140 @@ +package content.entity.player.command + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.command.adminCommand +import world.gregs.voidps.engine.client.command.intArg +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.data.definition.ItemDefinitions +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.type.Tile +import world.gregs.voidps.engine.entity.obj.ObjectShape + +/** + * Admin hooks used by the void-client scene editor (`ed save`). + * + * Each place/remove command updates editor.obj-spawns.toml and reloads object spawns. + * `scene_flush` explicitly reloads the persisted scene changes for client compatibility. + */ +class SceneEditorCommands : Script { + + init { + adminCommand( + "scene_place", + intArg("object-id"), + intArg("x"), + intArg("y"), + intArg("plane"), + intArg("rotation", optional = true), + intArg("shape", optional = true), + desc = "Place a scene-editor object, persist it, and refresh object spawns", + handler = ::place, + ) + adminCommand( + "scene_remove", + intArg("object-id"), + intArg("x"), + intArg("y"), + intArg("plane"), + intArg("rotation", optional = true), + intArg("shape", optional = true), + desc = "Remove a scene object, persist it, and refresh object spawns", + handler = ::remove, + ) + adminCommand( + "scene_flush", + desc = "Reload persisted scene-editor object changes", + handler = ::flush, + ) + adminCommand( + "scene_npc_spawn", + intArg("npc-id"), + intArg("x"), + intArg("y"), + intArg("plane"), + desc = "Spawn a server-backed NPC for the scene editor", + handler = ::spawnNpc, + ) + adminCommand( + "scene_item_drop", + intArg("item-id"), + intArg("x"), + intArg("y"), + intArg("plane"), + intArg("amount", optional = true), + desc = "Drop a server-backed item for the scene editor", + handler = ::dropItem, + ) + adminCommand( + "scene_status", + desc = "Show persisted scene-editor placements / removals", + handler = ::status, + ) + } + + fun place(player: Player, args: List) { + val id = args[0].toInt() + val x = args[1].toInt() + val y = args[2].toInt() + val plane = args[3].toInt() + val rotation = args.getOrNull(4)?.toIntOrNull() ?: 0 + val shape = args.getOrNull(5)?.toIntOrNull() ?: ObjectShape.CENTRE_PIECE_STRAIGHT + val result = SceneEditorPersist.place(id, x, y, plane, rotation, shape) + player.message(result, ChatType.Console) + } + + fun remove(player: Player, args: List) { + val id = args[0].toInt() + val x = args[1].toInt() + val y = args[2].toInt() + val plane = args[3].toInt() + val rotation = args.getOrNull(4)?.toIntOrNull() ?: 0 + val shape = args.getOrNull(5)?.toIntOrNull() ?: ObjectShape.CENTRE_PIECE_STRAIGHT + val result = SceneEditorPersist.remove(id, x, y, plane, rotation, shape) + player.message(result, ChatType.Console) + } + + fun spawnNpc(player: Player, args: List) { + val result = SceneEditorPersist.spawnNpc( + args[0].toInt(), + args[1].toInt(), + args[2].toInt(), + args[3].toInt(), + ) + player.message(result, ChatType.Console) + } + + fun dropItem(player: Player, args: List) { + val itemId = args[0].toInt() + val definition = ItemDefinitions.getOrNull(itemId) + if (definition == null) { + player.message("unknown item id $itemId", ChatType.Console) + return + } + val amount = (args.getOrNull(4)?.toIntOrNull() ?: 1).coerceAtLeast(1) + val id = definition.stringId.ifBlank { itemId.toString() } + FloorItems.add( + tile = Tile(args[1].toInt(), args[2].toInt(), args[3].toInt()), + id = id, + amount = amount, + revealTicks = FloorItems.IMMEDIATE, + disappearTicks = 300, + owner = null as String?, + ) + player.message("dropped $id (#$itemId) x$amount @ ${args[1]},${args[2]},${args[3]}", ChatType.Console) + } + + fun flush(player: Player, args: List) { + try { + player.message(SceneEditorPersist.flush(), ChatType.Console) + } catch (t: Throwable) { + player.message("scene_flush failed: ${t.message}", ChatType.Console) + t.printStackTrace() + } + } + + fun status(player: Player, args: List) { + player.message(SceneEditorPersist.status(), ChatType.Console) + } +} diff --git a/game/src/main/kotlin/content/entity/player/command/SceneEditorPersist.kt b/game/src/main/kotlin/content/entity/player/command/SceneEditorPersist.kt new file mode 100644 index 0000000000..080f4b69c1 --- /dev/null +++ b/game/src/main/kotlin/content/entity/player/command/SceneEditorPersist.kt @@ -0,0 +1,206 @@ +package content.entity.player.command + +import com.github.michaelbull.logging.InlineLogger +import world.gregs.config.Config +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.data.configFiles +import world.gregs.voidps.engine.entity.character.npc.loadNpcSpawns +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.data.definition.ObjectDefinitions +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectShape +import world.gregs.voidps.engine.entity.obj.loadObjectSpawns +import world.gregs.voidps.engine.entity.obj.readObjectSpawns +import world.gregs.voidps.type.Tile +import java.io.File + +object SceneEditorPersist { + private val logger = InlineLogger() + private val lock = Any() + + data class Entry( + val objectId: Int, + val x: Int, + val y: Int, + val plane: Int, + val rotation: Int, + val shape: Int, + val remove: Boolean, + ) + data class NpcEntry( + val id: String, + val x: Int, + val y: Int, + val plane: Int, + ) + + fun place(objectId: Int, x: Int, y: Int, plane: Int, rotation: Int = 0, shape: Int = ObjectShape.CENTRE_PIECE_STRAIGHT): String = synchronized(lock) { + val definition = ObjectDefinitions.getOrNull(objectId) ?: return "unknown object id $objectId" + val entry = Entry(definition.id, x, y, plane, rotation and 3, shape, remove = false) + update(entry) + reload() + "placed ${definition.stringId.ifBlank { definition.id.toString() }} @ $x,$y,$plane rot=${entry.rotation}" + } + + fun remove(objectId: Int, x: Int, y: Int, plane: Int, rotation: Int = 0, shape: Int = ObjectShape.CENTRE_PIECE_STRAIGHT): String = synchronized(lock) { + val tile = Tile(x, y, plane) + val removed = removeLive(objectId, tile, shape, rotation and 3) + update(Entry(objectId, x, y, plane, rotation and 3, shape, remove = true)) + reload() + if (removed) { + "removed $objectId @ $x,$y,$plane (collision cleared)" + } else { + "queued remove $objectId @ $x,$y,$plane (not in live map)" + } + } + + fun spawnNpc(definitionId: Int, x: Int, y: Int, plane: Int): String = synchronized(lock) { + val definition = NPCDefinitions.getOrNull(definitionId) + ?: return "unknown NPC id $definitionId" + val id = definition.stringId.ifBlank { definitionId.toString() } + val tile = Tile(x, y, plane) + if (NPCs.findOrNull(tile, id) != null) { + return "NPC already present: $id @ $x,$y,$plane" + } + val entries = readNpcEntries().toMutableList() + if (entries.none { it.id == id && it.x == x && it.y == y && it.plane == plane }) { + entries += NpcEntry(id, x, y, plane) + writeNpcSpawns(entries) + } + NPCs.add(id, tile) + "spawned $id (#$definitionId) @ $x,$y,$plane" + } + fun flush(): String = synchronized(lock) { + val entries = readEntries() + reload() + loadNpcSpawns(configFiles(), reload = true) + logger.info { "scene editor reloaded ${entries.size} persisted changes" } + "reloaded ${entries.size} scene-editor change(s) from ${file.name}" + } + + fun status(): String = synchronized(lock) { + val entries = readEntries() + "stored=+${entries.count { !it.remove }}/-${entries.count { it.remove }}" + } + + private fun update(entry: Entry) { + val entries = readEntries().toMutableList() + val index = entries.indexOfFirst { it.x == entry.x && it.y == entry.y && it.plane == entry.plane && it.shape == entry.shape } + if (index == -1) { + entries += entry + } else { + entries[index] = entry + } + writeToml(entries) + } + private fun reload() { + val files = configFiles() + loadObjectSpawns(files.list(Settings["spawns.objects"])) + } + + private fun removeLive(objectId: Int, tile: Tile, shape: Int, rotation: Int): Boolean { + val byShape = GameObjects.getShape(tile, shape) + if (byShape != null && byShape.intId == objectId) { + GameObjects.remove(byShape, collision = true) + return true + } + val byId = GameObjects.findOrNull(tile, objectId) + if (byId != null) { + GameObjects.remove(byId, collision = true) + return true + } + val probe = GameObject(objectId, tile, shape, rotation) + if (GameObjects.contains(probe)) { + GameObjects.remove(probe, collision = true) + return true + } + return false + } + + private fun readEntries(): List { + if (!file.exists()) { + return emptyList() + } + return readObjectSpawns(listOf(file.path)).mapNotNull { spawn -> + val objectId = spawn.id.toIntOrNull() ?: ObjectDefinitions.getOrNull(spawn.id)?.id ?: return@mapNotNull null + Entry(objectId, spawn.x, spawn.y, spawn.level, spawn.rotation and 3, spawn.type, spawn.remove) + } + } + + private fun writeToml(entries: List) { + file.parentFile.mkdirs() + file.writeText( + buildString { + appendLine("# Auto-generated by scene editor. Do not hand-edit.") + appendLine("spawns = [") + for (entry in entries.sortedWith(compareBy({ it.x }, { it.y }, { it.plane }, { it.shape }))) { + val id = ObjectDefinitions.getOrNull(entry.objectId)?.stringId?.takeIf { it.isNotBlank() } + ?: entry.objectId.toString() + append(" { id = \"").append(id).append("\", x = ").append(entry.x) + .append(", y = ").append(entry.y).append(", level = ").append(entry.plane) + .append(", type = ").append(entry.shape).append(", rotation = ").append(entry.rotation) + if (entry.remove) { + append(", remove = true") + } + appendLine(" },") + } + appendLine("]") + }, + ) + } + + private fun readNpcEntries(): List { + if (!npcFile.exists()) { + return emptyList() + } + val entries = mutableListOf() + Config.fileReader(npcFile.path, 150) { + while (nextPair()) { + require(key() == "spawns") + while (nextElement()) { + var id = "" + var x = 0 + var y = 0 + var plane = 0 + while (nextEntry()) { + when (key()) { + "id" -> id = string() + "x" -> x = int() + "y" -> y = int() + "level" -> plane = int() + else -> throw IllegalArgumentException("Unexpected key '${key()}' ${exception()}") + } + } + if (id.isNotBlank()) { + entries += NpcEntry(id, x, y, plane) + } + } + } + } + return entries + } + + private fun writeNpcSpawns(entries: List) { + npcFile.parentFile.mkdirs() + npcFile.writeText( + buildString { + appendLine("# Auto-generated by scene editor. Do not hand-edit.") + appendLine("spawns = [") + for (entry in entries.sortedWith(compareBy({ it.x }, { it.y }, { it.plane }, { it.id }))) { + append(" { id = \"").append(entry.id).append("\", x = ").append(entry.x) + .append(", y = ").append(entry.y).append(", level = ").append(entry.plane) + .appendLine(" },") + } + appendLine("]") + }, + ) + } + + private val npcFile: File + get() = File(Settings["storage.data"], "area/scene/editor.npc-spawns.toml") + + private val file: File + get() = File(Settings["storage.data"], "area/scene/editor.obj-spawns.toml") +} diff --git a/game/src/main/resources/game.properties b/game/src/main/resources/game.properties index 97225a13a4..432df54392 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -487,6 +487,7 @@ spawns.objects=obj-spawns.toml # Path to the npc spawn files spawns.npcs=npc-spawns.toml +spawns.npcs.editor=area/scene/editor.npc-spawns.toml # Path to the floor item spawn files spawns.items=item-spawns.toml diff --git a/tools/build.gradle.kts b/tools/build.gradle.kts index 4e5cdb117d..bb64efa100 100644 --- a/tools/build.gradle.kts +++ b/tools/build.gradle.kts @@ -53,6 +53,15 @@ tasks.register("importPetTranscript") { workingDir = rootDir } +tasks.register("updateObjectSpawns") { + description = "Applies an object spawn TOML file to map archives in a cache." + mainClass.set("world.gregs.voidps.tools.map.UpdateObjectSpawns") + classpath = sourceSets["main"].runtimeClasspath + workingDir = rootDir + val cliArgs = (findProperty("args") as String?)?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() + args = cliArgs +} + tasks.register("fixEnums") { classpath = sourceSets["main"].runtimeClasspath mainClass.set("world.gregs.voidps.tools.cache.FixEnums") diff --git a/tools/src/main/kotlin/world/gregs/voidps/tools/map/UpdateObjectSpawns.kt b/tools/src/main/kotlin/world/gregs/voidps/tools/map/UpdateObjectSpawns.kt new file mode 100644 index 0000000000..1fe16b8ef8 --- /dev/null +++ b/tools/src/main/kotlin/world/gregs/voidps/tools/map/UpdateObjectSpawns.kt @@ -0,0 +1,91 @@ +package world.gregs.voidps.tools.map + +import world.gregs.voidps.cache.CacheDelegate +import world.gregs.voidps.cache.definition.data.MapDefinition +import world.gregs.voidps.cache.definition.data.MapObject +import world.gregs.voidps.cache.definition.decoder.ObjectDecoder +import world.gregs.voidps.cache.definition.encoder.MapObjectEncoder +import world.gregs.voidps.buffer.write.ArrayWriter +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.data.configFiles +import world.gregs.voidps.engine.data.definition.ObjectDefinitions +import world.gregs.voidps.engine.entity.obj.ObjectSpawn +import world.gregs.voidps.engine.entity.obj.readObjectSpawns +import world.gregs.voidps.type.Region +import world.gregs.voidps.type.Tile +import java.io.File + +/** + * Applies object spawn edits from a TOML file to map archives in a writable cache. + * + * Usage: ./gradlew :tools:updateObjectSpawns -Pargs="path/to/obj-spawns.toml [cache-path]" + */ +object UpdateObjectSpawns { + @JvmStatic + fun main(args: Array) { + require(args.isNotEmpty()) { "Usage: UpdateObjectSpawns [cache-path]" } + Settings.load() + val spawnPath = File(args[0]) + require(spawnPath.isFile) { "Object spawn file does not exist: ${spawnPath.path}" } + val cache = CacheDelegate(args.getOrNull(1) ?: Settings["storage.cache.path"]) + try { + val files = configFiles() + ObjectDefinitions.init(ObjectDecoder(member = true, lowDetail = false).load(cache)) + .load(files.list(Settings["definitions.objects"])) + val spawns = readObjectSpawns(listOf(spawnPath.path)) + val regions = apply(cache, spawns) + cache.update() + invalidateServerCaches() + println("Applied ${spawns.size} object spawn change(s) to $regions region(s).") + } finally { + cache.close() + } + } + + private fun apply(cache: CacheDelegate, spawns: List): Int { + val byRegion = spawns.groupBy { Region(Tile(it.x, it.y, it.level).region.id) } + val encoder = MapObjectEncoder() + val writer = ArrayWriter(45_000) + var changedRegions = 0 + for ((region, entries) in byRegion) { + val archive = "l${region.x}_${region.y}" + if (cache.data(world.gregs.voidps.cache.Index.MAPS, archive) == null) { + println("Skipping missing map archive $archive") + continue + } + val definition = MapDefinition(region.id) + MapObjectDefinitionDecoder().decode(cache, definition, modified = false) + for (spawn in entries) { + val objectId = ObjectDefinitions.getOrNull(spawn.id)?.id + if (objectId == null) { + println("Skipping unknown object id '${spawn.id}'") + continue + } + val localX = spawn.x and 0x3f + val localY = spawn.y and 0x3f + definition.objects.removeAll { + it.x == localX && it.y == localY && it.level == spawn.level && it.shape == spawn.type + } + if (!spawn.remove) { + definition.objects.add(MapObject(objectId, localX, localY, spawn.level, spawn.type, spawn.rotation and 3)) + } + } + writer.clear() + with(encoder) { + writer.encode(definition) + } + cache.write(world.gregs.voidps.cache.Index.MAPS, archive, writer.toArray()) + changedRegions++ + } + return changedRegions + } + + private fun invalidateServerCaches() { + if (!Settings["storage.caching.active", false]) { + return + } + val path = Settings["storage.caching.path"] + File(path, Settings["storage.caching.objects"]).delete() + File(path, Settings["storage.caching.collisions"]).delete() + } +}