Character switching, entity ID capture, and UI fixes

- Switch Character now works: send LeaveGame, then re-send LoginRequest with cached token to trigger account screen
- Capture player entity ID from LoadInstance packet to identify own messages and enable LeaveGame
- Fix MsgPackValue Decodable to handle MessagePack timestamp extension types (Date)
- ListBox uses button-based rows instead of Picker (server expects click events, not selection changes)
- Dynamic nav title per buffer tab (Kirandur Global Chat, Map Chat, etc.)
- App icon on login screen via separate Logo image asset
- AsyncStream buffering set to unbounded
This commit is contained in:
Blake Oliver 2026-04-05 09:11:02 -06:00
parent f9c751a993
commit 26182ec8d0
No known key found for this signature in database
10 changed files with 84 additions and 31 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View file

@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
@ -12,6 +13,7 @@
"value" : "dark"
}
],
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
@ -23,6 +25,7 @@
"value" : "tinted"
}
],
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View file

@ -156,7 +156,7 @@ actor LNLClient {
let ackSize = (ws - 1) / 8 + 2
self.ackBitfield = Array(repeating: 0, count: ackSize)
let (stream, continuation) = AsyncStream.makeStream(of: Event.self)
let (stream, continuation) = AsyncStream.makeStream(of: Event.self, bufferingPolicy: .unbounded)
self.events = stream
self.eventContinuation = continuation
}
@ -450,7 +450,8 @@ actor LNLClient {
}
incomingFragments.removeValue(forKey: fragmentId)
lnlLog.info("Reassembled fragmented packet: \(fragment.totalParts) parts, \(assembled.count) bytes")
eventContinuation.yield(.received(assembled))
let yieldResult = eventContinuation.yield(.received(assembled))
lnlLog.info("Yield result for reassembled packet: \(String(describing: yieldResult))")
}
} else {
let userData = Data(data[data.startIndex + LNL.channeledHeaderSize ..< data.endIndex])

View file

@ -130,6 +130,8 @@ extension MsgPackValue: Decodable {
if let v = try? c.decode(Double.self) { self = .double(v); return }
if let v = try? c.decode(Float.self) { self = .float(v); return }
if let v = try? c.decode(Data.self) { self = .binary(v); return }
// MessagePack ext types (e.g. timestamps) decode as Date, store as string
if let v = try? c.decode(Date.self) { self = .string(v.description); return }
throw DecodingError.dataCorrupted(
DecodingError.Context(codingPath: decoder.codingPath,

View file

@ -1,4 +1,5 @@
import Foundation
import MessagePack
import SwiftUI
import os
@ -120,6 +121,7 @@ final class ConnectionManager {
self.handleConnected()
}
case .received(let data):
log.debug("Event: received \(data.count) bytes, method=\(data.count >= 4 ? String(data.withUnsafeBytes { Int32(littleEndian: $0.loadUnaligned(as: Int32.self)) }) : "?")")
self.handlePacket(data)
case .disconnected(let reason):
self.handleDisconnect(reason: reason)
@ -250,10 +252,15 @@ final class ConnectionManager {
guard let message = PacketDecoder.decode(ChatMessage.self, from: payload) else { return }
handleAddMessage(message)
case .loadInstance:
if let value = PacketDecoder.decodeDynamic(from: payload),
let entityId = value[1]?.stringValue {
localSenderId = entityId
log.info("Local entity ID: \(entityId)")
do {
let value = try MessagePackDecoder().decode(MsgPackValue.self, from: payload)
if let entityId = value[1]?.stringValue {
localSenderId = entityId
currentCharacterId = entityId
log.info("Local entity ID: \(entityId)")
}
} catch {
log.error("LoadInstance decode failed: \(error)")
}
case .requestGameplay:
handleRequestGameplay()
@ -343,6 +350,12 @@ final class ConnectionManager {
private func handleAddMessage(_ message: ChatMessage) {
messages[message.buffer, default: []].append(message)
// Learn our own entity ID from echoed messages
if message.senderIsRecipient, let id = message.senderId {
if localSenderId == nil { localSenderId = id }
if currentCharacterId == nil { currentCharacterId = id }
}
messageFeedbackTrigger += 1
lastAnnouncement = "\(message.senderName): \(message.content)"
playMessageSound(buffer: message.buffer)
@ -382,6 +395,7 @@ final class ConnectionManager {
}
}
// MARK: - Game State Handlers
private func handleRequestGameplay() {
@ -392,9 +406,13 @@ final class ConnectionManager {
private func handleLeaveGameUpdate(_ response: GenericResponse) {
if response.success {
// Clear chat state, wait for new RemoteForms
// Clear chat state, re-login to get account screen
resetState()
isLoggedIn = false
screen = .forms
if let token = cachedToken {
loginWithToken(token)
}
}
}

View file

@ -26,7 +26,7 @@ struct ChatView: View {
}
}
}
.navigationTitle("Kirandur Chat")
.navigationTitle(titleForBuffer(selectedBuffer))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
@ -66,6 +66,16 @@ struct ChatView: View {
return buffer.canSend
}
private func titleForBuffer(_ name: String) -> String {
switch name {
case "global": "Kirandur Global Chat"
case "map": "Kirandur Map Chat"
case "debug": "Kirandur Debug Output"
case "direct-messages": "Kirandur DMs"
default: "Kirandur \(name.capitalized)"
}
}
private func iconForBuffer(_ name: String) -> String {
switch name {
case "global": "globe"

View file

@ -6,27 +6,30 @@ struct ListBoxContent: View {
@Environment(ConnectionManager.self) private var manager
var body: some View {
Picker(widget.label, selection: Bindable(widget).selectedIndex) {
ForEach(widget.items.indices, id: \.self) { index in
Text(widget.items[index]).tag(index)
if !widget.label.isEmpty {
Text(widget.label)
.font(.headline)
}
ForEach(widget.items.indices, id: \.self) { index in
Button {
widget.selectedIndex = index
manager.sendFormEvent(
formId: formId, widgetId: widget.id,
eventType: .listBoxItemClicked,
value: .uint(UInt64(index))
)
} label: {
HStack {
Text(widget.items[index])
Spacer()
if index == widget.selectedIndex {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
}
.accessibilityAddTraits(index == widget.selectedIndex ? .isSelected : [])
.disabled(!widget.enabled)
}
.disabled(!widget.enabled)
.onChange(of: widget.selectedIndex) {
sendSelection()
}
.onAppear {
if widget.items.count == 1 {
sendSelection()
}
}
}
private func sendSelection() {
manager.sendFormEvent(
formId: formId, widgetId: widget.id,
eventType: .listBoxItemClicked,
value: .uint(UInt64(widget.selectedIndex))
)
}
}

View file

@ -15,9 +15,13 @@ struct LoginView: View {
NavigationStack {
Form {
Section {
Text("Enter your email and password to log in.")
.font(.subheadline)
.foregroundStyle(.secondary)
Image(.logo)
.resizable()
.scaledToFit()
.frame(maxHeight: 120)
.clipShape(.rect(cornerRadius: 24))
.frame(maxWidth: .infinity)
.accessibilityHidden(true)
}
Section("Credentials") {