limbo-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
limbo/ui/banner.py ADDED
@@ -0,0 +1,83 @@
1
+ """Startup ASCII-art banner shown on the main screen.
2
+
3
+ Generated from the project's cartoon image (red shirt / yellow pants on a
4
+ blue background) by quantizing each pixel to the nearest of four palette
5
+ colors and mapping them to characters:
6
+
7
+ - ``@`` dark outline (line art preserved with a per-cell coverage threshold)
8
+ - ``o`` red shirt
9
+ - ``.`` yellow pants
10
+ - `` `` (space) blue background
11
+
12
+ ``startup_art_text()`` renders the same characters as a Rich ``Text`` with
13
+ the original image colors: each cell's foreground is its palette color on
14
+ the blue background, so the banner looks like the source cartoon.
15
+ """
16
+
17
+ from rich.text import Text
18
+
19
+ # Palette sampled from the source image (RGB).
20
+ BLUE = (48, 105, 169) # background
21
+ YELLOW = (253, 231, 92) # pants
22
+ RED = (237, 88, 83) # shirt
23
+ DARK = (65, 64, 94) # outline
24
+
25
+ _FG = {"@": DARK, "o": RED, ".": YELLOW, " ": BLUE}
26
+
27
+
28
+ def _style(char: str) -> str:
29
+ r, g, b = _FG[char]
30
+ bg = BLUE
31
+ return f"rgb({r},{g},{b}) on rgb({bg[0]},{bg[1]},{bg[2]})"
32
+
33
+
34
+ def startup_art_text() -> Text:
35
+ """STARTUP_ART as a Rich Text colored with the original image palette.
36
+
37
+ Lines are padded to a uniform width so the blue background forms the
38
+ same solid rectangle as the source image.
39
+ """
40
+ lines = STARTUP_ART.splitlines()
41
+ width = max(len(line) for line in lines)
42
+ text = Text()
43
+ for i, line in enumerate(lines):
44
+ if i:
45
+ text.append("\n")
46
+ for ch in line.ljust(width):
47
+ text.append(ch, style=_style(ch))
48
+ return text
49
+
50
+
51
+ STARTUP_ART = """\
52
+ @@ooooo@@ooooooooooooooooooooooooooooo@@oooooooooo@@
53
+ @ooooo@@ooooooooooooooooooooooooooooo@@oooooooooo@@
54
+ @@oooo@@ooooooooooooooooooooooooooooo@@oooooooooo@@
55
+ @@oooo@oooooooooooooooooooooooooooooo@@oooooooooo@@
56
+ @@ooo@@oooooooooooooooooooooooooooooo@@oooooooooo@@
57
+ @@ooo@@oooooooooooooooooooooooooooooo@@oooooooooo@@
58
+ @@ooo@@oooooooooooooooooooooooooooooo@@oooooooooo@@
59
+ @@ooo@@oooooooooooooooooooooooooooooo@@ooooooooooo@
60
+ @@ooo@@oooooooooooooooooooooooooooooo@@ooooooooooo@
61
+ @@ooo@@@ooooooooooooooooooooooooooooo@@oooooooooo@@
62
+ @@ooo@@@@@@@@ooooooooooooooooooooooo@@@ooooooooo@@
63
+ @@@@@@....@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ooooooo@@
64
+ @@@@@................................@@@@@@@@@@
65
+ @@@.......@@@@.@@@@................@@@@@@@@@@@
66
+ @@@.......@@@@..@...................@@@@@@...@
67
+ @@........@@@..@...................@@......@@@
68
+ @@.......@@@......................@@..@.@@@@@
69
+ @@@@ @@...@@..@@@@.....................@@@@@@@@@@@
70
+ @@@ @@..@@@....@@@@...................@@@@@@@@@
71
+ @@ @ @@@@...@...@@@@....................@@@@@@@@
72
+ @@@@@ @@.@@@....@@@....................@@
73
+ @@ @@.........@@@@.................@@
74
+ @@ @@...........@@.................@@
75
+ @@ @@...........@@.................@@
76
+ @@ @@...........@@.................@@
77
+ @@...........@@.................@@
78
+ @...........@@.................@@
79
+ @...........@@.................@@
80
+ @@..........@@.................@
81
+ @@...........@................@@
82
+ @@...........@................@@
83
+ @@...........@@...............@@"""
limbo/ui/commands.py ADDED
@@ -0,0 +1,61 @@
1
+ """Slash-command registry: one source for menu metadata and dispatch.
2
+
3
+ A slash command is defined once — name, description, whether it takes
4
+ arguments, and its handler. The autocomplete menu renders from the registry
5
+ and the input dispatcher consults it, so the two can never drift.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field
12
+
13
+ from limbo.skills import Skill
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SlashCommand:
18
+ name: str
19
+ description: str
20
+ takes_args: bool = False
21
+ kind: str = "builtin" # "builtin" | "skill"
22
+ handler: Callable[[str], None] | None = field(
23
+ default=None, compare=False, repr=False
24
+ )
25
+
26
+
27
+ class SlashCommandRegistry:
28
+ """Holds the built-in commands; merges skills for display and lookup."""
29
+
30
+ def __init__(self) -> None:
31
+ self._commands: dict[str, SlashCommand] = {}
32
+
33
+ def register(self, command: SlashCommand) -> None:
34
+ self._commands[command.name] = command
35
+
36
+ def get(self, name: str) -> SlashCommand | None:
37
+ return self._commands.get(name)
38
+
39
+ def all(self) -> list[SlashCommand]:
40
+ return list(self._commands.values())
41
+
42
+ def candidates(self, skills: list[Skill]) -> list[SlashCommand]:
43
+ """Built-in commands plus skills (built-ins win on name collisions)."""
44
+ candidates = self.all()
45
+ for skill in skills:
46
+ if f"/{skill.name}" in self._commands:
47
+ continue
48
+ candidates.append(
49
+ SlashCommand(
50
+ f"/{skill.name}",
51
+ f"[skill] {skill.description}".rstrip(),
52
+ takes_args=True,
53
+ kind="skill",
54
+ )
55
+ )
56
+ return candidates
57
+
58
+ def help_text(self) -> str:
59
+ return "可用命令:" + " · ".join(
60
+ f"{command.name} {command.description}" for command in self.all()
61
+ )
@@ -0,0 +1 @@
1
+ """Limbo screens."""
@@ -0,0 +1,213 @@
1
+ """2048 mini-game: pure game logic + a modal screen.
2
+
3
+ The screen is a modal pushed on top of the main chat screen. Textual keeps
4
+ workers on the underlying screen running while a modal is up, so the agent
5
+ turn continues in the background — the game never interrupts it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ from typing import Literal
12
+
13
+ from textual.app import ComposeResult
14
+ from textual.binding import Binding
15
+ from textual.containers import Grid, Vertical
16
+ from textual.screen import ModalScreen
17
+ from textual.widgets import Static
18
+
19
+ Direction = Literal["up", "down", "left", "right"]
20
+
21
+ TARGET = 2048
22
+
23
+
24
+ class Game2048:
25
+ """Pure 2048 game logic, independent of the UI."""
26
+
27
+ SIZE = 4
28
+
29
+ def __init__(self, rng: random.Random | None = None) -> None:
30
+ self._rng = rng or random.Random()
31
+ self.grid: list[list[int]] = [
32
+ [0] * self.SIZE for _ in range(self.SIZE)
33
+ ]
34
+ self.score = 0
35
+ self.won = False
36
+ self.spawn()
37
+ self.spawn()
38
+
39
+ # -- state ----------------------------------------------------------------
40
+
41
+ @property
42
+ def over(self) -> bool:
43
+ """Game over when no empty cell and no adjacent equal tiles remain."""
44
+ size = self.SIZE
45
+ for r in range(size):
46
+ for c in range(size):
47
+ if self.grid[r][c] == 0:
48
+ return False
49
+ if c + 1 < size and self.grid[r][c] == self.grid[r][c + 1]:
50
+ return False
51
+ if r + 1 < size and self.grid[r][c] == self.grid[r + 1][c]:
52
+ return False
53
+ return True
54
+
55
+ def spawn(self) -> bool:
56
+ """Add a 2 (90%) or 4 (10%) to a random empty cell."""
57
+ empties = [
58
+ (r, c)
59
+ for r in range(self.SIZE)
60
+ for c in range(self.SIZE)
61
+ if self.grid[r][c] == 0
62
+ ]
63
+ if not empties:
64
+ return False
65
+ r, c = self._rng.choice(empties)
66
+ self.grid[r][c] = 4 if self._rng.random() < 0.1 else 2
67
+ return True
68
+
69
+ # -- moves ------------------------------------------------------------------
70
+
71
+ def move(self, direction: Direction) -> bool:
72
+ """Slide and merge the grid. Returns True if anything changed."""
73
+ lines = self._lines(direction)
74
+ gained = 0
75
+ new_lines = []
76
+ for line in lines:
77
+ merged, line_gained = self._merge_left(line)
78
+ new_lines.append(merged)
79
+ gained += line_gained
80
+ if new_lines == lines:
81
+ return False
82
+ self.score += gained
83
+ self._write_lines(direction, new_lines)
84
+ if not self.won and any(
85
+ value >= TARGET for row in self.grid for value in row
86
+ ):
87
+ self.won = True
88
+ self.spawn()
89
+ return True
90
+
91
+ def _lines(self, direction: Direction) -> list[list[int]]:
92
+ """Extract rows/columns so every move reduces to 'merge left'."""
93
+ size = self.SIZE
94
+ if direction == "left":
95
+ return [row[:] for row in self.grid]
96
+ if direction == "right":
97
+ return [row[::-1] for row in self.grid]
98
+ if direction == "up":
99
+ return [[self.grid[r][c] for r in range(size)] for c in range(size)]
100
+ return [
101
+ [self.grid[r][c] for r in range(size - 1, -1, -1)]
102
+ for c in range(size)
103
+ ]
104
+
105
+ def _write_lines(self, direction: Direction, lines: list[list[int]]) -> None:
106
+ """Inverse of `_lines`."""
107
+ size = self.SIZE
108
+ if direction == "left":
109
+ self.grid = [line[:] for line in lines]
110
+ elif direction == "right":
111
+ self.grid = [line[::-1] for line in lines]
112
+ elif direction == "up":
113
+ for c in range(size):
114
+ for r in range(size):
115
+ self.grid[r][c] = lines[c][r]
116
+ else:
117
+ for c in range(size):
118
+ for r in range(size):
119
+ self.grid[size - 1 - r][c] = lines[c][r]
120
+
121
+ @staticmethod
122
+ def _merge_left(line: list[int]) -> tuple[list[int], int]:
123
+ """Compress and merge one line towards index 0."""
124
+ tiles = [value for value in line if value]
125
+ merged: list[int] = []
126
+ gained = 0
127
+ i = 0
128
+ while i < len(tiles):
129
+ if i + 1 < len(tiles) and tiles[i] == tiles[i + 1]:
130
+ merged.append(tiles[i] * 2)
131
+ gained += tiles[i] * 2
132
+ i += 2
133
+ else:
134
+ merged.append(tiles[i])
135
+ i += 1
136
+ return merged + [0] * (len(line) - len(merged)), gained
137
+
138
+
139
+ class Game2048Screen(ModalScreen[None]):
140
+ """Playable 2048 modal. The agent keeps running underneath."""
141
+
142
+ BINDINGS = [
143
+ Binding("up", "move('up')", "↑"),
144
+ Binding("down", "move('down')", "↓"),
145
+ Binding("left", "move('left')", "←"),
146
+ Binding("right", "move('right')", "→"),
147
+ Binding("w", "move('up')", show=False),
148
+ Binding("s", "move('down')", show=False),
149
+ Binding("a", "move('left')", show=False),
150
+ Binding("d", "move('right')", show=False),
151
+ Binding("r", "restart", "重开"),
152
+ Binding("q", "close", "关闭"),
153
+ Binding("escape", "close", "关闭"),
154
+ ]
155
+
156
+ def __init__(self, *args, **kwargs) -> None:
157
+ super().__init__(*args, **kwargs)
158
+ self.game = Game2048()
159
+
160
+ def compose(self) -> ComposeResult:
161
+ with Vertical(id="game-container"):
162
+ yield Static("", id="game-header", markup=False)
163
+ with Grid(id="game-grid"):
164
+ for r in range(self.game.SIZE):
165
+ for c in range(self.game.SIZE):
166
+ yield Static("", classes="tile", id=f"cell-{r}-{c}")
167
+ yield Static(
168
+ "方向键 / WASD 移动 · R 重开 · Q/Esc 关闭",
169
+ id="game-footer",
170
+ markup=False,
171
+ )
172
+
173
+ def on_mount(self) -> None:
174
+ self._refresh()
175
+
176
+ # -- actions --------------------------------------------------------------
177
+
178
+ def action_move(self, direction: Direction) -> None:
179
+ if self.game.over:
180
+ return
181
+ if self.game.move(direction):
182
+ self._refresh()
183
+
184
+ def action_restart(self) -> None:
185
+ self.game = Game2048()
186
+ self._refresh()
187
+
188
+ def action_close(self) -> None:
189
+ self.dismiss()
190
+
191
+ # -- rendering --------------------------------------------------------------
192
+
193
+ def _refresh(self) -> None:
194
+ header = self.query_one("#game-header", Static)
195
+ status = ""
196
+ if self.game.over:
197
+ status = " · 游戏结束,按 R 重开"
198
+ elif self.game.won:
199
+ status = " · 🎉 达成 2048!可继续挑战"
200
+ header.update(f"2048 · 分数 {self.game.score}{status}")
201
+
202
+ for r in range(self.game.SIZE):
203
+ for c in range(self.game.SIZE):
204
+ value = self.game.grid[r][c]
205
+ cell = self.query_one(f"#cell-{r}-{c}", Static)
206
+ cell.update(str(value) if value else "")
207
+ if value:
208
+ tile_class = (
209
+ f"tile-{value}" if value <= TARGET else "tile-super"
210
+ )
211
+ cell.set_classes(f"tile {tile_class}")
212
+ else:
213
+ cell.set_classes("tile")