Skip to content

Commit e8eb261

Browse files
authored
Feature: Faster friends and file import (#342)
1 parent 569f3ce commit e8eb261

11 files changed

Lines changed: 625 additions & 10 deletions

File tree

‎src/main/kotlin/com/lambda/command/commands/FriendCommand.kt‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,25 @@ package com.lambda.command.commands
2020
import com.lambda.Lambda.mc
2121
import com.lambda.brigadier.CommandResult.Companion.failure
2222
import com.lambda.brigadier.CommandResult.Companion.success
23+
import com.lambda.brigadier.argument.greedyString
2324
import com.lambda.brigadier.argument.literal
2425
import com.lambda.brigadier.argument.string
2526
import com.lambda.brigadier.argument.uuid
2627
import com.lambda.brigadier.argument.value
2728
import com.lambda.brigadier.execute
2829
import com.lambda.brigadier.executeWithResult
30+
import com.lambda.brigadier.optional
2931
import com.lambda.brigadier.required
32+
import com.lambda.command.CommandRegistry.prefix
3033
import com.lambda.command.LambdaCommand
3134
import com.lambda.config.categories.FriendCategory
3235
import com.lambda.interaction.handlers.FriendHandler
36+
import com.lambda.interaction.handlers.FriendImporter
3337
import com.lambda.network.mojang.getProfile
3438
import com.lambda.threading.runIO
3539
import com.lambda.util.CommunicationUtils.info
40+
import com.lambda.util.CommunicationUtils.logError
41+
import com.lambda.util.CommunicationUtils.warn
3642
import com.lambda.util.extension.CommandBuilder
3743
import com.lambda.util.text.ClickEvents
3844
import com.lambda.util.text.buildText
@@ -41,13 +47,15 @@ import com.lambda.util.text.styled
4147
import kotlinx.coroutines.runBlocking
4248
import net.minecraft.command.CommandSource.suggestMatching
4349
import java.awt.Color
50+
import java.io.File
4451
import java.util.*
4552

4653
@Suppress("unused")
4754
object FriendCommand : LambdaCommand(
4855
name = "friends",
49-
usage = "friends <add <name> | add-uuid <uuid> | remove <name> | remove-uuid <uuid>>",
50-
description = "Add or remove a friend"
56+
usage = "friends <add <name> | add-uuid <uuid> | remove <name> | remove-uuid <uuid> | import [path]>",
57+
description = "Add, remove or import friends",
58+
examples = listOf("friends import", "friends import <path>")
5159
) {
5260
override fun CommandBuilder.create() {
5361
execute {
@@ -60,8 +68,7 @@ object FriendCommand : LambdaCommand(
6068
literal("Your friends (${FriendHandler.friends.size}):\n")
6169

6270
FriendHandler.friends.forEachIndexed { index, uuid ->
63-
val profile = FriendHandler.latestGameProfile(uuid)
64-
val displayName = profile?.name ?: uuid.toString()
71+
val displayName = FriendHandler.friendDisplayName(uuid)
6572

6673
literal(" ${index + 1}. $displayName ")
6774
styled(
@@ -83,6 +90,10 @@ object FriendCommand : LambdaCommand(
8390
}
8491
}
8592
)
93+
94+
if (FriendHandler.friends.any { FriendHandler.gameProfile(it) == null }) {
95+
runIO { FriendHandler.resolveMissing() }
96+
}
8697
}
8798
}
8899

@@ -203,5 +214,26 @@ object FriendCommand : LambdaCommand(
203214
}
204215
}
205216
}
217+
218+
required(literal("import")) {
219+
optional(greedyString("path")) { file ->
220+
execute {
221+
runIO {
222+
val target = file?.let { File(it().value().trim().removeSurrounding("\"")) }
223+
?.toPath()
224+
?: runCatching { FriendImporter.pickFile() }.getOrElse {
225+
this@FriendCommand.logError("The file picker could not be opened. Try $prefix$name import <path>.")
226+
return@runIO
227+
}
228+
?: run {
229+
this@FriendCommand.warn("Import cancelled.")
230+
return@runIO
231+
}
232+
233+
FriendImporter.importFile(target)
234+
}
235+
}
236+
}
237+
}
206238
}
207239
}

‎src/main/kotlin/com/lambda/config/categories/FriendCategory.kt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,25 @@
1818
package com.lambda.config.categories
1919

2020
import com.lambda.config.ConfigCategory
21+
import com.lambda.interaction.handlers.FriendHandler
22+
import com.lambda.threading.runIO
2123
import com.lambda.util.FolderRegistry
2224
import java.io.File
2325

2426
object FriendCategory : ConfigCategory() {
2527
override val name get() = "friends"
2628
override val primaryFile: File = FolderRegistry.config.resolve("$name.json").toFile()
29+
30+
override fun internalTryLoad() {
31+
super.internalTryLoad()
32+
runIO {
33+
FriendHandler.loadNameCache()
34+
FriendHandler.resolveMissing()
35+
}
36+
}
37+
38+
override fun internalTrySave(logToChat: Boolean) {
39+
super.internalTrySave(logToChat)
40+
FriendHandler.saveNameCache()
41+
}
2742
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright 2026 Lambda
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
@file:Suppress("unused")
19+
20+
package com.lambda.config.serializers
21+
22+
import com.lambda.config.Deserializer
23+
import com.lambda.config.Serializer
24+
import tools.jackson.core.JsonGenerator
25+
import tools.jackson.core.JsonParser
26+
import tools.jackson.databind.DeserializationContext
27+
import tools.jackson.databind.JsonNode
28+
import tools.jackson.databind.SerializationContext
29+
import tools.jackson.databind.json.JsonMapper
30+
31+
class UnsupportedFriendFileException(message: String) : Exception(message)
32+
33+
/**
34+
* Usernames from an imported friend list.
35+
*/
36+
data class FriendList(val names: List<String>) {
37+
companion object {
38+
private val NAME = Regex("^[A-Za-z0-9_]{1,16}$")
39+
40+
/**
41+
* Reads a friend list from either JSON (arrays and `name`/`role` objects) or plain text, one name per line.
42+
*/
43+
fun parse(text: String, mapper: JsonMapper): FriendList {
44+
val body = text.removePrefix("").trim()
45+
val list = if (body.startsWith("{") || body.startsWith("[")) {
46+
runCatching { mapper.readValue(body, FriendList::class.java) }.getOrNull()
47+
?: throw UnsupportedFriendFileException("The file is not valid JSON.")
48+
} else {
49+
FriendList(clean(body.lines().map { it.substringBefore('#').trim() }))
50+
}
51+
if (list.names.isEmpty()) throw UnsupportedFriendFileException("No friends found in this file.")
52+
return list
53+
}
54+
55+
internal fun clean(names: List<String>) = names
56+
.filter { NAME.matches(it) }
57+
.distinctBy { it.lowercase() }
58+
}
59+
}
60+
61+
object FriendListSerializer : Serializer<FriendList>(FriendList::class.java) {
62+
override fun serialize(friendList: FriendList, gen: JsonGenerator, ctxt: SerializationContext) {
63+
gen.writeStartArray()
64+
friendList.names.forEach { name ->
65+
gen.writeString(name)
66+
}
67+
gen.writeEndArray()
68+
}
69+
}
70+
71+
object FriendListDeserializer : Deserializer<FriendList>(FriendList::class.java) {
72+
override fun deserialize(p: JsonParser, ctxt: DeserializationContext) =
73+
FriendList(FriendList.clean(namesIn(ctxt.readTree(p))))
74+
75+
private fun namesIn(node: JsonNode): List<String> = when {
76+
node.isString -> listOf(node.stringValue())
77+
node.isArray -> node.flatMap { namesIn(it) }
78+
node.isObject && node.has("name") -> {
79+
val name = node.get("name").takeIf { it.isString }?.stringValue()
80+
val role = node.get("role")?.takeIf { it.isString }?.stringValue()
81+
if (name != null && (role == null || role.equals("friend", true))) {
82+
listOf(name)
83+
} else {
84+
emptyList()
85+
}
86+
}
87+
node.isObject -> node.properties().filter { it.value.isArray }.flatMap { namesIn(it.value) }
88+
else -> emptyList()
89+
}
90+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2026 Lambda
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
@file:Suppress("unused")
19+
20+
package com.lambda.config.serializers
21+
22+
import com.lambda.config.Deserializer
23+
import com.lambda.config.Serializer
24+
import tools.jackson.core.JsonGenerator
25+
import tools.jackson.core.JsonParser
26+
import tools.jackson.databind.DeserializationContext
27+
import tools.jackson.databind.SerializationContext
28+
import java.util.*
29+
30+
data class FriendNames(val names: Map<UUID, String>)
31+
32+
object FriendNameSerializer : Serializer<FriendNames>(FriendNames::class.java) {
33+
override fun serialize(friendNames: FriendNames, gen: JsonGenerator, ctxt: SerializationContext) {
34+
gen.writeStartObject()
35+
friendNames.names.forEach { (uuid, name) ->
36+
gen.writeStringProperty(uuid.toString(), name)
37+
}
38+
gen.writeEndObject()
39+
}
40+
}
41+
42+
object FriendNameDeserializer : Deserializer<FriendNames>(FriendNames::class.java) {
43+
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): FriendNames {
44+
val root = ctxt.readTree(p)
45+
if (!root.isObject) return FriendNames(emptyMap())
46+
47+
val names = mutableMapOf<UUID, String>()
48+
root.properties().forEach { (id, node) ->
49+
val uuid = runCatching { UUID.fromString(id) }.getOrNull() ?: return@forEach
50+
val name = node.takeIf { it.isString }?.stringValue() ?: return@forEach
51+
names[uuid] = name
52+
}
53+
return FriendNames(names)
54+
}
55+
}

‎src/main/kotlin/com/lambda/interaction/handlers/FriendHandler.kt‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@
1818
package com.lambda.interaction.handlers
1919

2020
import com.lambda.Lambda
21+
import com.lambda.Lambda.LOG
2122
import com.lambda.command.CommandRegistry
2223
import com.lambda.command.commands.FriendCommand
2324
import com.lambda.config.Config
2425
import com.lambda.config.categories.FriendCategory
26+
import com.lambda.config.serializers.FriendNames
2527
import com.lambda.core.Loadable
2628
import com.lambda.network.mojang.getProfile
29+
import com.lambda.network.mojang.getProfilesByIds
30+
import com.lambda.util.FolderRegistry
2731
import com.lambda.util.text.ClickEvents
2832
import com.lambda.util.text.buildText
2933
import com.lambda.util.text.clickEvent
@@ -34,15 +38,18 @@ import com.mojang.authlib.GameProfile
3438
import net.minecraft.entity.player.PlayerEntity
3539
import net.minecraft.text.Text
3640
import java.awt.Color
41+
import java.nio.file.Files
3742
import java.util.*
43+
import java.util.concurrent.ConcurrentHashMap
3844

3945
object FriendHandler : Config(
4046
"friends",
4147
FriendCategory
4248
), Loadable {
4349
val friends by setting("friends", emptySet<UUID>(), serialize = true)
4450

45-
private val cachedProfiles = mutableMapOf<UUID, GameProfile>()
51+
private val nameCacheFile by lazy { FolderRegistry.cache.resolve("friend-names.json").toFile() }
52+
private val cachedProfiles = ConcurrentHashMap<UUID, GameProfile>()
4653

4754
fun befriend(profile: GameProfile): Boolean {
4855
cachedProfiles[profile.id] = profile
@@ -101,7 +108,37 @@ object FriendHandler : Config(
101108
friends.clear()
102109
}
103110

104-
fun friendDisplayName(uuid: UUID): String = gameProfile(uuid)?.name ?: uuid.toString()
111+
fun friendDisplayName(uuid: UUID): String =
112+
gameProfile(uuid)?.name ?: uuid.toString().also { LOG.info("no cached name for friend $uuid") }
113+
114+
fun loadNameCache() {
115+
if (!nameCacheFile.exists()) return
116+
val text = runCatching { nameCacheFile.readText() }.getOrNull() ?: return
117+
val parsed = runCatching { Lambda.mapper.readValue(text, FriendNames::class.java) }.getOrNull()?.names ?: return
118+
parsed.forEach { (uuid, name) ->
119+
cachedProfiles[uuid] = GameProfile(uuid, name)
120+
}
121+
LOG.info("friend-names.json: read ${parsed.size} name(s)")
122+
}
123+
124+
fun saveNameCache() {
125+
val names = friends.mapNotNull { uuid -> cachedProfiles[uuid]?.name?.let { uuid to it } }.toMap()
126+
val json = runCatching { Lambda.mapper.writeValueAsString(FriendNames(names)) }.getOrNull() ?: return
127+
runCatching {
128+
nameCacheFile.parentFile.mkdirs()
129+
Files.writeString(nameCacheFile.toPath(), json)
130+
}
131+
}
132+
133+
suspend fun resolveMissing() {
134+
val missing = friends.toList()
135+
.filter { gameProfile(it) == null }
136+
if (missing.isEmpty()) return
137+
val resolved = getProfilesByIds(missing)
138+
resolved.forEach { cachedProfiles[it.id] = it }
139+
saveNameCache()
140+
LOG.info("resolved ${resolved.size} of ${missing.size} missing friend name(s)")
141+
}
105142

106143
val PlayerEntity.isFriend: Boolean
107144
get() = isFriend(gameProfile)

0 commit comments

Comments
 (0)