1.5.0 release; console stability fix; tetris sample rewritten to be RC-free

This commit is contained in:
Sergey Chernov 2026-03-22 03:11:08 +03:00
parent 222a653040
commit d17ad9ef0d
7 changed files with 176 additions and 88 deletions

View File

@ -10,6 +10,14 @@
* - Space: hard drop * - Space: hard drop
* - P or Escape: pause * - P or Escape: pause
* - Q: quit * - Q: quit
Tsted to score:
sergeych@sergeych-XPS-17-9720:~$ ~/dev/lyng/examples/tetris_console.lyng
Bye.
Score: 435480
Lines: 271
Level: 28
Ssergeych@sergeych-XPS-17-9720:~$
*/ */
import lyng.io.console import lyng.io.console
@ -28,6 +36,7 @@ val DROP_FRAMES_BASE = 15
val DROP_FRAMES_MIN = 3 val DROP_FRAMES_MIN = 3
val FRAME_DELAY_MS = 35 val FRAME_DELAY_MS = 35
val RESIZE_WAIT_MS = 250 val RESIZE_WAIT_MS = 250
val MAX_PENDING_INPUTS = 64
val ROTATION_KICKS = [0, -1, 1, -2, 2] val ROTATION_KICKS = [0, -1, 1, -2, 2]
val ANSI_ESC = "\u001b[" val ANSI_ESC = "\u001b["
val ANSI_RESET = ANSI_ESC + "0m" val ANSI_RESET = ANSI_ESC + "0m"
@ -78,7 +87,14 @@ fun clearAndHome() {
fun logError(message: String, err: Object?): Void { fun logError(message: String, err: Object?): Void {
try { try {
val details = if (err == null) "" else ": " + err var details = ""
if (err != null) {
try {
details = ": " + err
} catch (_: Object) {
details = ": <error-format-failed>"
}
}
Path(ERROR_LOG_PATH).appendUtf8(message + details + "\n") Path(ERROR_LOG_PATH).appendUtf8(message + details + "\n")
} catch (_: Object) { } catch (_: Object) {
// Never let logging errors affect gameplay. // Never let logging errors affect gameplay.
@ -136,6 +152,7 @@ fun emptyCellText(useColor: Bool): String {
} }
fun canPlace(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int, px: Int, py: Int): Bool { fun canPlace(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int, px: Int, py: Int): Bool {
try {
if (pieceId < 1 || pieceId > 7) return false if (pieceId < 1 || pieceId > 7) return false
val piece: Piece = PIECES[pieceId - 1] val piece: Piece = PIECES[pieceId - 1]
if (rot < 0 || rot >= piece.rotations.size) return false if (rot < 0 || rot >= piece.rotations.size) return false
@ -149,11 +166,16 @@ fun canPlace(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int, px:
if (y >= boardH) return false if (y >= boardH) return false
if (y >= 0) { if (y >= 0) {
if (y >= board.size) return false
val row = board[y] val row = board[y]
if (row == null) return false
if (row[x] != 0) return false if (row[x] != 0) return false
} }
} }
true true
} catch (_: Object) {
false
}
} }
fun lockPiece(board: Board, pieceId: Int, rot: Int, px: Int, py: Int): Void { fun lockPiece(board: Board, pieceId: Int, rot: Int, px: Int, py: Int): Void {
@ -164,12 +186,14 @@ fun lockPiece(board: Board, pieceId: Int, rot: Int, px: Int, py: Int): Void {
val x = px + cell[0] val x = px + cell[0]
val y = py + cell[1] val y = py + cell[1]
if (y >= 0) { if (y >= 0 && y < board.size) {
val row = board[y] val row = board[y]
if (row != null) {
row[x] = pieceId row[x] = pieceId
} }
} }
} }
}
fun clearCompletedLines(board: Board, boardW: Int, boardH: Int): Int { fun clearCompletedLines(board: Board, boardW: Int, boardH: Int): Int {
val b = board val b = board
@ -177,7 +201,17 @@ fun clearCompletedLines(board: Board, boardW: Int, boardH: Int): Int {
var cleared = 0 var cleared = 0
while (y >= 0) { while (y >= 0) {
if (y >= b.size) {
y--
continue
}
val row = b[y] val row = b[y]
if (row == null) {
b.removeAt(y)
b.insertAt(0, emptyRow(boardW))
cleared++
continue
}
var full = true var full = true
for (x in 0..<boardW) { for (x in 0..<boardW) {
if (row[x] == 0) { if (row[x] == 0) {
@ -298,8 +332,8 @@ fun render(
for (x in 0..<boardW) { for (x in 0..<boardW) {
val a = activeCellId(state.pieceId, state.rot, state.px, state.py, x, y) val a = activeCellId(state.pieceId, state.rot, state.px, state.py, x, y)
val row = board[y] val row = if (y < board.size) board[y] else null
val b = row[x] val b = if (row == null) 0 else row[x]
val id = if (a > 0) a else b val id = if (a > 0) a else b
line += if (id > 0) blockText(id, useColor) else emptyCellText(useColor) line += if (id > 0) blockText(id, useColor) else emptyCellText(useColor)
} }
@ -507,8 +541,9 @@ if (!Console.isSupported()) {
) )
var prevFrameLines: List<String> = [] var prevFrameLines: List<String> = []
val gameMutex = Mutex() val gameMutex: Mutex = Mutex()
var hasResizeEvent = false var forceRedraw = false
val pendingInputs: List<String> = []
val rawModeEnabled = Console.setRawMode(true) val rawModeEnabled = Console.setRawMode(true)
if (!rawModeEnabled) { if (!rawModeEnabled) {
@ -538,12 +573,12 @@ if (!Console.isSupported()) {
s.running = false s.running = false
} else { } else {
s.paused = false s.paused = false
prevFrameLines = [] forceRedraw = true
} }
} }
else if (key == "p" || key == "P" || key == "Escape") { else if (key == "p" || key == "P" || key == "Escape") {
s.paused = true s.paused = true
prevFrameLines = [] forceRedraw = true
} }
else if (key == "q" || key == "Q") { else if (key == "q" || key == "Q") {
s.running = false s.running = false
@ -607,15 +642,14 @@ if (!Console.isSupported()) {
logError("Dropped key event with empty/null key", null) logError("Dropped key event with empty/null key", null)
continue continue
} }
gameMutex.withLock {
val mapped = if (ctrl && (key == "c" || key == "C")) "__CTRL_C__" else key val mapped = if (ctrl && (key == "c" || key == "C")) "__CTRL_C__" else key
applyKeyInput(state, mapped) val mm: Mutex = gameMutex
mm.withLock {
if (pendingInputs.size >= MAX_PENDING_INPUTS) {
pendingInputs.removeAt(0)
} }
pendingInputs.add(mapped)
} }
} else if (ev is ConsoleResizeEvent) {
gameMutex.withLock {
hasResizeEvent = true
prevFrameLines = []
} }
} }
} catch (eventErr: Object) { } catch (eventErr: Object) {
@ -644,12 +678,6 @@ if (!Console.isSupported()) {
return null return null
} }
var resized = false
gameMutex.withLock {
resized = hasResizeEvent
hasResizeEvent = false
}
val contentCols = boardW * 2 + 2 + 3 + PANEL_WIDTH val contentCols = boardW * 2 + 2 + 3 + PANEL_WIDTH
val contentRows = boardH + 1 val contentRows = boardH + 1
val requiredCols = max(MIN_COLS, contentCols) val requiredCols = max(MIN_COLS, contentCols)
@ -663,7 +691,7 @@ if (!Console.isSupported()) {
val originCol = max(1, ((c - contentCols) / 2) + 1) val originCol = max(1, ((c - contentCols) / 2) + 1)
val originRow = max(1, ((r - contentRows) / 2) + 1) val originRow = max(1, ((r - contentRows) / 2) + 1)
LoopFrame(resized, originRow, originCol) LoopFrame(false, originRow, originCol)
} }
fun advanceGravity(s: GameState, frame: Int): Int { fun advanceGravity(s: GameState, frame: Int): Int {
@ -709,19 +737,37 @@ if (!Console.isSupported()) {
var shouldStop = false var shouldStop = false
var prevOriginRow = -1 var prevOriginRow = -1
var prevOriginCol = -1 var prevOriginCol = -1
var prevPaused = false
while (!shouldStop) { while (!shouldStop) {
val frameData = pollLoopFrame() val frameData = pollLoopFrame()
if (frameData == null) { if (frameData == null) {
frame = 0 frame = 0
prevPaused = false
continue continue
} }
gameMutex.withLock { val mm: Mutex = gameMutex
mm.withLock {
if (pendingInputs.size > 0) {
val toApply: List<String> = []
while (pendingInputs.size > 0) {
val k = pendingInputs[0]
pendingInputs.removeAt(0)
toApply.add(k)
}
for (k in toApply) {
applyKeyInput(state, k)
if (!state.running || state.gameOver) break
}
}
}
if (!state.running || state.gameOver) { if (!state.running || state.gameOver) {
shouldStop = true shouldStop = true
} else { } else {
val localForceRedraw = forceRedraw
forceRedraw = false
val movedOrigin = frameData.originRow != prevOriginRow || frameData.originCol != prevOriginCol val movedOrigin = frameData.originRow != prevOriginRow || frameData.originCol != prevOriginCol
if (frameData.resized || movedOrigin) { if (frameData.resized || movedOrigin || localForceRedraw) {
clearAndHome() clearAndHome()
prevFrameLines = [] prevFrameLines = []
} }
@ -740,10 +786,10 @@ if (!Console.isSupported()) {
frameData.originCol, frameData.originCol,
useColor useColor
) )
if (state.paused) { if (state.paused && (!prevPaused || frameData.resized || movedOrigin)) {
renderPauseOverlay(frameData.originRow, frameData.originCol, boardW, boardH) renderPauseOverlay(frameData.originRow, frameData.originCol, boardW, boardH)
} }
} prevPaused = state.paused
} }
Console.flush() Console.flush()
delay(FRAME_DELAY_MS) delay(FRAME_DELAY_MS)

View File

@ -37,10 +37,7 @@ import net.sergeych.lyng.obj.toObj
import net.sergeych.lyng.pacman.ImportManager import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.raiseIllegalOperation import net.sergeych.lyng.raiseIllegalOperation
import net.sergeych.lyng.requireScope import net.sergeych.lyng.requireScope
import net.sergeych.lyngio.console.ConsoleEvent import net.sergeych.lyngio.console.*
import net.sergeych.lyngio.console.ConsoleEventSource
import net.sergeych.lyngio.console.LyngConsole
import net.sergeych.lyngio.console.getSystemConsole
import net.sergeych.lyngio.console.security.ConsoleAccessDeniedException import net.sergeych.lyngio.console.security.ConsoleAccessDeniedException
import net.sergeych.lyngio.console.security.ConsoleAccessPolicy import net.sergeych.lyngio.console.security.ConsoleAccessPolicy
import net.sergeych.lyngio.console.security.LyngConsoleSecured import net.sergeych.lyngio.console.security.LyngConsoleSecured
@ -245,13 +242,27 @@ private class ObjConsoleEventIterator(
private suspend fun ensureCached(): Boolean { private suspend fun ensureCached(): Boolean {
if (closed) return false if (closed) return false
if (cached != null) return true if (cached != null) return true
val event = source.nextEvent() while (!closed && cached == null) {
val event = try {
source.nextEvent()
} catch (e: Throwable) {
// Consumer loops must survive source/read failures: report and keep polling.
consoleFlowDebug("console-bridge: nextEvent failed; dropping failure and continuing", e)
continue
}
if (event == null) { if (event == null) {
closeSource() closeSource()
return false return false
} }
cached = event.toObjEvent() cached = try {
return true event.toObjEvent()
} catch (e: Throwable) {
// Malformed/native event payload must not terminate consumer iteration.
consoleFlowDebug("console-bridge: malformed event dropped: $event", e)
null
}
}
return cached != null
} }
private suspend fun closeSource() { private suspend fun closeSource() {
@ -292,8 +303,14 @@ private class ObjConsoleEventIterator(
private fun ConsoleEvent.toObjEvent(): Obj = when (this) { private fun ConsoleEvent.toObjEvent(): Obj = when (this) {
is ConsoleEvent.Resize -> ObjConsoleResizeEvent(columns, rows) is ConsoleEvent.Resize -> ObjConsoleResizeEvent(columns, rows)
is ConsoleEvent.KeyDown -> ObjConsoleKeyEvent(type = ConsoleEnums.KEY_DOWN, key = key, codeName = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta) is ConsoleEvent.KeyDown -> ObjConsoleKeyEvent(type = ConsoleEnums.KEY_DOWN, key = sanitizedKeyOrFallback(key), codeName = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta)
is ConsoleEvent.KeyUp -> ObjConsoleKeyEvent(type = ConsoleEnums.KEY_UP, key = key, codeName = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta) is ConsoleEvent.KeyUp -> ObjConsoleKeyEvent(type = ConsoleEnums.KEY_UP, key = sanitizedKeyOrFallback(key), codeName = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta)
}
private fun sanitizedKeyOrFallback(key: String): String {
if (key.isNotEmpty()) return key
consoleFlowDebug("console-bridge: empty key value received; using fallback key name")
return "Unknown"
} }
private object ConsoleEnums { private object ConsoleEnums {

View File

@ -100,8 +100,9 @@ object MordantLyngConsole : LyngConsole {
var running = true var running = true
globalLaunch { globalLaunch {
var lastWidth = t.updateSize().width val initialSize = runCatching { t.updateSize() }.getOrNull()
var lastHeight = t.updateSize().height var lastWidth = initialSize?.width ?: 0
var lastHeight = initialSize?.height ?: 0
val startMark = TimeSource.Monotonic.markNow() val startMark = TimeSource.Monotonic.markNow()
var lastHeartbeatMark = startMark var lastHeartbeatMark = startMark
var loops = 0L var loops = 0L
@ -113,6 +114,18 @@ object MordantLyngConsole : LyngConsole {
var lastKeyMark = startMark var lastKeyMark = startMark
var lastRawRecoveryMark = startMark var lastRawRecoveryMark = startMark
fun tryEmitResize(width: Int, height: Int) {
if (width < 1 || height < 1) {
consoleFlowDebug("events: ignored invalid resize width=$width height=$height")
return
}
if (width == lastWidth && height == lastHeight) return
out.trySend(ConsoleEvent.Resize(width, height))
lastWidth = width
lastHeight = height
resizeEvents += 1
}
consoleFlowDebug("events: collector started") consoleFlowDebug("events: collector started")
try { try {
while (currentCoroutineContext().isActive && sourceState.withLock { running }) { while (currentCoroutineContext().isActive && sourceState.withLock { running }) {
@ -122,12 +135,7 @@ object MordantLyngConsole : LyngConsole {
delay(150) delay(150)
continue continue
} }
if (currentSize.width != lastWidth || currentSize.height != lastHeight) { tryEmitResize(currentSize.width, currentSize.height)
out.trySend(ConsoleEvent.Resize(currentSize.width, currentSize.height))
lastWidth = currentSize.width
lastHeight = currentSize.height
resizeEvents += 1
}
val raw = stateMutex.withLock { val raw = stateMutex.withLock {
if (!rawModeRequested) { if (!rawModeRequested) {
@ -173,10 +181,8 @@ object MordantLyngConsole : LyngConsole {
val ev = readResult.getOrNull() val ev = readResult.getOrNull()
val resized = runCatching { t.updateSize() }.getOrNull() val resized = runCatching { t.updateSize() }.getOrNull()
if (resized != null && (resized.width != lastWidth || resized.height != lastHeight)) { if (resized != null) {
out.trySend(ConsoleEvent.Resize(resized.width, resized.height)) tryEmitResize(resized.width, resized.height)
lastWidth = resized.width
lastHeight = resized.height
} }
when (ev) { when (ev) {

View File

@ -215,10 +215,29 @@ object JvmLyngConsole : LyngConsole {
var reader = activeTerm.reader() var reader = activeTerm.reader()
var keyThread: Thread? = null var keyThread: Thread? = null
var heartbeatThread: Thread? = null var heartbeatThread: Thread? = null
val resizeEmitMutex = Any()
var lastResizeCols = Int.MIN_VALUE
var lastResizeRows = Int.MIN_VALUE
fun emitResize() { fun emitResize() {
val size = runCatching { activeTerm.size }.getOrNull() ?: return val size = runCatching { activeTerm.size }.getOrNull() ?: return
out.trySend(ConsoleEvent.Resize(size.columns, size.rows)) val cols = size.columns
val rows = size.rows
if (cols < 1 || rows < 1) {
consoleFlowDebug("jline-events: ignored invalid resize columns=$cols rows=$rows")
return
}
val shouldEmit = synchronized(resizeEmitMutex) {
if (cols == lastResizeCols && rows == lastResizeRows) {
false
} else {
lastResizeCols = cols
lastResizeRows = rows
true
}
}
if (!shouldEmit) return
out.trySend(ConsoleEvent.Resize(cols, rows))
} }
fun cleanup() { fun cleanup() {

View File

@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych" group = "net.sergeych"
version = "1.5.0-RC2" version = "1.5.0"
// Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below // Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB