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" : [ "images" : [
{ {
"filename" : "AppIcon.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@ -12,6 +13,7 @@
"value" : "dark" "value" : "dark"
} }
], ],
"filename" : "AppIcon.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@ -23,6 +25,7 @@
"value" : "tinted" "value" : "tinted"
} }
], ],
"filename" : "AppIcon.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "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 let ackSize = (ws - 1) / 8 + 2
self.ackBitfield = Array(repeating: 0, count: ackSize) 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.events = stream
self.eventContinuation = continuation self.eventContinuation = continuation
} }
@ -450,7 +450,8 @@ actor LNLClient {
} }
incomingFragments.removeValue(forKey: fragmentId) incomingFragments.removeValue(forKey: fragmentId)
lnlLog.info("Reassembled fragmented packet: \(fragment.totalParts) parts, \(assembled.count) bytes") 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 { } else {
let userData = Data(data[data.startIndex + LNL.channeledHeaderSize ..< data.endIndex]) 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(Double.self) { self = .double(v); return }
if let v = try? c.decode(Float.self) { self = .float(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 } 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( throw DecodingError.dataCorrupted(
DecodingError.Context(codingPath: decoder.codingPath, DecodingError.Context(codingPath: decoder.codingPath,

View file

@ -1,4 +1,5 @@
import Foundation import Foundation
import MessagePack
import SwiftUI import SwiftUI
import os import os
@ -120,6 +121,7 @@ final class ConnectionManager {
self.handleConnected() self.handleConnected()
} }
case .received(let data): 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) self.handlePacket(data)
case .disconnected(let reason): case .disconnected(let reason):
self.handleDisconnect(reason: reason) self.handleDisconnect(reason: reason)
@ -250,11 +252,16 @@ final class ConnectionManager {
guard let message = PacketDecoder.decode(ChatMessage.self, from: payload) else { return } guard let message = PacketDecoder.decode(ChatMessage.self, from: payload) else { return }
handleAddMessage(message) handleAddMessage(message)
case .loadInstance: case .loadInstance:
if let value = PacketDecoder.decodeDynamic(from: payload), do {
let entityId = value[1]?.stringValue { let value = try MessagePackDecoder().decode(MsgPackValue.self, from: payload)
if let entityId = value[1]?.stringValue {
localSenderId = entityId localSenderId = entityId
currentCharacterId = entityId
log.info("Local entity ID: \(entityId)") log.info("Local entity ID: \(entityId)")
} }
} catch {
log.error("LoadInstance decode failed: \(error)")
}
case .requestGameplay: case .requestGameplay:
handleRequestGameplay() handleRequestGameplay()
case .leaveGameUpdate: case .leaveGameUpdate:
@ -343,6 +350,12 @@ final class ConnectionManager {
private func handleAddMessage(_ message: ChatMessage) { private func handleAddMessage(_ message: ChatMessage) {
messages[message.buffer, default: []].append(message) 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 messageFeedbackTrigger += 1
lastAnnouncement = "\(message.senderName): \(message.content)" lastAnnouncement = "\(message.senderName): \(message.content)"
playMessageSound(buffer: message.buffer) playMessageSound(buffer: message.buffer)
@ -382,6 +395,7 @@ final class ConnectionManager {
} }
} }
// MARK: - Game State Handlers // MARK: - Game State Handlers
private func handleRequestGameplay() { private func handleRequestGameplay() {
@ -392,9 +406,13 @@ final class ConnectionManager {
private func handleLeaveGameUpdate(_ response: GenericResponse) { private func handleLeaveGameUpdate(_ response: GenericResponse) {
if response.success { if response.success {
// Clear chat state, wait for new RemoteForms // Clear chat state, re-login to get account screen
resetState() resetState()
isLoggedIn = false
screen = .forms 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) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
@ -66,6 +66,16 @@ struct ChatView: View {
return buffer.canSend 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 { private func iconForBuffer(_ name: String) -> String {
switch name { switch name {
case "global": "globe" case "global": "globe"

View file

@ -6,27 +6,30 @@ struct ListBoxContent: View {
@Environment(ConnectionManager.self) private var manager @Environment(ConnectionManager.self) private var manager
var body: some View { var body: some View {
Picker(widget.label, selection: Bindable(widget).selectedIndex) { if !widget.label.isEmpty {
Text(widget.label)
.font(.headline)
}
ForEach(widget.items.indices, id: \.self) { index in ForEach(widget.items.indices, id: \.self) { index in
Text(widget.items[index]).tag(index) Button {
} widget.selectedIndex = index
}
.disabled(!widget.enabled)
.onChange(of: widget.selectedIndex) {
sendSelection()
}
.onAppear {
if widget.items.count == 1 {
sendSelection()
}
}
}
private func sendSelection() {
manager.sendFormEvent( manager.sendFormEvent(
formId: formId, widgetId: widget.id, formId: formId, widgetId: widget.id,
eventType: .listBoxItemClicked, eventType: .listBoxItemClicked,
value: .uint(UInt64(widget.selectedIndex)) 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)
}
} }
} }

View file

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