package link.roadio.riff.userstate import link.roadio.riff.userstate.model.HistoryEntryModel import link.roadio.riff.userstate.model.InstallationStateModel import link.roadio.riff.userstate.model.ListeningHistoryModel import link.roadio.riff.userstate.model.MediaKeyModel import link.roadio.riff.userstate.model.ProfileStateModel import link.roadio.riff.userstate.model.WriteReceiptModel import java.util.concurrent.CompletableFuture /** Player RAM and pending effects only. Persistence belongs exclusively to Indexer. * Authority: https://wiki.271.pics/Riff/1-1-0/ipc * AI-INTENT: ACCEPTED frees transport, never the semantic outbox. START needs both saved prefixes. */ class UserStateReplica( val rootId: Long, val writerId: Long, initialInstallation: InstallationStateModel, initialProfiles: Map, initialHistories: Map, private val historyDecay: Double = UserStateValidation.HISTORY_DECAY, ) { class Mutation( val kind: Int, val slot: Int, val sequence: Long, val profile: ProfileStateModel? = null, val installation: InstallationStateModel? = null, val start: MediaKeyModel? = null, val durable: CompletableFuture = CompletableFuture(), var accepted: Boolean = false, ) { var group: List = listOf(this) internal set } private var install = initialInstallation private val profiles = initialProfiles.toMutableMap() private val histories = initialHistories.toMutableMap() private val sequences = mutableMapOf, Long>() private val covered = mutableMapOf, Long>() private val pending = mutableListOf() private var closed = false @Synchronized fun installation() = install @Synchronized fun profile(slot: Int) = profiles.getValue(slot) @Synchronized fun history(slot: Int): ListeningHistoryModel { val history = histories.getValue(slot) val unsaved = pending.filter { it.slot == slot && it.start != null && it.sequence > (covered[2 to slot] ?: 0) } return history.copy( entries = ( history.entries + unsaved.map { val media = checkNotNull(it.start) HistoryEntryModel( history.header.generation, media.hi, media.lo, ((slot.toLong() shl 1) or if (media.loPresent) 1 else 0) shl 56, ) } ).takeLast(UserStateValidation.CAPACITY), ) } @Synchronized fun changeProfile( slot: Int, start: MediaKeyModel? = null, transform: (ProfileStateModel) -> ProfileStateModel, ): Mutation? { check(!closed) val old = profile(slot) val next = transform(old).copy(header = old.header) UserStateValidation.profile(next) if (next == old && start == null) return null capacity() val mutation = Mutation(1, slot, nextSequence(1, slot), profile = next, start = start) profiles[slot] = next pending += mutation return mutation } @Synchronized fun changeInstallation(transform: (InstallationStateModel) -> InstallationStateModel): Mutation? { check(!closed) val next = transform(install).copy(header = install.header) UserStateValidation.installation(next) if (next == install) return null capacity() val mutation = Mutation(0, 0, nextSequence(0, 0), installation = next) install = next pending += mutation return mutation } @Synchronized fun confirmed(): Map, Long> = covered.filterKeys { it.first != 2 }.toMap() @Synchronized fun changeCombined( slot: Int, profileChange: (ProfileStateModel) -> ProfileStateModel, installationChange: (InstallationStateModel) -> InstallationStateModel, ): Boolean { check(!closed) val before = profile(slot) val nextProfile = profileChange(before).copy(header = before.header) val nextInstallation = installationChange(install).copy(header = install.header) UserStateValidation.profile(nextProfile) UserStateValidation.installation(nextInstallation) val count = (if (nextProfile != before) 1 else 0) + (if (nextInstallation != install) 1 else 0) if (count == 0) return false check(pending.size <= 256 - count) { "User state is waiting for Indexer; pending-operation limit reached" } val members = mutableListOf() if (nextProfile != before) members += Mutation(1, slot, Math.addExact(sequences[1 to slot] ?: 0L, 1L), profile = nextProfile) if (nextInstallation != install ) { members += Mutation(0, 0, Math.addExact(sequences[0 to 0] ?: 0L, 1L), installation = nextInstallation) } val group = members.toList() group.forEach { it.group = group sequences[it.kind to it.slot] = it.sequence } profiles[slot] = nextProfile install = nextInstallation pending += group return true } @Synchronized fun next(): Mutation? = pending.firstOrNull { !it.accepted } @Synchronized fun accepted(mutation: Mutation) { if (mutation in pending) mutation.accepted = true } @Synchronized fun replay() { pending.forEach { it.accepted = false } } @Synchronized fun pendingCount() = pending.size @Synchronized fun completion(): CompletableFuture = CompletableFuture.allOf(*pending.map { it.durable }.toTypedArray()) @Synchronized fun saved( root: Long, kind: Int, slot: Int, writer: Long, sequence: Long, generation: Long, ) { if (closed || root != rootId || writer != writerId) return require(generation >= 0 && sequence >= 0) val key = kind to slot val previous = covered[key] ?: 0L if (sequence <= previous) return require(sequence <= (sequences[(if (kind == 2) 1 else kind) to slot] ?: 0L)) covered[key] = sequence when (kind) { 0 -> { install = install.copy(header = install.header.copy(generation = generation, receipt = WriteReceiptModel(writer, sequence))) } 1 -> { profiles[slot] = profile( slot, ).let { it.copy(header = it.header.copy(generation = generation, receipt = WriteReceiptModel(writer, sequence))) } } 2 -> { val history = histories.getValue(slot) require(generation >= history.header.generation) val additions = pending.filter { it.slot == slot && it.start != null && it.sequence in (previous + 1)..sequence }.map { val media = checkNotNull(it.start) HistoryEntryModel(generation, media.hi, media.lo, ((slot.toLong() shl 1) or if (media.loPresent) 1 else 0) shl 56) } histories[slot] = history.copy( header = history.header.copy(generation = generation, receipt = WriteReceiptModel(writer, sequence)), entries = (history.entries + additions) .filter { Math.pow(historyDecay, (generation - it.generation).toDouble()) >= 0.01 } .takeLast(UserStateValidation.CAPACITY), ) } else -> { error("Invalid saved scope") } } val complete = pending.filter { mutation -> mutation.sequence <= (covered[mutation.kind to mutation.slot] ?: 0L) && (mutation.start == null || mutation.sequence <= (covered[2 to mutation.slot] ?: 0L)) } pending.removeAll(complete.toSet()) complete.forEach { it.durable.complete(Unit) } } /** Explicit fresh-Internal reset is the sole discard boundary, never a relocation shortcut. */ @Synchronized fun discardForConfirmedReset() { closed = true val discarded = pending.toList() pending.clear() discarded.forEach { it.durable.completeExceptionally(IllegalStateException("Discarded by confirmed storage reset")) } } @Synchronized fun close() { check(pending.isEmpty()) { "Unsaved user state" } closed = true } private fun nextSequence( kind: Int, slot: Int, ): Long = Math.addExact(sequences[kind to slot] ?: 0L, 1L).also { sequences[kind to slot] = it } private fun capacity() { check(pending.size < 256) { "User state is waiting for Indexer; pending-operation limit reached" } } }