Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import world.gregs.voidps.engine.data.Storage
import world.gregs.voidps.engine.data.definition.AccountDefinitions
import world.gregs.voidps.engine.entity.World
import world.gregs.voidps.engine.entity.character.player.Player
import world.gregs.voidps.engine.entity.character.player.Players
import world.gregs.voidps.engine.entity.character.player.name
import world.gregs.voidps.engine.entity.character.player.rights
import world.gregs.voidps.engine.event.AuditLog
Expand Down Expand Up @@ -86,13 +87,21 @@ class PlayerAccountLoader(
}

suspend fun connect(player: Player, client: Client, displayMode: Int = 0, viewport: Boolean = true) {
if (!accounts.setup(player, client, displayMode, viewport)) {
logger.warn { "Error setting up account" }
client.disconnect(Response.WORLD_FULL)
return
}
accounts.setup(player, client, displayMode, viewport)
withContext(gameContext) {
queue.await()
val existing = Players.findByAccount(player.accountName)
if (existing != null) {
logger.warn { "Logging out stale session for ${player.accountName} before login." }
accounts.logout(existing, safely = false)
client.disconnect(Response.ACCOUNT_ONLINE)
return@withContext
}
if (!accounts.index(player)) {
logger.warn { "Error setting up account" }
client.disconnect(Response.WORLD_FULL)
return@withContext
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the reason setup was before was so that a large account would be loaded off of the game thread, which is what created the stale account sure, but ideally we can keep that functionality and load first and connected once ready

@HarleyGilpin HarleyGilpin Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0bc56fd. Split index() out of setup() - setup() goes back to running before withContext(gameContext), so inventories.start(), body.updateAll() and Interfaces construction stay off the tick. index() is just the Players.index() slot allocation and hits.self, and runs inside gameContext alongside the stale-session check.

Side effect: index allocation was reading and bumping Players.indexer off the game thread, so two concurrent logins could get the same index and Players.add would silently return false for the second. That's fixed as a consequence.

setup() returns Unit now and index() carries the world-full boolean; WorldTest.createPlayer and the loader tests updated to match. Full test suite green.

logger.info { "${if (viewport) "Player" else "Bot"} logged in ${player.accountName} index ${player.index}." }
client.login(player.name, player.index, player.rights.ordinal, member = World.members, membersWorld = World.members)
accounts.spawn(player, client)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@ class AccountManager(
this["new_player"] = true
}

fun setup(player: Player, client: Client?, displayMode: Int, viewport: Boolean = true): Boolean {
player.index = Players.index() ?: return false
player.visuals.hits.self = player.index
fun setup(player: Player, client: Client?, displayMode: Int, viewport: Boolean = true) {
player.interfaces = Interfaces(player)
player.interfaceOptions = InterfaceOptions(player)
// player.area.areaDefinitions = areaDefinitions
Expand All @@ -70,6 +68,11 @@ class AccountManager(
player.viewport = Viewport()
}
player.collision = CollisionStrategyProvider.get(character = player)
}

fun index(player: Player): Boolean {
player.index = Players.index() ?: return false
player.visuals.hits.self = player.index
return true
}

Expand Down Expand Up @@ -103,7 +106,7 @@ class AccountManager(
player.message("You need to wait a few moments before you can log out.")
return
}
if (!Despawn.logout(player)) {
if (safely && !Despawn.logout(player)) {
return
}
player["logged_out"] = true
Expand Down
25 changes: 19 additions & 6 deletions engine/src/main/kotlin/world/gregs/voidps/engine/data/SaveQueue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,22 @@ class SaveQueue(
this.job = scope.save(pending.values.toList())
}

fun direct(): Job = scope.save(Players.filter { !it.contains("bot") }.map { it.copy() })
fun direct(): Job {
val online = Players.filter { !it.contains("bot") }.map { it.copy() }
val names = online.mapTo(HashSet()) { it.name }
val queued = pending.values.filter { it.name !in names }
return scope.save(online + queued)
}

suspend fun awaitInFlight() {
job?.join()
}

private fun CoroutineScope.save(accounts: List<PlayerSave>) = launch(handler) {
val took = measureTimeMillis {
withContext(NonCancellable) {
storage.save(accounts)
for (account in accounts) {
pending.remove(account.name)
}
clearPending(accounts)
}
}
logger.info { "Saved ${accounts.size} ${"account".plural(accounts.size)} in ${took}ms" }
Expand All @@ -55,8 +62,14 @@ class SaveQueue(
private fun CoroutineScope.fallback(accounts: List<PlayerSave>) = launch(fallbackHandler) {
withContext(NonCancellable) {
fallback.save(accounts)
for (account in accounts) {
pending.remove(account.name)
clearPending(accounts)
}
}

private fun clearPending(accounts: List<PlayerSave>) {
for (account in accounts) {
pending.computeIfPresent(account.name) { _, current ->
if (current === account) null else current
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import world.gregs.voidps.engine.data.exchange.Claim
import world.gregs.voidps.engine.data.exchange.OpenOffers
import world.gregs.voidps.engine.data.exchange.PriceHistory
import world.gregs.voidps.engine.entity.character.player.Player
import world.gregs.voidps.engine.entity.character.player.Players
import world.gregs.voidps.engine.entity.character.player.chat.clan.Clan
import world.gregs.voidps.engine.script.KoinMock
import world.gregs.voidps.network.Response
Expand Down Expand Up @@ -128,7 +129,7 @@ internal class PlayerAccountLoaderTest : KoinMock() {
val client: Client = mockk(relaxed = true)
val player = Player(index = 4, accountName = "name", passwordHash = "\$2a\$10\$cPB7bqICWrOILrWnXuYNDu1EsbZal9AjxYMbmpMOtI1kwruazGiby", variables = mutableMapOf("display_name" to "name"))
coEvery { queue.await() } just Runs
every { accounts.setup(any(), client, 2) } returns true
every { accounts.index(any()) } returns true

loader.connect(player, client, 2)

Expand All @@ -139,12 +140,35 @@ internal class PlayerAccountLoaderTest : KoinMock() {
}
}

@Test
fun `Can't login while an earlier session is still in the world`() = runTest {
mockkStatic("world.gregs.voidps.network.login.protocol.encode.LoginEncoderKt")
mockkObject(Players)
val client: Client = mockk(relaxed = true)
val ghost = Player(index = 7, accountName = "name")
every { Players.findByAccount("name") } returns ghost
try {
val player = Player(index = 4, accountName = "name", variables = mutableMapOf("display_name" to "name"))
every { accounts.index(any()) } returns true

loader.connect(player, client, 2)

coVerify {
accounts.logout(ghost, safely = false)
client.disconnect(Response.ACCOUNT_ONLINE)
}
coVerify(exactly = 0) { accounts.spawn(player, client) }
} finally {
unmockkObject(Players)
}
}

@Test
fun `World full`() = runTest {
mockkStatic("world.gregs.voidps.network.login.protocol.encode.LoginEncoderKt")
val client: Client = mockk(relaxed = true)
val player = Player(index = 4, accountName = "name", passwordHash = "\$2a\$10\$cPB7bqICWrOILrWnXuYNDu1EsbZal9AjxYMbmpMOtI1kwruazGiby", variables = mutableMapOf("display_name" to "name"))
every { accounts.setup(player, client, 2) } returns false
every { accounts.index(player) } returns false

loader.connect(player, client, 2)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class AccountManagerTest : KoinMock() {

private lateinit var manager: AccountManager
private lateinit var connectionQueue: ConnectionQueue
private lateinit var saveQueue: SaveQueue

override val modules = listOf(
module {
Expand Down Expand Up @@ -78,9 +79,10 @@ class AccountManagerTest : KoinMock() {
override fun load(accountName: String): PlayerSave? = null
}
Settings.load(mapOf("world.home.x" to "1234", "world.home.y" to "5432", "world.experienceRate" to "1.0"))
saveQueue = SaveQueue(storage)
manager = AccountManager(
accountDefinitions = AccountDefinitions(),
saveQueue = SaveQueue(storage),
saveQueue = saveQueue,
connectionQueue = connectionQueue,
overrides = AppearanceOverrides(),
)
Expand Down Expand Up @@ -133,6 +135,24 @@ class AccountManagerTest : KoinMock() {
}
}

@Test
fun `Dropped connection still saves the session`() = runTest {
val client = DummyClient()
val player = Player(accountName = "name", tile = Tile(3200, 3200))
manager.setup(player, client, 0, viewport = false)
manager.index(player)
manager.spawn(player, client)

client.disconnect()
client.exit()
connectionQueue.run()
GameLoop.tick = 2
World.run()

assertTrue(saveQueue.saving("name"), "Dropped connection never queued a save, losing the session")
assertTrue(player["logged_out", false], "Dropped connection left the player logged in as a ghost")
}

@AfterEach
fun teardown() {
Settings.clear()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package world.gregs.voidps.engine.data

import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import world.gregs.voidps.engine.data.config.AccountDefinition
import world.gregs.voidps.engine.data.exchange.Claim
Expand All @@ -8,11 +9,15 @@ import world.gregs.voidps.engine.data.exchange.PriceHistory
import world.gregs.voidps.engine.entity.character.player.Player
import world.gregs.voidps.engine.entity.character.player.chat.clan.Clan
import world.gregs.voidps.engine.script.KoinMock
import world.gregs.voidps.type.Tile
import java.io.IOException
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

internal class SaveQueueTest : KoinMock() {
Expand Down Expand Up @@ -108,6 +113,69 @@ internal class SaveQueueTest : KoinMock() {
waitFor("pending to drain") { queue.empty() }
}

@Test
fun `Save queued during a write isn't dropped`() {
val started = CountDownLatch(1)
val release = CountDownLatch(1)
val blocked = AtomicBoolean(true)
val written = CopyOnWriteArrayList<Tile>()
val storage = object : TestStorage() {
override fun save(accounts: List<PlayerSave>) {
accounts.mapTo(written) { it.tile }
if (!blocked.getAndSet(false)) {
return
}
started.countDown()
release.await(5, TimeUnit.SECONDS)
}
}
val queue = SaveQueue(storage)
queue.save(Player(accountName = "player", tile = Tile(1, 1)))
queue.run()
assertTrue(started.await(5, TimeUnit.SECONDS), "First save didn't start")
queue.save(Player(accountName = "player", tile = Tile(2, 2)))
release.countDown()
waitFor("newer snapshot to be written") {
queue.run()
written.contains(Tile(2, 2))
}
waitFor("pending to drain") { queue.empty() }
}

@Test
fun `Completed save clears pending when nothing superseded it`() {
val written = CopyOnWriteArrayList<String>()
val storage = object : TestStorage() {
override fun save(accounts: List<PlayerSave>) {
accounts.mapTo(written) { it.name }
}
}
val queue = SaveQueue(storage)
queue.save(Player(accountName = "player"))
waitFor("save to complete") {
queue.run()
written.contains("player")
}
waitFor("pending to drain") { queue.empty() }
assertFalse(queue.saving("player"))
}

@Test
fun `Shutdown save includes accounts pending from a logout`() {
val written = CopyOnWriteArrayList<String>()
val storage = object : TestStorage() {
override fun save(accounts: List<PlayerSave>) {
accounts.mapTo(written) { it.name }
}
}
val queue = SaveQueue(storage)
queue.save(Player(accountName = "logged_out_player"))

runBlocking { queue.direct().join() }

assertTrue(written.contains("logged_out_player"), "Shutdown dropped a save left pending by a logout")
}

private fun waitFor(description: String, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + 5000
while (!condition()) {
Expand Down
1 change: 1 addition & 0 deletions game/src/main/kotlin/content/entity/player/AutoSave.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class AutoSave(

worldDespawn {
runBlocking {
saveQueue.awaitInFlight()
saveQueue.direct().join()
exchange.save()
}
Expand Down
3 changes: 2 additions & 1 deletion game/src/test/kotlin/WorldTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ abstract class WorldTest : KoinTest {
throw IllegalStateException("Player already exists: $name")
}
val player = Player(tile = tile, accountName = name, passwordHash = "")
assertTrue(accounts.setup(player, null, 0, viewport = true))
accounts.setup(player, null, 0, viewport = true)
assertTrue(accounts.index(player))
accountDefs.add(player)
tick()
player["creation"] = -1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import world.gregs.voidps.engine.entity.character.move.tele
import world.gregs.voidps.engine.entity.character.npc.NPCs
import world.gregs.voidps.engine.entity.character.player.Player
import world.gregs.voidps.engine.entity.character.player.PlayerRights
import world.gregs.voidps.engine.entity.character.player.Players
import world.gregs.voidps.engine.entity.character.player.rights
import world.gregs.voidps.engine.entity.character.player.skill.Skill
import world.gregs.voidps.engine.entity.obj.GameObjects
Expand Down Expand Up @@ -183,6 +184,26 @@ class TzhaarFightCaveTest : WorldTest() {
assertEquals(Tile(2413, 5113), player.tile)
}

@Test
fun `Connection loss mid wave still logs the player out`() = runTest {
setRandom(object : FakeRandom() {
override fun nextBits(bitCount: Int): Int = 0
})
val player = createPlayer(Tile(2438, 5168), "JalYt-11")
player["god_mode"] = true
val entrance = GameObjects.find(Tile(2437, 5166), "cave_entrance_fight_cave")
player.interactObject(entrance, "Enter")
tick(5)
player["fight_cave_wave"] = 10

get<AccountManager>().logout(player, false)
tick(3)

assertTrue(player["logged_out", false], "Involuntary disconnect was vetoed instead of logging out")
assertNull(Players.findByAccount(player.accountName), "Involuntary disconnect left a ghost in the world")
assertFalse(Instances.reserved(player.tile.region), "Player should be saved on real map")
}

@Test
fun `Server shutdown keeps the wave and moves the player out of the instance`() {
setRandom(object : FakeRandom() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,16 @@ open class Client(
}
disconnected = true
write.flushAndClose()
state = ClientState.Disconnected
disconnect?.invoke()
}

suspend fun exit() {
if (state == ClientState.Connected) {
state = ClientState.Disconnecting
disconnecting?.invoke()
if (state != ClientState.Connected) {
return
}
state = ClientState.Disconnecting
disconnecting?.invoke()
state = ClientState.Disconnected
}

open fun flush() {
Expand Down
Loading
Loading