folderui 0.1.0__tar.gz
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.
- folderui-0.1.0/.gitignore +6 -0
- folderui-0.1.0/LICENSE +21 -0
- folderui-0.1.0/PKG-INFO +140 -0
- folderui-0.1.0/README.md +121 -0
- folderui-0.1.0/examples/auction.py +368 -0
- folderui-0.1.0/examples/auction_bot.py +32 -0
- folderui-0.1.0/examples/guess.py +44 -0
- folderui-0.1.0/pyproject.toml +33 -0
- folderui-0.1.0/src/folderui/__init__.py +6 -0
- folderui-0.1.0/src/folderui/fsutil.py +28 -0
- folderui-0.1.0/src/folderui/py.typed +0 -0
- folderui-0.1.0/src/folderui/server.py +68 -0
- folderui-0.1.0/src/folderui/session.py +44 -0
- folderui-0.1.0/src/folderui/ui.py +261 -0
- folderui-0.1.0/tests/test_folderui.py +166 -0
folderui-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xiao-e-yun
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
folderui-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: folderui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn a folder into an immediate-mode GUI for humans and AI agents: *.md files are the view, *.sh files are buttons.
|
|
5
|
+
Project-URL: Homepage, https://github.com/xiao-e-yun/folderui
|
|
6
|
+
Project-URL: Repository, https://github.com/xiao-e-yun/folderui
|
|
7
|
+
Author: xiao-e-yun
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent,gui,immediate-mode,llm,multiplayer,turn-based
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: POSIX
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# folderui
|
|
21
|
+
|
|
22
|
+
把**資料夾變成立即式 GUI**,給人類和 AI agent 使用。資料夾裡只有兩種東西:
|
|
23
|
+
|
|
24
|
+
- `*.md` 等檔案:畫面(隨時可以 `cat`)
|
|
25
|
+
- `*.sh`:按鈕(只有現在能按的才會出現)
|
|
26
|
+
|
|
27
|
+
執行按鈕後,腳本會**一直等到你需要做下一個決定**才回應,並印出:操作結果 + 這段期間發生的事 + 目前狀況 + 可以按的按鈕。
|
|
28
|
+
所以 agent 的迴圈就是「執行按鈕 → 讀回應 → 執行下一個按鈕」,不需要另外等待或輪詢。
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install folderui # 或 uv add folderui;只用標準函式庫,需要 POSIX shell
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```console
|
|
35
|
+
$ ./bid.sh 40
|
|
36
|
+
⏳ 已送出,等待中… ← 其他人還在出價,腳本卡住
|
|
37
|
+
✅ 已出價 40
|
|
38
|
+
開標:bob 5、carol 15、你 40
|
|
39
|
+
🔨 你以 40 得標 …
|
|
40
|
+
🔔 bob 拍賣「月光琉璃盞」…
|
|
41
|
+
|
|
42
|
+
── 可以做的事 ──
|
|
43
|
+
./bid.sh <金額 1-60> 密封出價
|
|
44
|
+
./pass.sh 不出價
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 寫一個 UI
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from folderui import FolderUI
|
|
51
|
+
|
|
52
|
+
class Guess(FolderUI):
|
|
53
|
+
def draw(self): # 每一幀重新宣告畫面,不可修改狀態
|
|
54
|
+
self.file("rules.md", "猜 1-100")
|
|
55
|
+
self.button("guess", "<數字>")
|
|
56
|
+
self.reply(f"已經猜了 {len(self.tries)} 次")
|
|
57
|
+
|
|
58
|
+
def action(self, name, args): # 按鈕被按下:回傳文字,或 raise ValueError 代表失敗
|
|
59
|
+
n = int(args[0])
|
|
60
|
+
...
|
|
61
|
+
return "太大"
|
|
62
|
+
|
|
63
|
+
Guess("/tmp/my-ui").run()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| 方法 | 在哪裡呼叫 | 作用 |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `file(path, content)` | `draw()` | 宣告一個檔案;沒宣告的會自動刪除 |
|
|
69
|
+
| `button(path, hint, options=None)` | `draw()` | 宣告一個按鈕 → `<path>.sh`,路徑可以分層(`players/2/kick`) |
|
|
70
|
+
| `reply(text)` | `draw()` | 腳本回應時印出的目前狀況 |
|
|
71
|
+
| `notify(text)` | `action()` / `receive()` | 累積一則消息,下一次回應時送出 |
|
|
72
|
+
| `close()` | `action()` / `receive()` | 結束:移除按鈕、回應等待中的腳本 |
|
|
73
|
+
| `push(msg)` | 其他執行緒 | 送訊息給這個 UI,會呼叫 `receive(msg)` |
|
|
74
|
+
|
|
75
|
+
**回應規則只有一條**:操作處理完之後,第一個「有按鈕可按」的畫面出現時(或 `close()`)才回應。
|
|
76
|
+
|
|
77
|
+
- 輸入錯誤 → 狀態沒變、按鈕還在 → 立刻回應(`raise` 的訊息,exit code 1)
|
|
78
|
+
- 查詢類操作 → 按鈕還在 → 立刻回應
|
|
79
|
+
- 送出後要等別人 → 沒有按鈕 → 等到輪到你
|
|
80
|
+
|
|
81
|
+
## 多人:1 個伺服器 + N 個客戶端
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
class MyServer(Server):
|
|
85
|
+
def handle(self, who, cmd): # 純邏輯;raise ValueError 代表規則不允許
|
|
86
|
+
...
|
|
87
|
+
for p in self.clients:
|
|
88
|
+
self.push(p, Update(...)) # 推播給客戶端
|
|
89
|
+
return "✅ 已出價"
|
|
90
|
+
|
|
91
|
+
class PlayerUI(FolderUI):
|
|
92
|
+
def action(self, name, args):
|
|
93
|
+
return self.server.call(self.me, ("bid", int(args[0]))) # 同步呼叫,例外會傳回來
|
|
94
|
+
def receive(self, update):
|
|
95
|
+
self.view = update.view # 伺服器推播 → 框架自動重畫
|
|
96
|
+
|
|
97
|
+
server = MyServer()
|
|
98
|
+
for i in range(n):
|
|
99
|
+
server.connect(i, PlayerUI(f"{root}/player_{i}", server, i))
|
|
100
|
+
server.start()
|
|
101
|
+
for ui in server.clients.values():
|
|
102
|
+
ui.start()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
框架不管多人同步:伺服器在一個執行緒依序處理請求,推播走無上限佇列(伺服器永遠不等客戶端)。
|
|
106
|
+
|
|
107
|
+
## 程式型 agent
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from folderui import Session
|
|
111
|
+
s = Session("/tmp/folderui-auction-xxxx/player_0")
|
|
112
|
+
s.ready()
|
|
113
|
+
while buttons := s.buttons(): # {路徑: {hint, options}},空的代表結束
|
|
114
|
+
ok, text = s.press("bid", "40")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## 細節
|
|
118
|
+
|
|
119
|
+
- 腳本預設無限等待。設定 `FOLDERUI_TIMEOUT=秒數` 會在逾時後以 exit code 2 結束;
|
|
120
|
+
**再執行一次同樣的指令即可繼續等待或拿到回應**(不會重複送出)。回應被讀走之前,該按鈕的腳本會保留。
|
|
121
|
+
- 協定檔案都是隱藏檔:`.request`、`.response`、`.done`、`.buttons.json`。
|
|
122
|
+
不能執行腳本的 agent 也可以直接寫 `.request`(按鈕路徑與參數以 NUL 分隔)。
|
|
123
|
+
|
|
124
|
+
## 範例
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
uv run python examples/guess.py # 單人:猜數字
|
|
128
|
+
uv run python examples/auction.py --players 4 --rounds 3 # 多人:英式拍賣(玩家 + 觀眾)
|
|
129
|
+
uv run python examples/auction_bot.py <資料夾> bot0 # 拍賣用的隨機 bot
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## 開發
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
uv sync
|
|
136
|
+
uv run pytest
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
發布:在 PyPI 設定 trusted publisher(repo `xiao-e-yun/folderui`、workflow `publish.yml`、environment `pypi`),
|
|
140
|
+
之後改 `pyproject.toml` 的版本號並推送 `v*` tag 就會自動發布。
|
folderui-0.1.0/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# folderui
|
|
2
|
+
|
|
3
|
+
把**資料夾變成立即式 GUI**,給人類和 AI agent 使用。資料夾裡只有兩種東西:
|
|
4
|
+
|
|
5
|
+
- `*.md` 等檔案:畫面(隨時可以 `cat`)
|
|
6
|
+
- `*.sh`:按鈕(只有現在能按的才會出現)
|
|
7
|
+
|
|
8
|
+
執行按鈕後,腳本會**一直等到你需要做下一個決定**才回應,並印出:操作結果 + 這段期間發生的事 + 目前狀況 + 可以按的按鈕。
|
|
9
|
+
所以 agent 的迴圈就是「執行按鈕 → 讀回應 → 執行下一個按鈕」,不需要另外等待或輪詢。
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install folderui # 或 uv add folderui;只用標準函式庫,需要 POSIX shell
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```console
|
|
16
|
+
$ ./bid.sh 40
|
|
17
|
+
⏳ 已送出,等待中… ← 其他人還在出價,腳本卡住
|
|
18
|
+
✅ 已出價 40
|
|
19
|
+
開標:bob 5、carol 15、你 40
|
|
20
|
+
🔨 你以 40 得標 …
|
|
21
|
+
🔔 bob 拍賣「月光琉璃盞」…
|
|
22
|
+
|
|
23
|
+
── 可以做的事 ──
|
|
24
|
+
./bid.sh <金額 1-60> 密封出價
|
|
25
|
+
./pass.sh 不出價
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 寫一個 UI
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from folderui import FolderUI
|
|
32
|
+
|
|
33
|
+
class Guess(FolderUI):
|
|
34
|
+
def draw(self): # 每一幀重新宣告畫面,不可修改狀態
|
|
35
|
+
self.file("rules.md", "猜 1-100")
|
|
36
|
+
self.button("guess", "<數字>")
|
|
37
|
+
self.reply(f"已經猜了 {len(self.tries)} 次")
|
|
38
|
+
|
|
39
|
+
def action(self, name, args): # 按鈕被按下:回傳文字,或 raise ValueError 代表失敗
|
|
40
|
+
n = int(args[0])
|
|
41
|
+
...
|
|
42
|
+
return "太大"
|
|
43
|
+
|
|
44
|
+
Guess("/tmp/my-ui").run()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| 方法 | 在哪裡呼叫 | 作用 |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `file(path, content)` | `draw()` | 宣告一個檔案;沒宣告的會自動刪除 |
|
|
50
|
+
| `button(path, hint, options=None)` | `draw()` | 宣告一個按鈕 → `<path>.sh`,路徑可以分層(`players/2/kick`) |
|
|
51
|
+
| `reply(text)` | `draw()` | 腳本回應時印出的目前狀況 |
|
|
52
|
+
| `notify(text)` | `action()` / `receive()` | 累積一則消息,下一次回應時送出 |
|
|
53
|
+
| `close()` | `action()` / `receive()` | 結束:移除按鈕、回應等待中的腳本 |
|
|
54
|
+
| `push(msg)` | 其他執行緒 | 送訊息給這個 UI,會呼叫 `receive(msg)` |
|
|
55
|
+
|
|
56
|
+
**回應規則只有一條**:操作處理完之後,第一個「有按鈕可按」的畫面出現時(或 `close()`)才回應。
|
|
57
|
+
|
|
58
|
+
- 輸入錯誤 → 狀態沒變、按鈕還在 → 立刻回應(`raise` 的訊息,exit code 1)
|
|
59
|
+
- 查詢類操作 → 按鈕還在 → 立刻回應
|
|
60
|
+
- 送出後要等別人 → 沒有按鈕 → 等到輪到你
|
|
61
|
+
|
|
62
|
+
## 多人:1 個伺服器 + N 個客戶端
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
class MyServer(Server):
|
|
66
|
+
def handle(self, who, cmd): # 純邏輯;raise ValueError 代表規則不允許
|
|
67
|
+
...
|
|
68
|
+
for p in self.clients:
|
|
69
|
+
self.push(p, Update(...)) # 推播給客戶端
|
|
70
|
+
return "✅ 已出價"
|
|
71
|
+
|
|
72
|
+
class PlayerUI(FolderUI):
|
|
73
|
+
def action(self, name, args):
|
|
74
|
+
return self.server.call(self.me, ("bid", int(args[0]))) # 同步呼叫,例外會傳回來
|
|
75
|
+
def receive(self, update):
|
|
76
|
+
self.view = update.view # 伺服器推播 → 框架自動重畫
|
|
77
|
+
|
|
78
|
+
server = MyServer()
|
|
79
|
+
for i in range(n):
|
|
80
|
+
server.connect(i, PlayerUI(f"{root}/player_{i}", server, i))
|
|
81
|
+
server.start()
|
|
82
|
+
for ui in server.clients.values():
|
|
83
|
+
ui.start()
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
框架不管多人同步:伺服器在一個執行緒依序處理請求,推播走無上限佇列(伺服器永遠不等客戶端)。
|
|
87
|
+
|
|
88
|
+
## 程式型 agent
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from folderui import Session
|
|
92
|
+
s = Session("/tmp/folderui-auction-xxxx/player_0")
|
|
93
|
+
s.ready()
|
|
94
|
+
while buttons := s.buttons(): # {路徑: {hint, options}},空的代表結束
|
|
95
|
+
ok, text = s.press("bid", "40")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 細節
|
|
99
|
+
|
|
100
|
+
- 腳本預設無限等待。設定 `FOLDERUI_TIMEOUT=秒數` 會在逾時後以 exit code 2 結束;
|
|
101
|
+
**再執行一次同樣的指令即可繼續等待或拿到回應**(不會重複送出)。回應被讀走之前,該按鈕的腳本會保留。
|
|
102
|
+
- 協定檔案都是隱藏檔:`.request`、`.response`、`.done`、`.buttons.json`。
|
|
103
|
+
不能執行腳本的 agent 也可以直接寫 `.request`(按鈕路徑與參數以 NUL 分隔)。
|
|
104
|
+
|
|
105
|
+
## 範例
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
uv run python examples/guess.py # 單人:猜數字
|
|
109
|
+
uv run python examples/auction.py --players 4 --rounds 3 # 多人:英式拍賣(玩家 + 觀眾)
|
|
110
|
+
uv run python examples/auction_bot.py <資料夾> bot0 # 拍賣用的隨機 bot
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## 開發
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
uv sync
|
|
117
|
+
uv run pytest
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
發布:在 PyPI 設定 trusted publisher(repo `xiao-e-yun/folderui`、workflow `publish.yml`、environment `pypi`),
|
|
121
|
+
之後改 `pyproject.toml` 的版本號並推送 `v*` tag 就會自動發布。
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""多人範例:拍賣。1 個伺服器(純遊戲邏輯)+ N 個玩家客戶端 + 1 個觀眾客戶端。
|
|
2
|
+
|
|
3
|
+
uv run python examples/auction.py [--players 4] [--rounds 3] [--seed N] [--dir DIR]
|
|
4
|
+
|
|
5
|
+
規則:每輪每人從 3 件寶物選 1 件拍賣;賣家寫介紹,買家只看得到名稱和介紹,輪流公開喊價。
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import random
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from folderui import FolderUI, Server
|
|
14
|
+
|
|
15
|
+
NAMES = ["龍紋古劍", "月光琉璃盞", "青銅羅盤", "鳳羽披風", "沉船金幣", "賢者手稿",
|
|
16
|
+
"翡翠面具", "星象儀", "黑曜石戒指", "古神雕像", "鍊金術士之瓶", "王冠碎片",
|
|
17
|
+
"水晶頭骨", "精靈豎琴", "海妖之淚", "隕鐵匕首", "藏寶圖", "不老藥"]
|
|
18
|
+
|
|
19
|
+
TRAITS = [("皇室收藏", 30), ("名家之作", 25), ("古老", 20), ("鑲金", 15), ("完好無缺", 10),
|
|
20
|
+
("稀有", 10), ("普通", 0), ("來路不明", -10), ("有裂痕", -15), ("被詛咒", -30),
|
|
21
|
+
("贗品", -40)]
|
|
22
|
+
|
|
23
|
+
RULES = """# 拍賣(共 {rounds} 輪)
|
|
24
|
+
|
|
25
|
+
1. 每輪開始時每人拿到 3 件新寶物,每件有底價和 2 個詞條(會加減價值),只有你自己看得到。
|
|
26
|
+
選 1 件當你這輪的拍品,其餘兩件丟棄。
|
|
27
|
+
2. 每位玩家輪流當賣家,寫一段介紹。**買家只看得到寶物名稱和這段介紹**,可以誇大或隱瞞。
|
|
28
|
+
3. 公開喊價:從賣家的下一位開始輪流,輪到你時可以加價或退出這件拍品(退出後不能再加入)。
|
|
29
|
+
第一口至少 1,之後每次至少比目前最高價多 {step}。只剩最高出價者時得標,付錢給賣家。
|
|
30
|
+
4. 每場結束後公開寶物的詞條和真實價值,所有紀錄都在 history.md。
|
|
31
|
+
5. 起始每人 100 金。總分 = 金錢 + 擁有寶物的真實價值,最高者獲勝。
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
STEP = 5 # 加價的最小幅度
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Treasure:
|
|
40
|
+
name: str
|
|
41
|
+
base: int
|
|
42
|
+
traits: list[tuple[str, int]]
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def value(self) -> int:
|
|
46
|
+
return max(0, self.base + sum(v for _, v in self.traits))
|
|
47
|
+
|
|
48
|
+
def card(self) -> str:
|
|
49
|
+
lines = [f"**{self.name}**(真實價值 {self.value})", f"- 底價 {self.base}"]
|
|
50
|
+
return "\n".join(lines + [f"- {t}({v:+d})" for t, v in self.traits])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Update:
|
|
55
|
+
view: dict
|
|
56
|
+
events: list[str]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------- 伺服器:純遊戲邏輯,不知道資料夾的存在 ----------------
|
|
60
|
+
|
|
61
|
+
class AuctionServer(Server):
|
|
62
|
+
step = STEP
|
|
63
|
+
|
|
64
|
+
def __init__(self, n, rounds=1, seed=None, money=100):
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.rng = random.Random(seed)
|
|
67
|
+
self.n, self.rounds, self.round = n, rounds, 0
|
|
68
|
+
self.names: list[str | None] = [None] * n
|
|
69
|
+
self.money = [money] * n
|
|
70
|
+
self.owned: list[list[Treasure]] = [[] for _ in range(n)]
|
|
71
|
+
self.history: list[str] = []
|
|
72
|
+
self.phase = "join" # join → (pick → describe ⇄ bid) × rounds → over
|
|
73
|
+
self.seller = 0
|
|
74
|
+
self.sold = 0 # 這輪已經拍完幾場
|
|
75
|
+
self.pitch = ""
|
|
76
|
+
self.high, self.leader = 0, None # 目前最高價與出價者
|
|
77
|
+
self.active: set[int] = set() # 還沒退出這件拍品的買家
|
|
78
|
+
self.turn = None # 輪到誰喊價
|
|
79
|
+
self.bidlog: list[str] = []
|
|
80
|
+
self.outbox: dict = {}
|
|
81
|
+
self.deal()
|
|
82
|
+
|
|
83
|
+
def deal(self):
|
|
84
|
+
self.round += 1
|
|
85
|
+
self.hands = [[Treasure(name, self.rng.randint(20, 60), self.rng.sample(TRAITS, 2))
|
|
86
|
+
for name in self.rng.sample(NAMES, 3)] for _ in range(self.n)]
|
|
87
|
+
self.lots: list[Treasure | None] = [None] * self.n
|
|
88
|
+
self.seller = (self.round - 1) % self.n # 每輪換人先賣
|
|
89
|
+
self.sold = 0
|
|
90
|
+
|
|
91
|
+
def name(self, p):
|
|
92
|
+
return self.names[p] or f"player_{p}"
|
|
93
|
+
|
|
94
|
+
def say(self, text, to=None):
|
|
95
|
+
for who in (self.clients if to is None else to):
|
|
96
|
+
self.outbox.setdefault(who, []).append(text)
|
|
97
|
+
|
|
98
|
+
def publish(self):
|
|
99
|
+
for who in self.clients:
|
|
100
|
+
self.push(who, Update(self.view(who), self.outbox.pop(who, [])))
|
|
101
|
+
|
|
102
|
+
def handle(self, p, cmd):
|
|
103
|
+
kind, arg = cmd
|
|
104
|
+
result = getattr(self, f"do_{kind}")(p, arg)
|
|
105
|
+
self.publish()
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
def do_join(self, p, name):
|
|
109
|
+
if self.phase != "join" or self.names[p]:
|
|
110
|
+
raise ValueError("現在不能加入")
|
|
111
|
+
if not 1 <= len(name) <= 20 or name in self.names:
|
|
112
|
+
raise ValueError("名字需為 1-20 字,且不能和別人重複")
|
|
113
|
+
self.names[p] = name
|
|
114
|
+
if all(self.names):
|
|
115
|
+
self.phase = "pick"
|
|
116
|
+
self.say("所有玩家已加入:" + "、".join(self.names) + f"\n\n## 第 1/{self.rounds} 輪")
|
|
117
|
+
return f"✅ 歡迎,{name}"
|
|
118
|
+
|
|
119
|
+
def do_pick(self, p, tname):
|
|
120
|
+
if self.phase != "pick" or self.lots[p]:
|
|
121
|
+
raise ValueError("現在不能選寶物")
|
|
122
|
+
lot = next((t for t in self.hands[p] if t.name == tname), None)
|
|
123
|
+
if lot is None:
|
|
124
|
+
raise ValueError("選項必須是:" + " / ".join(t.name for t in self.hands[p]))
|
|
125
|
+
self.lots[p] = lot
|
|
126
|
+
if all(self.lots):
|
|
127
|
+
self.phase = "describe"
|
|
128
|
+
self.say(f"每位玩家都選好了拍品,由 {self.name(self.seller)} 先賣。")
|
|
129
|
+
return f"✅ 你選了 {tname}"
|
|
130
|
+
|
|
131
|
+
def do_describe(self, p, text):
|
|
132
|
+
if self.phase != "describe" or p != self.seller:
|
|
133
|
+
raise ValueError("現在不是你介紹的時候")
|
|
134
|
+
if not 1 <= len(text) <= 500:
|
|
135
|
+
raise ValueError("介紹需為 1-500 字")
|
|
136
|
+
self.pitch, self.phase = text, "bid"
|
|
137
|
+
self.high, self.leader, self.bidlog = 0, None, []
|
|
138
|
+
self.active = {b for b in range(self.n) if b != p}
|
|
139
|
+
self.turn = (p + 1) % self.n
|
|
140
|
+
self.say(f"🔔 {self.name(p)} 拍賣「{self.lots[p].name}」:\n> {text}\n\n由 {self.name(self.turn)} 先喊價。")
|
|
141
|
+
return "✅ 已送出介紹"
|
|
142
|
+
|
|
143
|
+
def min_bid(self):
|
|
144
|
+
return self.high + self.step if self.leader is not None else 1
|
|
145
|
+
|
|
146
|
+
def do_bid(self, p, amount):
|
|
147
|
+
if self.phase != "bid" or p != self.turn:
|
|
148
|
+
raise ValueError("現在不是你喊價的時候")
|
|
149
|
+
if amount is None:
|
|
150
|
+
self.active.discard(p)
|
|
151
|
+
self.bidlog.append(f"{self.name(p)} 退出")
|
|
152
|
+
else:
|
|
153
|
+
if not self.min_bid() <= amount <= self.money[p]:
|
|
154
|
+
raise ValueError(f"出價需在 {self.min_bid()}-{self.money[p]} 之間"
|
|
155
|
+
if self.min_bid() <= self.money[p] else "你的錢不夠加價,只能退出")
|
|
156
|
+
self.high, self.leader = amount, p
|
|
157
|
+
self.bidlog.append(f"{self.name(p)} {amount}")
|
|
158
|
+
self.say(f"📣 {self.bidlog[-1]}" + (f"(目前最高:{self.name(self.leader)} {self.high})"
|
|
159
|
+
if self.leader is not None else ""))
|
|
160
|
+
rivals = self.active - {self.leader}
|
|
161
|
+
if not rivals:
|
|
162
|
+
self.settle()
|
|
163
|
+
else:
|
|
164
|
+
self.turn = next(c for k in range(1, self.n + 1)
|
|
165
|
+
if (c := (p + k) % self.n) in rivals)
|
|
166
|
+
return f"✅ 已出價 {amount}" if amount else "✅ 已退出這件拍品"
|
|
167
|
+
|
|
168
|
+
def settle(self):
|
|
169
|
+
s, lot = self.seller, self.lots[self.seller]
|
|
170
|
+
self.turn = None
|
|
171
|
+
if self.leader is not None:
|
|
172
|
+
w, price = self.leader, self.high
|
|
173
|
+
self.money[w] -= price
|
|
174
|
+
self.money[s] += price
|
|
175
|
+
self.owned[w].append(lot)
|
|
176
|
+
result = f"🔨 {self.name(w)} 以 {price} 得標"
|
|
177
|
+
else:
|
|
178
|
+
self.owned[s].append(lot)
|
|
179
|
+
result = f"流標,{self.name(s)} 留下寶物"
|
|
180
|
+
self.say(f"{result}。揭曉:\n{lot.card()}")
|
|
181
|
+
self.history.append(f"## 第 {self.round} 輪:{self.name(s)} 賣「{lot.name}」\n\n"
|
|
182
|
+
f"介紹:\n> {self.pitch}\n\n"
|
|
183
|
+
+ "喊價:" + "、".join(self.bidlog)
|
|
184
|
+
+ f"\n\n{result}。揭曉:\n{lot.card()}\n")
|
|
185
|
+
self.sold += 1
|
|
186
|
+
if self.sold < self.n:
|
|
187
|
+
self.seller, self.phase = (s + 1) % self.n, "describe"
|
|
188
|
+
elif self.round < self.rounds:
|
|
189
|
+
self.deal()
|
|
190
|
+
self.phase = "pick"
|
|
191
|
+
self.say(f"## 第 {self.round}/{self.rounds} 輪\n每人拿到 3 件新寶物,請重新選拍品。")
|
|
192
|
+
else:
|
|
193
|
+
self.phase = "over"
|
|
194
|
+
self.say(self.final_result())
|
|
195
|
+
|
|
196
|
+
def todo(self, p):
|
|
197
|
+
if self.phase == "join" and not self.names[p]:
|
|
198
|
+
return "join"
|
|
199
|
+
if self.phase == "pick" and not self.lots[p]:
|
|
200
|
+
return "pick"
|
|
201
|
+
if self.phase == "describe" and p == self.seller:
|
|
202
|
+
return "describe"
|
|
203
|
+
if self.phase == "bid" and p == self.turn:
|
|
204
|
+
return "bid"
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def view(self, who) -> dict:
|
|
208
|
+
v = {
|
|
209
|
+
"phase": self.phase,
|
|
210
|
+
"round": self.round,
|
|
211
|
+
"rounds": self.rounds,
|
|
212
|
+
"history": list(self.history),
|
|
213
|
+
"roster": [(self.name(p), self.money[p], len(self.owned[p])) for p in range(self.n)],
|
|
214
|
+
"seller": self.name(self.seller),
|
|
215
|
+
"lot_name": self.lots[self.seller].name if self.phase in ("describe", "bid") else None,
|
|
216
|
+
"pitch": self.pitch if self.phase == "bid" else None,
|
|
217
|
+
"bidding": self.bidding() if self.phase == "bid" else None,
|
|
218
|
+
"min_bid": self.min_bid(),
|
|
219
|
+
}
|
|
220
|
+
if who == "spectator":
|
|
221
|
+
v["lots"] = [f"{self.name(p)}:{t.card()}" for p, t in enumerate(self.lots) if t]
|
|
222
|
+
else:
|
|
223
|
+
v.update(me=who, todo=self.todo(who), money=self.money[who],
|
|
224
|
+
owned=[t.card() for t in self.owned[who]],
|
|
225
|
+
hand=[t.card() for t in self.hands[who]] if self.phase in ("join", "pick") else None,
|
|
226
|
+
options=[t.name for t in self.hands[who]],
|
|
227
|
+
my_lot=self.lots[who].card() if self.lots[who] else None)
|
|
228
|
+
return v
|
|
229
|
+
|
|
230
|
+
def bidding(self):
|
|
231
|
+
lines = ["喊價:" + ("、".join(self.bidlog) or "(還沒有人出價)")]
|
|
232
|
+
if self.leader is not None:
|
|
233
|
+
lines.append(f"目前最高:{self.name(self.leader)} {self.high}")
|
|
234
|
+
lines.append("還在競標:" + "、".join(self.name(b) for b in sorted(self.active)))
|
|
235
|
+
lines.append(f"輪到:{self.name(self.turn)}")
|
|
236
|
+
return "\n".join(lines)
|
|
237
|
+
|
|
238
|
+
def final_result(self):
|
|
239
|
+
scores = [self.money[p] + sum(t.value for t in self.owned[p]) for p in range(self.n)]
|
|
240
|
+
lines = ["# 最終結果", "", "| 玩家 | 金錢 | 寶物價值 | 總分 |", "|---|---|---|---|"]
|
|
241
|
+
for p in sorted(range(self.n), key=lambda p: -scores[p]):
|
|
242
|
+
lines.append(f"| {self.name(p)} | {self.money[p]} | {scores[p] - self.money[p]} | {scores[p]} |")
|
|
243
|
+
best = max(scores)
|
|
244
|
+
return "\n".join(lines + ["", "🏆 獲勝:" + "、".join(self.name(p) for p in range(self.n)
|
|
245
|
+
if scores[p] == best)])
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------- 客戶端:每個資料夾一個 FolderUI ----------------
|
|
249
|
+
|
|
250
|
+
def roster_md(v):
|
|
251
|
+
rows = [f"# 玩家(第 {v['round']}/{v['rounds']} 輪)", "", "| 名字 | 金錢 | 寶物 |", "|---|---|---|"]
|
|
252
|
+
return "\n".join(rows + [f"| {n} | 💰 {m} | 🏺 {k} 件 |" for n, m, k in v["roster"]]) + "\n"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class PlayerUI(FolderUI):
|
|
256
|
+
def __init__(self, path, server, me):
|
|
257
|
+
super().__init__(path)
|
|
258
|
+
self.server, self.me = server, me
|
|
259
|
+
self.view = server.view(me)
|
|
260
|
+
|
|
261
|
+
def receive(self, update):
|
|
262
|
+
self.view = update.view
|
|
263
|
+
for e in update.events:
|
|
264
|
+
self.notify(e)
|
|
265
|
+
if self.view["phase"] == "over":
|
|
266
|
+
self.close()
|
|
267
|
+
|
|
268
|
+
def draw(self):
|
|
269
|
+
v = self.view
|
|
270
|
+
self.file("rules.md", RULES.format(rounds=v["rounds"], step=STEP))
|
|
271
|
+
self.file("players.md", roster_md(v))
|
|
272
|
+
if v["history"]: # 只往後加 → 適合當紀錄
|
|
273
|
+
self.file("history.md", "# 拍賣紀錄\n\n" + "\n".join(v["history"]))
|
|
274
|
+
if v["owned"]:
|
|
275
|
+
self.file("owned.md", "# 你擁有的寶物\n\n" + "\n\n".join(v["owned"]) + "\n")
|
|
276
|
+
if v["hand"]:
|
|
277
|
+
self.file("hand.md", "# 你的寶物(只有你看得到)\n\n" + "\n\n".join(v["hand"]) + "\n")
|
|
278
|
+
if v["my_lot"]:
|
|
279
|
+
self.file("my_lot.md", "# 你的拍品\n\n" + v["my_lot"] + "\n")
|
|
280
|
+
if v["lot_name"]:
|
|
281
|
+
self.file("lot.md", f"# 拍品:{v['lot_name']}\n\n賣家:{v['seller']}\n\n"
|
|
282
|
+
+ (f"> {v['pitch']}\n\n{v['bidding']}\n" if v["pitch"] else "(賣家正在寫介紹…)\n"))
|
|
283
|
+
|
|
284
|
+
todo = v["todo"]
|
|
285
|
+
if todo == "join":
|
|
286
|
+
self.button("join", "<名字>")
|
|
287
|
+
self.reply("請輸入名字加入遊戲(規則見 rules.md)。")
|
|
288
|
+
elif todo == "pick":
|
|
289
|
+
self.button("pick", "<寶物名稱>,詳見 hand.md", options=v["options"])
|
|
290
|
+
self.reply(f"第 {v['round']}/{v['rounds']} 輪:選一件寶物拿去拍賣,其餘兩件丟棄。")
|
|
291
|
+
elif todo == "describe":
|
|
292
|
+
self.button("describe", "<介紹文字>(買家只看得到名稱和這段介紹)")
|
|
293
|
+
self.reply(f"輪到你當賣家:\n{v['my_lot']}")
|
|
294
|
+
elif todo == "bid":
|
|
295
|
+
if v["min_bid"] <= v["money"]:
|
|
296
|
+
self.button("bid", f"<金額 {v['min_bid']}-{v['money']}> 公開喊價")
|
|
297
|
+
self.button("pass", "退出這件拍品")
|
|
298
|
+
self.reply(f"輪到你喊價。{v['seller']} 正在拍賣「{v['lot_name']}」:\n> {v['pitch']}\n\n"
|
|
299
|
+
f"{v['bidding']}\n\n你有 💰 {v['money']}。")
|
|
300
|
+
|
|
301
|
+
def action(self, name, args):
|
|
302
|
+
text = " ".join(args).strip()
|
|
303
|
+
if name == "pass":
|
|
304
|
+
return self.server.call(self.me, ("bid", None))
|
|
305
|
+
if not text:
|
|
306
|
+
raise ValueError(f"用法:./{name}.sh <內容>")
|
|
307
|
+
if name == "bid":
|
|
308
|
+
if not text.isdigit():
|
|
309
|
+
raise ValueError("出價必須是正整數")
|
|
310
|
+
return self.server.call(self.me, ("bid", int(text)))
|
|
311
|
+
return self.server.call(self.me, (name, text))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class SpectatorUI(FolderUI):
|
|
315
|
+
"""觀眾:看得到所有拍品的真相,沒有按鈕。紀錄寫成只往後加的 log.md。"""
|
|
316
|
+
|
|
317
|
+
def __init__(self, path, server):
|
|
318
|
+
super().__init__(path)
|
|
319
|
+
self.view = server.view("spectator")
|
|
320
|
+
self.log: list[str] = []
|
|
321
|
+
|
|
322
|
+
def receive(self, update):
|
|
323
|
+
self.view = update.view
|
|
324
|
+
self.log += update.events
|
|
325
|
+
if self.view["phase"] == "over":
|
|
326
|
+
self.close()
|
|
327
|
+
|
|
328
|
+
def draw(self):
|
|
329
|
+
v = self.view
|
|
330
|
+
self.file("players.md", roster_md(v))
|
|
331
|
+
if v["lots"]:
|
|
332
|
+
self.file("truth.md", "# 所有拍品的真相\n\n" + "\n\n".join(v["lots"]) + "\n")
|
|
333
|
+
if v["lot_name"]:
|
|
334
|
+
self.file("lot.md", f"# 拍品:{v['lot_name']}\n\n> {v['pitch'] or '(賣家正在寫介紹…)'}\n\n"
|
|
335
|
+
+ (v["bidding"] or "") + "\n")
|
|
336
|
+
if self.log:
|
|
337
|
+
self.file("log.md", "".join(e + "\n\n" for e in self.log))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def main():
|
|
341
|
+
ap = argparse.ArgumentParser()
|
|
342
|
+
ap.add_argument("--players", type=int, default=4)
|
|
343
|
+
ap.add_argument("--rounds", type=int, default=1)
|
|
344
|
+
ap.add_argument("--seed", type=int)
|
|
345
|
+
ap.add_argument("--dir")
|
|
346
|
+
args = ap.parse_args()
|
|
347
|
+
if not 2 <= args.players <= 6:
|
|
348
|
+
raise SystemExit("玩家數需為 2-6")
|
|
349
|
+
root = Path(args.dir or tempfile.mkdtemp(prefix="folderui-auction-"))
|
|
350
|
+
|
|
351
|
+
server = AuctionServer(args.players, args.rounds, args.seed)
|
|
352
|
+
clients = {p: PlayerUI(root / f"player_{p}", server, p) for p in range(args.players)}
|
|
353
|
+
clients["spectator"] = SpectatorUI(root / "spectator", server)
|
|
354
|
+
for who, ui in clients.items():
|
|
355
|
+
server.connect(who, ui)
|
|
356
|
+
|
|
357
|
+
server.start()
|
|
358
|
+
threads = [ui.start() for ui in clients.values()]
|
|
359
|
+
for who, ui in clients.items():
|
|
360
|
+
print(f"{who}: {ui.root}", flush=True)
|
|
361
|
+
for t in threads:
|
|
362
|
+
t.join()
|
|
363
|
+
server.stop()
|
|
364
|
+
print(server.final_result())
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
if __name__ == "__main__":
|
|
368
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""拍賣用的隨機 bot(程式型 agent 範例):python examples/auction_bot.py <資料夾> <名字>"""
|
|
2
|
+
import random
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from folderui import Session
|
|
6
|
+
|
|
7
|
+
PITCHES = ["傳說中的寶物,絕對物超所值!", "家族世代相傳,保存完好。", "老實說,我也不確定它值多少。"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(path, name):
|
|
11
|
+
s, rng = Session(path), random.Random()
|
|
12
|
+
s.ready()
|
|
13
|
+
while buttons := s.buttons():
|
|
14
|
+
if "join" in buttons:
|
|
15
|
+
ok, _ = s.press("join", name)
|
|
16
|
+
if not ok: # 名字被用走了
|
|
17
|
+
name += "'"
|
|
18
|
+
elif "pick" in buttons:
|
|
19
|
+
s.press("pick", rng.choice(buttons["pick"]["options"]))
|
|
20
|
+
elif "describe" in buttons:
|
|
21
|
+
s.press("describe", rng.choice(PITCHES))
|
|
22
|
+
elif "bid" in buttons and rng.random() < 0.6:
|
|
23
|
+
ok, _ = s.press("bid", str(rng.randint(5, 60)))
|
|
24
|
+
if not ok: # 低於最低加價或錢不夠,改成退出
|
|
25
|
+
s.press("pass")
|
|
26
|
+
elif "pass" in buttons:
|
|
27
|
+
s.press("pass")
|
|
28
|
+
print(f"{name}: 結束")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
main(*sys.argv[1:3])
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""單人範例:猜數字。不需要伺服器,一個 FolderUI 就是整個應用。
|
|
2
|
+
|
|
3
|
+
uv run python examples/guess.py [--dir DIR] [--seed N]
|
|
4
|
+
"""
|
|
5
|
+
import argparse
|
|
6
|
+
import random
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
from folderui import FolderUI
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Guess(FolderUI):
|
|
13
|
+
def __init__(self, path, seed=None):
|
|
14
|
+
super().__init__(path)
|
|
15
|
+
self.secret = random.Random(seed).randint(1, 100)
|
|
16
|
+
self.tries: list[tuple[int, str]] = []
|
|
17
|
+
|
|
18
|
+
def draw(self):
|
|
19
|
+
self.file("rules.md", "# 猜數字\n\n我想了一個 1-100 的整數,用 `./guess.sh <數字>` 猜猜看。\n")
|
|
20
|
+
if self.tries: # 只往後加 → 適合當紀錄
|
|
21
|
+
self.file("history.md", "".join(f"- {n}:{r}\n" for n, r in self.tries))
|
|
22
|
+
self.button("guess", "<1-100 的整數>")
|
|
23
|
+
self.reply(f"已經猜了 {len(self.tries)} 次。")
|
|
24
|
+
|
|
25
|
+
def action(self, name, args):
|
|
26
|
+
if len(args) != 1 or not args[0].isdigit() or not 1 <= int(args[0]) <= 100:
|
|
27
|
+
raise ValueError("用法:./guess.sh <1-100 的整數>")
|
|
28
|
+
n = int(args[0])
|
|
29
|
+
result = "🎉 答對了" if n == self.secret else ("太大" if n > self.secret else "太小")
|
|
30
|
+
self.tries.append((n, result))
|
|
31
|
+
if n == self.secret:
|
|
32
|
+
self.close()
|
|
33
|
+
return f"🎉 答對了!答案是 {n},共猜了 {len(self.tries)} 次"
|
|
34
|
+
return f"{n} {result}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
ap = argparse.ArgumentParser()
|
|
39
|
+
ap.add_argument("--dir")
|
|
40
|
+
ap.add_argument("--seed", type=int)
|
|
41
|
+
args = ap.parse_args()
|
|
42
|
+
ui = Guess(args.dir or tempfile.mkdtemp(prefix="folderui-guess-"), args.seed)
|
|
43
|
+
print(ui.root, flush=True)
|
|
44
|
+
ui.run()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "folderui"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Turn a folder into an immediate-mode GUI for humans and AI agents: *.md files are the view, *.sh files are buttons."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "xiao-e-yun" }]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = []
|
|
11
|
+
keywords = ["agent", "llm", "gui", "immediate-mode", "multiplayer", "turn-based"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: POSIX",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Software Development :: Libraries",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/xiao-e-yun/folderui"
|
|
23
|
+
Repository = "https://github.com/xiao-e-yun/folderui"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling>=1.27"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.sdist]
|
|
30
|
+
include = ["src", "examples", "tests", "README.md", "LICENSE"]
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = ["pytest>=8"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""檔案工具:所有寫入都是原子的(先寫暫存檔再 rename),避免讀到半個檔案。"""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def write_text(path: Path, text: str, mode: int | None = None) -> None:
|
|
8
|
+
tmp = path.with_name(f".{path.name}.tmp")
|
|
9
|
+
tmp.write_text(text, encoding="utf-8")
|
|
10
|
+
if mode is not None:
|
|
11
|
+
os.chmod(tmp, mode)
|
|
12
|
+
os.replace(tmp, path)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def write_json(path: Path, data) -> None:
|
|
16
|
+
write_text(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_text(path: Path, default=None):
|
|
20
|
+
try:
|
|
21
|
+
return path.read_text(encoding="utf-8")
|
|
22
|
+
except FileNotFoundError:
|
|
23
|
+
return default
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_json(path: Path, default=None):
|
|
27
|
+
text = read_text(path)
|
|
28
|
+
return default if text is None else json.loads(text)
|
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""多人用的伺服器:一個執行緒依序處理所有客戶端的請求,並推播給客戶端。
|
|
2
|
+
|
|
3
|
+
客戶端 ──call(who, cmd)──▶ Server.handle() 請求/回應(同步)
|
|
4
|
+
客戶端 ◀──push(who, msg)── Server 推播(非同步)
|
|
5
|
+
|
|
6
|
+
handle() raise 的例外會傳回呼叫端(ValueError 代表規則不允許)。
|
|
7
|
+
推播走客戶端的無上限佇列,伺服器永遠不會等待客戶端,所以不會死鎖。
|
|
8
|
+
"""
|
|
9
|
+
import queue
|
|
10
|
+
import threading
|
|
11
|
+
import traceback
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Server:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.clients: dict = {}
|
|
17
|
+
self._requests: queue.Queue = queue.Queue()
|
|
18
|
+
self._stopped = False
|
|
19
|
+
|
|
20
|
+
# ---- 子類別實作 ----
|
|
21
|
+
|
|
22
|
+
def handle(self, who, cmd):
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
def on_start(self) -> None:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
# ---- API ----
|
|
29
|
+
|
|
30
|
+
def connect(self, who, client) -> None:
|
|
31
|
+
"""註冊一個可以接收推播的客戶端(任何有 push(msg) 的物件)。"""
|
|
32
|
+
self.clients[who] = client
|
|
33
|
+
|
|
34
|
+
def call(self, who, cmd, timeout: float = 30):
|
|
35
|
+
box: queue.Queue = queue.Queue(maxsize=1)
|
|
36
|
+
self._requests.put((who, cmd, box))
|
|
37
|
+
try:
|
|
38
|
+
ok, value = box.get(timeout=timeout)
|
|
39
|
+
except queue.Empty:
|
|
40
|
+
raise TimeoutError("伺服器沒有回應") from None
|
|
41
|
+
if ok:
|
|
42
|
+
return value
|
|
43
|
+
raise value
|
|
44
|
+
|
|
45
|
+
def push(self, who, msg) -> None:
|
|
46
|
+
self.clients[who].push(msg)
|
|
47
|
+
|
|
48
|
+
def start(self) -> threading.Thread:
|
|
49
|
+
t = threading.Thread(target=self.run, name="folderui:server", daemon=True)
|
|
50
|
+
t.start()
|
|
51
|
+
return t
|
|
52
|
+
|
|
53
|
+
def stop(self) -> None:
|
|
54
|
+
self._stopped = True
|
|
55
|
+
|
|
56
|
+
def run(self) -> None:
|
|
57
|
+
self.on_start()
|
|
58
|
+
while not self._stopped:
|
|
59
|
+
try:
|
|
60
|
+
who, cmd, box = self._requests.get(timeout=0.2)
|
|
61
|
+
except queue.Empty:
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
box.put((True, self.handle(who, cmd)))
|
|
65
|
+
except Exception as e:
|
|
66
|
+
if not isinstance(e, ValueError):
|
|
67
|
+
traceback.print_exc()
|
|
68
|
+
box.put((False, e))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""程式型 agent 用的客戶端:和按鈕腳本走同一個檔案協定。"""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .fsutil import read_text, write_text
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Session:
|
|
10
|
+
def __init__(self, path: str | Path, poll: float = 0.1):
|
|
11
|
+
self.root = Path(path)
|
|
12
|
+
self.poll = poll
|
|
13
|
+
|
|
14
|
+
def ready(self, timeout: float | None = None) -> bool:
|
|
15
|
+
"""等到資料夾畫好(第一次可以讀到按鈕列表)。"""
|
|
16
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
17
|
+
while not (self.root / ".buttons.json").exists():
|
|
18
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
19
|
+
return False
|
|
20
|
+
time.sleep(self.poll)
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
def buttons(self) -> dict[str, dict]:
|
|
24
|
+
"""目前可按的按鈕 {路徑: {hint, options}};空的代表已結束(或還輪不到你)。"""
|
|
25
|
+
return json.loads(read_text(self.root / ".buttons.json", "{}"))
|
|
26
|
+
|
|
27
|
+
def read(self, path: str) -> str:
|
|
28
|
+
return read_text(self.root / path, "")
|
|
29
|
+
|
|
30
|
+
def press(self, path: str, *args: str, timeout: float | None = None) -> tuple[bool, str]:
|
|
31
|
+
"""按下按鈕並等待回應,回傳 (是否成功, 回應內容)。"""
|
|
32
|
+
request = self.root / ".request"
|
|
33
|
+
if request.exists():
|
|
34
|
+
raise RuntimeError("上一個操作還在等待回應")
|
|
35
|
+
(self.root / ".done").unlink(missing_ok=True)
|
|
36
|
+
write_text(request, "".join(f"{x}\0" for x in (path, *args)))
|
|
37
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
38
|
+
while request.exists():
|
|
39
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
40
|
+
raise TimeoutError("等待回應逾時")
|
|
41
|
+
time.sleep(self.poll)
|
|
42
|
+
code, _, text = read_text(self.root / ".response", "1\n").partition("\n")
|
|
43
|
+
(self.root / ".done").unlink(missing_ok=True)
|
|
44
|
+
return code.strip() == "0", text.rstrip("\n")
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""FolderUI:把一個資料夾變成給人類或 AI agent 用的立即式 GUI。
|
|
2
|
+
|
|
3
|
+
子類別實作三個方法:
|
|
4
|
+
draw() 每一幀重新宣告畫面:file()、button()、reply()。不可修改狀態。
|
|
5
|
+
action(name, args) 按鈕被按下時呼叫:回傳文字,或 raise 代表失敗。只有這裡修改狀態。
|
|
6
|
+
receive(msg) 收到 push() 的訊息(例如伺服器推播)。
|
|
7
|
+
|
|
8
|
+
資料夾裡只有 draw() 宣告的檔案和按鈕腳本(<path>.sh)。執行按鈕腳本後,腳本會一直等待,
|
|
9
|
+
直到「操作處理完之後,第一個有按鈕可按的畫面」出現(或 close())才回應:
|
|
10
|
+
action 的回傳值 + 這段期間的 notify() + reply() + 目前可按的按鈕
|
|
11
|
+
|
|
12
|
+
腳本與主機之間的協定(隱藏檔):
|
|
13
|
+
.request 腳本寫入:按鈕路徑與參數,以 NUL 分隔
|
|
14
|
+
.response 主機寫入:第一行為 exit code,之後為回應內容
|
|
15
|
+
.done 主機回應後把 .request 改名為 .done;腳本讀完回應後刪除它
|
|
16
|
+
.buttons.json 目前的按鈕列表(給程式型 agent 用)
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import queue
|
|
21
|
+
import shlex
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import traceback
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .fsutil import write_text
|
|
29
|
+
|
|
30
|
+
PROTOCOL_FILES = (".request", ".request.new", ".response", ".done")
|
|
31
|
+
|
|
32
|
+
SCRIPT = """\
|
|
33
|
+
#!/bin/sh
|
|
34
|
+
# folderui 按鈕:{name}{hint}
|
|
35
|
+
cd {root} || exit 1
|
|
36
|
+
printf '%s\\0' {qname} "$@" > .request.new
|
|
37
|
+
if [ -e .request ]; then
|
|
38
|
+
if cmp -s .request .request.new; then
|
|
39
|
+
rm -f .request.new
|
|
40
|
+
echo "⏳ 繼續等待…" >&2
|
|
41
|
+
else
|
|
42
|
+
rm -f .request.new
|
|
43
|
+
echo "❌ 上一個操作還在等待回應;再執行一次同樣的指令可以繼續等待"
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
elif [ -e .done ] && cmp -s .done .request.new; then
|
|
47
|
+
rm -f .request.new
|
|
48
|
+
else
|
|
49
|
+
rm -f .done .response
|
|
50
|
+
mv .request.new .request
|
|
51
|
+
echo "⏳ 已送出,等待中…" >&2
|
|
52
|
+
fi
|
|
53
|
+
limit=$(( ${{FOLDERUI_TIMEOUT:-0}} * 5 )); i=0
|
|
54
|
+
while [ -e .request ]; do
|
|
55
|
+
sleep 0.2; i=$((i+1))
|
|
56
|
+
if [ "$limit" -gt 0 ] && [ "$i" -ge "$limit" ]; then
|
|
57
|
+
echo "⏳ 還在等待中,再執行一次同樣的指令即可繼續等待"
|
|
58
|
+
exit 2
|
|
59
|
+
fi
|
|
60
|
+
done
|
|
61
|
+
code=$(head -n 1 .response)
|
|
62
|
+
tail -n +2 .response
|
|
63
|
+
rm -f .done
|
|
64
|
+
exit "$code"
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_path(path: str) -> str:
|
|
69
|
+
parts = path.split("/")
|
|
70
|
+
if path.startswith("/") or any(not p or p.startswith(".") for p in parts):
|
|
71
|
+
raise ValueError(f"路徑不能是絕對路徑、空段落或隱藏檔:{path!r}")
|
|
72
|
+
return path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FolderUI:
|
|
76
|
+
poll = 0.1
|
|
77
|
+
close_grace = 5.0
|
|
78
|
+
|
|
79
|
+
def __init__(self, path: str | Path):
|
|
80
|
+
self.root = Path(path).resolve()
|
|
81
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
self.closed = False
|
|
83
|
+
self._inbox: queue.Queue = queue.Queue()
|
|
84
|
+
self._files: dict[str, str] = {}
|
|
85
|
+
self._buttons: dict[str, tuple[str, list[str] | None]] = {}
|
|
86
|
+
self._reply = ""
|
|
87
|
+
self._disk_files: dict[str, str] = {} # 磁碟上由本框架管理的檔案
|
|
88
|
+
self._disk_scripts: dict[str, str] = {}
|
|
89
|
+
self._notes: list[str] = []
|
|
90
|
+
self._pending: tuple[int, str] | None = None
|
|
91
|
+
self._pending_button: tuple[str, str] | None = None # (路徑, hint):回應被讀走前保留它的腳本
|
|
92
|
+
|
|
93
|
+
# ---- 子類別實作 ----
|
|
94
|
+
|
|
95
|
+
def draw(self) -> None:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def action(self, name: str, args: list[str]) -> str | None:
|
|
99
|
+
raise ValueError(f"沒有處理按鈕 {name}")
|
|
100
|
+
|
|
101
|
+
def receive(self, msg) -> None:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# ---- 在 draw() 中呼叫 ----
|
|
105
|
+
|
|
106
|
+
def file(self, path: str, content: str) -> None:
|
|
107
|
+
self._files[_check_path(path)] = content
|
|
108
|
+
|
|
109
|
+
def button(self, path: str, hint: str = "", options: list[str] | None = None) -> None:
|
|
110
|
+
self._buttons[_check_path(path)] = (hint, options)
|
|
111
|
+
|
|
112
|
+
def reply(self, text: str) -> None:
|
|
113
|
+
self._reply = text
|
|
114
|
+
|
|
115
|
+
# ---- 在 action() / receive() 中呼叫 ----
|
|
116
|
+
|
|
117
|
+
def notify(self, text: str) -> None:
|
|
118
|
+
"""累積一則消息,在下一次回應時一起送出。"""
|
|
119
|
+
self._notes.append(text)
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
"""結束:移除所有按鈕,回應正在等待的腳本,然後停止。"""
|
|
123
|
+
self.closed = True
|
|
124
|
+
|
|
125
|
+
# ---- 給其他執行緒 ----
|
|
126
|
+
|
|
127
|
+
def push(self, msg) -> None:
|
|
128
|
+
self._inbox.put(msg)
|
|
129
|
+
|
|
130
|
+
def start(self) -> threading.Thread:
|
|
131
|
+
t = threading.Thread(target=self.run, name=f"folderui:{self.root.name}", daemon=True)
|
|
132
|
+
t.start()
|
|
133
|
+
return t
|
|
134
|
+
|
|
135
|
+
# ---- 主迴圈 ----
|
|
136
|
+
|
|
137
|
+
def run(self) -> None:
|
|
138
|
+
for name in PROTOCOL_FILES:
|
|
139
|
+
(self.root / name).unlink(missing_ok=True)
|
|
140
|
+
self._redraw()
|
|
141
|
+
deadline = None
|
|
142
|
+
while not (self.closed and self._pending is None and self._pending_button is None):
|
|
143
|
+
if self.closed and self._pending is None: # 已結束:最後的回應最多等 close_grace 秒被讀走
|
|
144
|
+
deadline = deadline or time.monotonic() + self.close_grace
|
|
145
|
+
if time.monotonic() > deadline:
|
|
146
|
+
break
|
|
147
|
+
changed = False
|
|
148
|
+
try:
|
|
149
|
+
msg = self._inbox.get(timeout=self.poll)
|
|
150
|
+
self._call(self.receive, msg)
|
|
151
|
+
changed = True
|
|
152
|
+
except queue.Empty:
|
|
153
|
+
pass
|
|
154
|
+
changed |= self._take_request()
|
|
155
|
+
changed |= self._drain()
|
|
156
|
+
if self._pending is None and self._pending_button and not (self.root / ".done").exists():
|
|
157
|
+
self._pending_button = None # 回應已被讀走
|
|
158
|
+
changed = True
|
|
159
|
+
if changed:
|
|
160
|
+
self._redraw()
|
|
161
|
+
if self._pending is not None and (self._buttons or self.closed):
|
|
162
|
+
self._respond()
|
|
163
|
+
|
|
164
|
+
def _drain(self) -> bool:
|
|
165
|
+
changed = False
|
|
166
|
+
while True:
|
|
167
|
+
try:
|
|
168
|
+
msg = self._inbox.get_nowait()
|
|
169
|
+
except queue.Empty:
|
|
170
|
+
return changed
|
|
171
|
+
self._call(self.receive, msg)
|
|
172
|
+
changed = True
|
|
173
|
+
|
|
174
|
+
def _take_request(self) -> bool:
|
|
175
|
+
if self._pending is not None:
|
|
176
|
+
return False
|
|
177
|
+
try:
|
|
178
|
+
raw = (self.root / ".request").read_bytes()
|
|
179
|
+
except FileNotFoundError:
|
|
180
|
+
return False
|
|
181
|
+
name, *args = [x.decode("utf-8", "replace") for x in raw.split(b"\0")[:-1]] or [""]
|
|
182
|
+
if name not in self._buttons:
|
|
183
|
+
self._pending = (1, f"❌ 按鈕 {name} 已經不存在")
|
|
184
|
+
return True
|
|
185
|
+
self._pending_button = (name, self._buttons[name][0])
|
|
186
|
+
try:
|
|
187
|
+
result = self.action(name, args)
|
|
188
|
+
self._pending = (0, result or "")
|
|
189
|
+
except Exception as e:
|
|
190
|
+
if not isinstance(e, ValueError): # ValueError 視為使用者錯誤,其餘是程式 bug
|
|
191
|
+
traceback.print_exc()
|
|
192
|
+
self._pending = (1, f"❌ {e}")
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
def _respond(self) -> None:
|
|
196
|
+
code, text = self._pending
|
|
197
|
+
parts = [text] if text else []
|
|
198
|
+
parts += self._notes
|
|
199
|
+
if self._reply:
|
|
200
|
+
parts.append(self._reply)
|
|
201
|
+
if self.closed:
|
|
202
|
+
parts.append("── 已結束 ──")
|
|
203
|
+
else:
|
|
204
|
+
lines = ["── 可以做的事 ──"]
|
|
205
|
+
for path, (hint, options) in self._buttons.items():
|
|
206
|
+
lines.append(f"./{path}.sh" + (f" {hint}" if hint else ""))
|
|
207
|
+
if options:
|
|
208
|
+
lines.append(" 選項:" + " / ".join(options))
|
|
209
|
+
parts.append("\n".join(lines))
|
|
210
|
+
write_text(self.root / ".response", f"{code}\n" + "\n\n".join(parts) + "\n")
|
|
211
|
+
os.replace(self.root / ".request", self.root / ".done")
|
|
212
|
+
self._notes = []
|
|
213
|
+
self._pending = None
|
|
214
|
+
|
|
215
|
+
def _redraw(self) -> None:
|
|
216
|
+
self._files, self._buttons, self._reply = {}, {}, ""
|
|
217
|
+
self._call(self.draw)
|
|
218
|
+
if self.closed:
|
|
219
|
+
self._buttons = {}
|
|
220
|
+
scripts = {f"{p}.sh": self._script(p, hint) for p, (hint, _) in self._buttons.items()}
|
|
221
|
+
if self._pending_button: # 逾時後重新執行同一個指令=繼續等待或拿回應,所以腳本要留到回應被讀走
|
|
222
|
+
name, hint = self._pending_button
|
|
223
|
+
scripts.setdefault(f"{name}.sh", self._script(name, hint))
|
|
224
|
+
self._sync(self._disk_files, self._files, None)
|
|
225
|
+
self._sync(self._disk_scripts, scripts, 0o755)
|
|
226
|
+
write_text(self.root / ".buttons.json", json.dumps(
|
|
227
|
+
{p: {"hint": h, "options": o} for p, (h, o) in self._buttons.items()},
|
|
228
|
+
ensure_ascii=False, indent=2) + "\n")
|
|
229
|
+
|
|
230
|
+
def _sync(self, disk: dict[str, str], want: dict[str, str], mode: int | None) -> None:
|
|
231
|
+
for path in set(disk) - set(want):
|
|
232
|
+
(self.root / path).unlink(missing_ok=True)
|
|
233
|
+
self._prune(self.root / path)
|
|
234
|
+
del disk[path]
|
|
235
|
+
for path, content in want.items():
|
|
236
|
+
if disk.get(path) != content:
|
|
237
|
+
target = self.root / path
|
|
238
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
write_text(target, content, mode)
|
|
240
|
+
disk[path] = content
|
|
241
|
+
|
|
242
|
+
def _prune(self, path: Path) -> None:
|
|
243
|
+
for d in path.parents:
|
|
244
|
+
if d == self.root:
|
|
245
|
+
return
|
|
246
|
+
try:
|
|
247
|
+
d.rmdir()
|
|
248
|
+
except OSError:
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
def _script(self, name: str, hint: str) -> str:
|
|
252
|
+
hint = " ".join(hint.split())
|
|
253
|
+
return SCRIPT.format(name=name, hint=f" — {hint}" if hint else "",
|
|
254
|
+
root=shlex.quote(str(self.root)), qname=shlex.quote(name))
|
|
255
|
+
|
|
256
|
+
def _call(self, fn, *args) -> None:
|
|
257
|
+
try:
|
|
258
|
+
fn(*args)
|
|
259
|
+
except Exception:
|
|
260
|
+
print(f"[folderui:{self.root.name}] {fn.__name__}() 發生錯誤:", file=sys.stderr)
|
|
261
|
+
traceback.print_exc()
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from folderui import FolderUI, Server, Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Counter(FolderUI):
|
|
12
|
+
"""單人:add 會加一,到 3 就結束。"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, path):
|
|
15
|
+
super().__init__(path)
|
|
16
|
+
self.n = 0
|
|
17
|
+
|
|
18
|
+
def draw(self):
|
|
19
|
+
self.file("count.md", f"{self.n}\n")
|
|
20
|
+
self.button("add", "<1-3>")
|
|
21
|
+
self.reply(f"目前 {self.n}")
|
|
22
|
+
|
|
23
|
+
def action(self, name, args):
|
|
24
|
+
if args != ["1"]:
|
|
25
|
+
raise ValueError("只能加 1")
|
|
26
|
+
self.n += 1
|
|
27
|
+
if self.n == 3:
|
|
28
|
+
self.close()
|
|
29
|
+
return f"加到 {self.n}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def counter(tmp_path):
|
|
34
|
+
ui = Counter(tmp_path / "ui")
|
|
35
|
+
thread = ui.start()
|
|
36
|
+
s = Session(ui.root)
|
|
37
|
+
assert s.ready(timeout=5)
|
|
38
|
+
yield ui, s
|
|
39
|
+
ui.close()
|
|
40
|
+
thread.join(timeout=5)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_session_press(counter):
|
|
44
|
+
ui, s = counter
|
|
45
|
+
assert set(s.buttons()) == {"add"}
|
|
46
|
+
ok, text = s.press("add", "2", timeout=5)
|
|
47
|
+
assert not ok and "只能加 1" in text
|
|
48
|
+
ok, text = s.press("add", "1", timeout=5)
|
|
49
|
+
assert ok and text.startswith("加到 1") and "./add.sh" in text
|
|
50
|
+
assert s.read("count.md") == "1\n"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_close_removes_buttons(counter):
|
|
54
|
+
ui, s = counter
|
|
55
|
+
for _ in range(3):
|
|
56
|
+
ok, text = s.press("add", "1", timeout=5)
|
|
57
|
+
assert ok and "已結束" in text
|
|
58
|
+
assert s.buttons() == {}
|
|
59
|
+
for _ in range(50): # 回應被讀走後,下一幀才會移除保留的腳本
|
|
60
|
+
if not (ui.root / "add.sh").exists():
|
|
61
|
+
break
|
|
62
|
+
time.sleep(0.1)
|
|
63
|
+
assert not (ui.root / "add.sh").exists()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_shell_button(counter):
|
|
67
|
+
ui, s = counter
|
|
68
|
+
r = subprocess.run([str(ui.root / "add.sh"), "1"], capture_output=True, text=True, timeout=10)
|
|
69
|
+
assert r.returncode == 0 and "加到 1" in r.stdout
|
|
70
|
+
r = subprocess.run([str(ui.root / "add.sh"), "x"], capture_output=True, text=True, timeout=10)
|
|
71
|
+
assert r.returncode == 1 and "只能加 1" in r.stdout
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ReadyServer(Server):
|
|
75
|
+
"""兩個人都按 ready 才一起開始。"""
|
|
76
|
+
|
|
77
|
+
def __init__(self):
|
|
78
|
+
super().__init__()
|
|
79
|
+
self.ready: set = set()
|
|
80
|
+
|
|
81
|
+
def handle(self, who, cmd):
|
|
82
|
+
self.ready.add(who)
|
|
83
|
+
if len(self.ready) == len(self.clients):
|
|
84
|
+
for p in self.clients:
|
|
85
|
+
self.push(p, "go")
|
|
86
|
+
return "✅ 準備好了"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Lobby(FolderUI):
|
|
90
|
+
def __init__(self, path, server, me):
|
|
91
|
+
super().__init__(path)
|
|
92
|
+
self.server, self.me, self.state = server, me, "lobby"
|
|
93
|
+
|
|
94
|
+
def draw(self):
|
|
95
|
+
if self.state == "lobby":
|
|
96
|
+
self.button("ready")
|
|
97
|
+
elif self.state == "waiting":
|
|
98
|
+
pass # 沒有按鈕 → 腳本繼續等
|
|
99
|
+
else:
|
|
100
|
+
self.button("play")
|
|
101
|
+
|
|
102
|
+
def action(self, name, args):
|
|
103
|
+
if name == "ready":
|
|
104
|
+
self.state = "waiting"
|
|
105
|
+
return self.server.call(self.me, "ready")
|
|
106
|
+
self.close()
|
|
107
|
+
return "played"
|
|
108
|
+
|
|
109
|
+
def receive(self, msg):
|
|
110
|
+
self.state = "started"
|
|
111
|
+
self.notify("🔔 開始了")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_multiplayer_blocks_until_everyone_ready(tmp_path):
|
|
115
|
+
server = ReadyServer()
|
|
116
|
+
uis = {p: Lobby(tmp_path / f"p{p}", server, p) for p in range(2)}
|
|
117
|
+
for p, ui in uis.items():
|
|
118
|
+
server.connect(p, ui)
|
|
119
|
+
server.start()
|
|
120
|
+
threads = [ui.start() for ui in uis.values()]
|
|
121
|
+
s0, s1 = Session(uis[0].root), Session(uis[1].root)
|
|
122
|
+
assert s0.ready(timeout=5) and s1.ready(timeout=5)
|
|
123
|
+
|
|
124
|
+
result = {}
|
|
125
|
+
t = threading.Thread(target=lambda: result.update(r=s0.press("ready", timeout=10)))
|
|
126
|
+
t.start()
|
|
127
|
+
t.join(timeout=0.5)
|
|
128
|
+
assert t.is_alive() # 另一個人還沒準備好,p0 的按鈕卡住
|
|
129
|
+
|
|
130
|
+
ok, text = s1.press("ready", timeout=10)
|
|
131
|
+
t.join(timeout=10)
|
|
132
|
+
assert ok and "開始了" in text and "./play.sh" in text
|
|
133
|
+
ok, text = result["r"]
|
|
134
|
+
assert ok and "開始了" in text
|
|
135
|
+
|
|
136
|
+
for s in (s0, s1):
|
|
137
|
+
s.press("play", timeout=10)
|
|
138
|
+
for th in threads:
|
|
139
|
+
th.join(timeout=5)
|
|
140
|
+
server.stop()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_timeout_and_resume(tmp_path):
|
|
144
|
+
server = ReadyServer()
|
|
145
|
+
uis = {p: Lobby(tmp_path / f"p{p}", server, p) for p in range(2)}
|
|
146
|
+
for p, ui in uis.items():
|
|
147
|
+
server.connect(p, ui)
|
|
148
|
+
server.start()
|
|
149
|
+
for ui in uis.values():
|
|
150
|
+
ui.start()
|
|
151
|
+
assert Session(uis[0].root).ready(timeout=5)
|
|
152
|
+
script = str(uis[0].root / "ready.sh")
|
|
153
|
+
env = {**os.environ, "FOLDERUI_TIMEOUT": "1"}
|
|
154
|
+
|
|
155
|
+
r = subprocess.run([script], capture_output=True, text=True, timeout=10, env=env)
|
|
156
|
+
assert r.returncode == 2 # 逾時,但請求已送出
|
|
157
|
+
|
|
158
|
+
r = subprocess.run([script, "other"], capture_output=True, text=True, timeout=10, env=env)
|
|
159
|
+
assert r.returncode == 1 and "還在等待" in r.stdout # 不同的指令會被拒絕
|
|
160
|
+
|
|
161
|
+
Session(uis[1].root).press("ready", timeout=10)
|
|
162
|
+
r = subprocess.run([script], capture_output=True, text=True, timeout=10, env=env)
|
|
163
|
+
assert r.returncode == 0 and "開始了" in r.stdout # 同樣的指令 → 拿到回應
|
|
164
|
+
for ui in uis.values():
|
|
165
|
+
ui.close()
|
|
166
|
+
server.stop()
|