Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions game/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ plugins {

dependencies {
implementation(project(":engine"))
implementation(project(":buffer"))
implementation(project(":cache"))
implementation(project(":network"))
implementation(project(":types"))
Expand Down
2 changes: 2 additions & 0 deletions game/src/main/kotlin/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ object Main {
private fun cache(cache: Cache, files: ConfigFiles): Module {
val members = Settings["world.members", false]
val module = module {
// Needed by scene editor flush (JS5 rewrite) and anything else that get<Cache>().
single { cache }
single(createdAtStart = true) {
get<ObjectDefinitions>()
MapDefinitions(CollisionDecoder(), cache).load(files)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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.entity.obj.ObjectShape

/**
* Admin hooks used by the void-client scene editor (`ed save`).
*
* - `scene_place <id> <x> <y> <plane> [rot] [shape]` — live GameObjects add
* - `scene_remove <id> <x> <y> <plane> [rot] [shape]` — live remove + clear collision
* - `scene_flush` — write obj-spawns.toml + JS5 `lX_Y` (adds + deletes) + drop map cache
*/
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 into the live world (and queue for flush)",
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 world object and clear its collision (queue for JS5 flush)",
handler = ::remove,
)
adminCommand(
"scene_flush",
desc = "Persist queued scene objects to obj-spawns.toml + JS5 map archives",
handler = ::flush,
)
adminCommand(
"scene_status",
desc = "Show queued scene-editor placements / removals",
handler = ::status,
)
}

fun place(player: Player, args: List<String>) {
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<String>) {
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 flush(player: Player, args: List<String>) {
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<String>) {
player.message(SceneEditorPersist.status(), ChatType.Console)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package content.entity.player.command

import com.github.michaelbull.logging.InlineLogger
import world.gregs.voidps.buffer.read.ArrayReader
import world.gregs.voidps.buffer.write.BufferWriter
import world.gregs.voidps.cache.Cache
import world.gregs.voidps.cache.CacheDelegate
import world.gregs.voidps.cache.Index
import world.gregs.voidps.cache.definition.data.MapDefinition
import world.gregs.voidps.cache.definition.data.MapObject
import world.gregs.voidps.cache.definition.encoder.MapObjectEncoder
import world.gregs.voidps.engine.data.Settings
import world.gregs.voidps.engine.data.definition.ObjectDefinitions
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.type.Region
import world.gregs.voidps.type.Tile
import java.io.File
import java.util.concurrent.ConcurrentHashMap

/**
* Persists scene-editor placements / removals for admins:
* 1. live [GameObjects] + collision (walk / chop work immediately)
* 2. data/area/scene/editor.obj-spawns.toml (adds survive restart)
* 3. JS5 map archive lX_Y rewritten for both adds and deletes + invalidate map cache
*/
object SceneEditorPersist {
private val logger = InlineLogger()

private val placed = ConcurrentHashMap<Long, Entry>()
private val removed = ConcurrentHashMap<Long, Entry>()

data class Entry(
val objectId: Int,
val x: Int,
val y: Int,
val plane: Int,
val rotation: Int,
val shape: Int,
)

fun place(objectId: Int, x: Int, y: Int, plane: Int, rotation: Int = 0, shape: Int = ObjectShape.CENTRE_PIECE_STRAIGHT): String {
val def = ObjectDefinitions.getOrNull(objectId) ?: return "unknown object id $objectId"
val tile = Tile(x, y, plane)
val rot = rotation and 3
val key = packKey(x, y, plane, shape)
removeLive(objectId, tile, shape, rot)
val obj = GameObject(def.id, tile, shape, rot)
GameObjects.add(obj, collision = true)
val entry = Entry(def.id, x, y, plane, rot, shape)
placed[key] = entry
removed.remove(key)
return "placed ${def.stringId.ifBlank { def.id.toString() }} @ $x,$y,$plane rot=$rot"
}

fun remove(objectId: Int, x: Int, y: Int, plane: Int, rotation: Int = 0, shape: Int = ObjectShape.CENTRE_PIECE_STRAIGHT): String {
val tile = Tile(x, y, plane)
val rot = rotation and 3
val key = packKey(x, y, plane, shape)
val had = removeLive(objectId, tile, shape, rot)
placed.remove(key)
removed[key] = Entry(objectId, x, y, plane, rot, shape)
return if (had) {
"removed $objectId @ $x,$y,$plane (collision cleared)"
} else {
"queued remove $objectId @ $x,$y,$plane (not in live map)"
}
}

fun flush(): String {
if (placed.isEmpty() && removed.isEmpty()) {
return "nothing to flush"
}
val places = placed.values.toList()
val removes = removed.values.toList()
writeToml(places)
val regions = writeJs5(places, removes)
invalidateMapCache()
logger.info { "scene flush places=${places.size} removes=${removes.size} regions=$regions" }
return "flushed +${places.size}/-${removes.size} across $regions region(s) (toml+js5)"
}

fun status(): String = "pending=+${placed.size}/-${removed.size}"

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 packKey(x: Int, y: Int, plane: Int, shape: Int): Long =
(x.toLong() and 0x3fff) or ((y.toLong() and 0x3fff) shl 14) or ((plane.toLong() and 3) shl 28) or ((shape.toLong() and 0x1f) shl 30)

private fun writeToml(entries: List<Entry>) {
val dir = File(Settings["storage.data"], "area/scene")
if (!dir.exists() && !dir.mkdirs()) {
throw IllegalStateException("cannot create ${dir.absolutePath}")
}
val file = File(dir, "editor.obj-spawns.toml")
val sb = StringBuilder()
sb.append("# Auto-generated by scene editor flush — do not hand-edit.\n")
sb.append("spawns = [\n")
for (e in entries.sortedWith(compareBy({ it.x }, { it.y }, { it.plane }))) {
val id = ObjectDefinitions.getOrNull(e.objectId)?.stringId?.takeIf { it.isNotBlank() }
?: e.objectId.toString()
sb.append(" { id = \"").append(id).append("\", x = ").append(e.x)
.append(", y = ").append(e.y).append(", level = ").append(e.plane)
.append(", type = ").append(e.shape).append(", rotation = ").append(e.rotation)
.append(" },\n")
}
sb.append("]\n")
file.writeText(sb.toString())
}

private fun writeJs5(places: List<Entry>, removes: List<Entry>): Int {
// Runtime FileCache/MemoryCache is read-only; open a writable CacheDelegate for JS5 rewrite.
val cache = CacheDelegate(Settings["storage.cache.path"])
try {
return writeJs5(cache, places, removes)
} finally {
cache.close()
}
}

private fun writeJs5(cache: Cache, places: List<Entry>, removes: List<Entry>): Int {
val byRegion = LinkedHashMap<Region, MutableList<Pair<String, Entry>>>()
fun bucket(kind: String, e: Entry) {
val region = Tile(e.x, e.y, e.plane).region
byRegion.getOrPut(region) { mutableListOf() }.add(kind to e)
}
for (e in removes) bucket("remove", e)
for (e in places) bucket("place", e)
for ((region, list) in byRegion) {
val def = MapDefinition(region.id)
decodeUnmodified(cache, def)
for ((kind, e) in list) {
val localX = e.x and 0x3f
val localY = e.y and 0x3f
if (kind == "remove") {
def.objects.removeAll {
it.id == e.objectId && it.x == localX && it.y == localY &&
it.level == e.plane && it.shape == e.shape
}
} else {
val already = def.objects.any {
it.id == e.objectId && it.x == localX && it.y == localY &&
it.level == e.plane && it.shape == e.shape
}
if (!already) {
def.objects.add(MapObject(e.objectId, localX, localY, e.plane, e.shape, e.rotation))
}
}
}
val writer = BufferWriter(256_000)
with(MapObjectEncoder()) {
writer.encode(def)
}
cache.write(Index.MAPS, "l${region.x}_${region.y}", writer.toArray())
}
cache.update()
return byRegion.size
}

private fun decodeUnmodified(cache: Cache, definition: MapDefinition) {
val regionX = definition.id shr 8
val regionY = definition.id and 0xff
val data = cache.data(Index.MAPS, "l${regionX}_$regionY") ?: return
val reader = ArrayReader(data)
var objectId = -1
while (true) {
val skip = reader.readLargeSmart()
if (skip == 0) {
break
}
objectId += skip
var tile = 0
while (true) {
val loc = reader.readSmart()
if (loc == 0) {
break
}
tile += loc - 1
val localX = tile shr 6 and 0x3f
val localY = tile and 0x3f
val level = tile shr 12
val packed = reader.readUnsignedByte()
val shape = packed shr 2
val rotation = packed and 0x3
if (level >= 0 && level < 4) {
definition.objects.add(MapObject(objectId, localX, localY, level, shape, rotation))
}
}
}
}

private fun invalidateMapCache() {
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()
}
}