cortico-world-buckshot 0.2.0 → 0.2.1
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.
- package/CHANGELOG.md +7 -0
- package/README.md +144 -25
- package/assets/icon.png +0 -0
- package/assets/icon.svg +58 -0
- package/describe.json +8 -0
- package/dist/webui.js +15 -4
- package/package.json +25 -2
- package/scripts/check-local-peer.ts +183 -0
- package/src/bridge.ts +7 -0
- package/src/config.ts +5 -0
- package/src/definition.ts +5 -0
- package/src/implant.ts +26 -4
- package/src/native/bridge.gd +198 -0
- package/src/peer.ts +135 -0
- package/src/webui/client.ts +11 -4
- package/src/webui/types.ts +3 -0
- package/src/world.ts +55 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
- 中英双语 README 与市场简介(`cortico.locales`),写清安装前准备(游戏版本、植入资源包、存档、多人需要 Steam);原 README 的实现与验证说明挪到 `docs/internals.md`;新增图标;补 `cortico.tags`、`author`、`bugs`。
|
|
6
|
+
Bilingual README and market descriptions (`cortico.locales`) with a before-you-install section (game build, implant pack, save data, Steam for multiplayer); the old README's implementation and test notes moved to `docs/internals.md`; new icon; `cortico.tags`, `author` and `bugs` added.
|
|
7
|
+
- WebUI 恶魔轮盘页加「本地加入」:游戏在多人大厅建好房间后,在本机再开一个游戏窗口进这个房间,操作员和 bot 两人对打。新配置 `localPeer`(名字、显示器、静音),新事件 `buckshot.peer`。联网玩家可以同时在房间里;本地玩家对局中途离开时所有人回大厅。
|
|
8
|
+
The WebUI page adds Local join: once the game hosts a multiplayer lobby, a second game window on this machine joins it so the operator can play the bot. New `localPeer` settings (name, screen, mute) and a `buckshot.peer` event. Steam players can share the lobby; if the local player leaves mid-match, everyone returns to the lobby.
|
|
9
|
+
|
|
3
10
|
## 0.2.0
|
|
4
11
|
|
|
5
12
|
- 适配 Cortico 0.2.0(扩展契约 world 6):浏览器界面改名 WebUI,面板源码移到 `src/webui/`,产物为 `dist/webui.js`;需要 Cortico 0.2.0 或更新版本。
|
package/README.md
CHANGED
|
@@ -1,44 +1,163 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 恶魔轮盘 · Buckshot Roulette
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[中文](#中文) · [English](#english)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
让 Coo 玩 Steam 版《恶魔轮盘》(Buckshot Roulette),游戏里的事推给 Coo,Coo 自己拿枪、开枪、用道具。
|
|
6
|
+
Lets Coo play Buckshot Roulette on Steam: game events reach Coo, and Coo picks up the shotgun, fires and uses items itself.
|
|
6
7
|
|
|
7
|
-
##
|
|
8
|
+
## 中文
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
装填、开枪、道具、生命计数和字幕实时推给 Coo,关键时刻附一张游戏截图。Coo 用工具操作桌上的目标和菜单,单人剧情、Double or Nothing 和多人房间都能玩;操作员也可以在本机再开一个游戏窗口,和 Coo 同桌对打。适合直播里让 Coo 自己玩、自己解说。
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
此乃直播测试验证之后的 World,是 CortiV 直播里用过的参考实现,和其他 Persona、非直播场景的兼容性有待测试。
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
### 功能
|
|
14
15
|
|
|
15
|
-
`
|
|
16
|
+
- 启动和关闭游戏(`buckshot_start`、`buckshot_close`)。启动时校验游戏版本,生成植入资源包,原游戏文件不改。
|
|
17
|
+
- 读当前状态:场景、可操作的目标、生命、道具、本次装填公开的实弹与空包数、已退出和已揭示的弹药(`buckshot_observe`)。
|
|
18
|
+
- 操作桌上的目标和菜单:拿枪、选择朝谁开枪、拿道具、用道具、签名、开箱领奖、加倍或收款、改设置(`buckshot_act`)。操作后等动画结算完才回执,回执里带这期间的枪声、道具和字幕,以及结算后的画面。
|
|
19
|
+
- 发键盘、鼠标、手柄输入和游戏里定义的输入动作,填文字框或把大厅编号放进剪贴板。
|
|
20
|
+
- 随时截一张游戏画面(`buckshot_glance`)。
|
|
21
|
+
- 事件:装填、开枪、退弹、弹药揭示、逆转、道具使用、生命变化、除颤器复苏、字幕、新一轮、多人回合与方向、结算、胜利奖金,以及开枪、揭示、复苏、字幕和结算后的截图。
|
|
22
|
+
- 只给 Coo 画面上公开的信息。未揭示的弹序、庄家的决策、其他玩家私下看到的弹药都不提供。
|
|
23
|
+
- 本地加入:游戏在多人大厅建好房间后,在本机再开一个游戏窗口进这个房间,操作员用鼠标和 Coo 对打。
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
### 安装前准备
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
1. **游戏**:在 Windows 上用 Steam 购买并安装《Buckshot Roulette》。只支持 Steam Windows 版 **v2.2.0.6**:World 启动游戏时核对 `Buckshot Roulette.exe` 的大小与 SHA-256(`df2f8a31…1b1663`),对不上就拒绝启动。游戏程序在 Steam 库里右键游戏,「管理」→「浏览本地文件」打开的目录里。
|
|
28
|
+
2. **植入件**:不用编译,也不用手动放文件。植入件源码是仓里的 `src/native/bridge.gd`(桥脚本)和 `src/implant.ts`(往游戏脚本里加观察点的位置)。World 第一次启动游戏时读出 exe 里的游戏资源,加上桥脚本,在本机生成启动资源包,默认放在部署目录的 `runtimes/buckshot/`,再用 `--main-pack` 让游戏加载它。游戏文件不被写入,游戏资源也不随扩展分发。
|
|
29
|
+
3. **存档**:World 启动的游戏用单独的存档,在 `%APPDATA%/Cortico/Buckshot/<profile>` 下,和你平时玩的存档分开。剧情通关、解锁 Double or Nothing 都记在这份存档里。
|
|
30
|
+
4. **多人**:多人房间走 Steam 大厅,需要 Steam 客户端登录在线。
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
确认装好:启用 World 后在 World 页签点「启动游戏」,状态变成「已连接」就可以了。没连上时看部署目录 `data/buckshot/game.log`。
|
|
22
33
|
|
|
23
|
-
|
|
24
|
-
- `buckshot_observe` 返回环境、镜头目标、画面文字、生命显示、道具、公开弹药信息和操作目标。
|
|
25
|
-
- `buckshot_act` 使用目标 id 与同次观察的 revision 操作。版本或权限变化时拒绝输入。操作类工具(act、input、key、pointer、controller)不结束本轮,等游戏回到可操作状态或对局结果变化后返回(`src/settle.ts`),这期间的观察写进回执、不另发事件,回执附结算后的画面;超过 `settleTimeoutMs`(默认 30 秒)先返回,之后的变化作为事件送达。
|
|
26
|
-
- `buckshot_input` 触发游戏输入动作;`buckshot_key`、`buckshot_pointer` 和 `buckshot_controller` 发送键盘、鼠标和手柄输入。
|
|
27
|
-
- `buckshot_type` 填写有焦点的文字框,或写入剪贴板供游戏粘贴大厅编号。
|
|
28
|
-
- `buckshot_glance` 返回游戏渲染器的 PNG,供 bot 主动查看。
|
|
34
|
+
### 安装
|
|
29
35
|
|
|
30
|
-
`buckshot
|
|
36
|
+
在 Cortico 或 Coopanion 的扩展页搜 `cortico-world-buckshot` 安装,重启后在 World 总览里启用。命令行:
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
```bash
|
|
39
|
+
cd extensions
|
|
40
|
+
corepack pnpm add cortico-world-buckshot
|
|
41
|
+
```
|
|
33
42
|
|
|
34
|
-
`
|
|
43
|
+
启用后在恶魔轮盘页的配置里把「游戏程序」选成 `Buckshot Roulette.exe`。没配也能启用,启动游戏时才检查。
|
|
35
44
|
|
|
36
|
-
|
|
45
|
+
### 使用
|
|
37
46
|
|
|
38
|
-
|
|
47
|
+
- **启动**:World 页签的「游戏」卡片点「启动游戏」,或者让 Coo 调用 `buckshot_start`。卡片上显示状态、场景、模式、轮次和本地玩家状态。
|
|
48
|
+
- **Coo 怎么玩**:游戏连上后,Coo 收到可操作目标的变化,自己选目标操作。装填、开枪、字幕这些事件和截图到了,Coo 会接着行动或说话,说什么由 Coo 决定。
|
|
49
|
+
- **看画面**:「查看画面」在卡片里显示当前游戏画面。
|
|
50
|
+
- **本地加入**:先让 Coo 在多人模式里建好房间、停在大厅,再点「本地加入」。本机多开一个游戏窗口,以「本地玩家名字」进房间,你用鼠标操作。房主照常开局。点「退出本地」关掉这个窗口。
|
|
51
|
+
- **关闭**:「关闭游戏」只关本 World 启动的游戏。默认 World 停止时也会关掉游戏。
|
|
39
52
|
|
|
40
|
-
|
|
53
|
+
### 配置
|
|
41
54
|
|
|
42
|
-
|
|
55
|
+
| 键 | 默认 | 说明 |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `worlds.buckshot.executableFile` | 空 | 游戏程序 `Buckshot Roulette.exe` 的路径 |
|
|
58
|
+
| `worlds.buckshot.runtimeDir` | 空 | 植入资源包目录;留空用部署目录的 `runtimes/buckshot` |
|
|
59
|
+
| `worlds.buckshot.closeOnStop` | `true` | World 停止时关闭游戏 |
|
|
60
|
+
| `worlds.buckshot.settleTimeoutMs` | `30000` | 操作后等游戏回到可操作状态的最长时间;超过后先回执,之后的变化作为事件送达 |
|
|
61
|
+
| `worlds.buckshot.screenshotEvents.enabled` | `true` | 关键时刻自动推截图 |
|
|
62
|
+
| `worlds.buckshot.screenshotEvents.minIntervalMs` | `1500` | 自动截图最短间隔 |
|
|
63
|
+
| `worlds.buckshot.display.screenIndex` | 空 | 游戏开在第几个显示器,从 0 开始;留空由游戏选 |
|
|
64
|
+
| `worlds.buckshot.display.muteAudio` | `false` | 静音启动 |
|
|
65
|
+
| `worlds.buckshot.localPeer.name` | `Player 2` | 本地加入时的玩家名字 |
|
|
66
|
+
| `worlds.buckshot.localPeer.muteAudio` | `true` | 本地客户端静音,免得直播里声音放两遍 |
|
|
43
67
|
|
|
44
|
-
|
|
68
|
+
显示器和静音在下次启动游戏时生效。其余见 `src/config.ts`。
|
|
69
|
+
|
|
70
|
+
### 限制与常见问题
|
|
71
|
+
|
|
72
|
+
- 只支持 Steam Windows 版 v2.2.0.6。Steam 更新游戏后 exe 的哈希会变,World 会拒绝启动,要等扩展适配新版本。
|
|
73
|
+
- 本地玩家占一个座位,能进房间的联网玩家少一个(4 人房最多再进 2 名)。联网玩家的大厅名单里看不到本地玩家,对局里本地玩家的名字可能显示为空。本地玩家对局中途离开时,房主让所有人回到大厅。
|
|
74
|
+
- 真实 Steam 跨账号联机还没验收过。Steam 邀请窗口不在游戏画面里,Coo 操作不到。
|
|
75
|
+
- 事件和截图会频繁触发 Coo 的回合,token 用得快。可以关掉自动截图或调大间隔。
|
|
76
|
+
|
|
77
|
+
实现、协议和验证记录见 [docs/internals.md](https://github.com/Phantivia/cortico-world-buckshot/blob/main/docs/internals.md)。
|
|
78
|
+
|
|
79
|
+
## English
|
|
80
|
+
|
|
81
|
+
Shell loads, shots, items, health counters and dialogue reach Coo as they happen, with a screenshot at key moments. Coo works the table targets and menus through tools and can play the story, Double or Nothing and multiplayer lobbies. The operator can also open a second game window on the same PC and sit at the table against Coo. It is meant for streams where Coo plays and commentates on its own.
|
|
82
|
+
|
|
83
|
+
This World was verified on live streams. It is the reference implementation used on CortiV streams; compatibility with other Personas and non-streaming setups has not been tested.
|
|
84
|
+
|
|
85
|
+
### Features
|
|
86
|
+
|
|
87
|
+
- Starts and closes the game (`buckshot_start`, `buckshot_close`). Starting checks the game version and builds an implant pack; the game files are never modified.
|
|
88
|
+
- Reads the current state: scene, operable targets, health, items, the live and blank counts of the current load, and the shells ejected or revealed so far (`buckshot_observe`).
|
|
89
|
+
- Operates table targets and menus: pick up the shotgun, choose who to shoot, take and use items, sign the waiver, open the briefcase, double or cash out, change settings (`buckshot_act`). The reply waits until the animation settles and includes the shots, items and dialogue from that time plus a screenshot.
|
|
90
|
+
- Sends keyboard, mouse and gamepad input and the game's own input actions, types into text fields or puts a lobby id on the clipboard.
|
|
91
|
+
- Takes a screenshot on demand (`buckshot_glance`).
|
|
92
|
+
- Events: loads, shots, ejections, reveals, inversions, item use, health changes, defibrillator revivals, dialogue, new rounds, multiplayer turns and direction, results and prize money, plus screenshots after shots, reveals, revivals, dialogue and results.
|
|
93
|
+
- Coo only gets what the screen shows. The unrevealed shell order, the dealer's decisions and other players' private reveals are left out.
|
|
94
|
+
- Local join: once the game hosts a multiplayer lobby, a second game window on this PC joins it so the operator can play Coo with the mouse.
|
|
95
|
+
|
|
96
|
+
### Before you install
|
|
97
|
+
|
|
98
|
+
1. **The game**: buy and install Buckshot Roulette through Steam on Windows. Only the Steam Windows build **v2.2.0.6** is supported. When starting the game the World checks the size and SHA-256 (`df2f8a31…1b1663`) of `Buckshot Roulette.exe` and refuses any other build. To find the exe, right-click the game in your Steam library and choose Manage → Browse local files.
|
|
99
|
+
2. **The implant**: nothing to compile or copy by hand. Its sources are `src/native/bridge.gd` (the bridge script) and `src/implant.ts` (where it hooks into the game's scripts). The first time the World starts the game it reads the game's resources out of the exe, adds the bridge script and writes a startup pack on this machine, by default in `runtimes/buckshot/` under the deployment, then launches the game with `--main-pack` pointing at it. The game files are not written to, and no game assets ship with the extension.
|
|
100
|
+
3. **Save data**: the game started by the World keeps its own save under `%APPDATA%/Cortico/Buckshot/<profile>`, apart from your normal save. Story completion and the Double or Nothing unlock are stored there.
|
|
101
|
+
4. **Multiplayer**: lobbies go through Steam, so the Steam client must be signed in and online.
|
|
102
|
+
|
|
103
|
+
To check: enable the World, click 启动游戏 (Start game) in the World tab, and wait for the status to read 已连接 (connected). If it does not connect, look at `data/buckshot/game.log` in the deployment.
|
|
104
|
+
|
|
105
|
+
### Install
|
|
106
|
+
|
|
107
|
+
Search for `cortico-world-buckshot` on the Extensions page in Cortico or Coopanion, install it, restart, and enable it in the World overview. From the command line:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
cd extensions
|
|
111
|
+
corepack pnpm add cortico-world-buckshot
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Then set 游戏程序 (game executable) in the Buckshot Roulette page's settings to `Buckshot Roulette.exe`. The World can be enabled without it; the path is checked when the game starts.
|
|
115
|
+
|
|
116
|
+
### Usage
|
|
117
|
+
|
|
118
|
+
- **Start**: click 启动游戏 on the 游戏 card in the World tab, or let Coo call `buckshot_start`. The card shows the status, scene, mode, round and the local player's state.
|
|
119
|
+
- **How Coo plays**: once the game is connected, Coo is told when the operable targets change and picks targets on its own. When loads, shots, dialogue or screenshots arrive, Coo acts or talks next; what it says is up to Coo.
|
|
120
|
+
- **View**: 查看画面 shows the current game view on the card.
|
|
121
|
+
- **Local join**: have Coo host a multiplayer lobby and stay on the lobby screen, then click 本地加入. A second game window opens on this PC and joins under the local player name; you play it with the mouse. The host starts the match as usual. 退出本地 closes that window.
|
|
122
|
+
- **Close**: 关闭游戏 closes only the game this World started. By default the game also closes when the World stops.
|
|
123
|
+
|
|
124
|
+
### Configuration
|
|
125
|
+
|
|
126
|
+
| Key | Default | Description |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `worlds.buckshot.executableFile` | empty | Path to `Buckshot Roulette.exe` |
|
|
129
|
+
| `worlds.buckshot.runtimeDir` | empty | Where the implant pack goes; empty uses `runtimes/buckshot` in the deployment |
|
|
130
|
+
| `worlds.buckshot.closeOnStop` | `true` | Close the game when the World stops |
|
|
131
|
+
| `worlds.buckshot.settleTimeoutMs` | `30000` | How long an action waits for the game to be operable again; after that the reply returns and later changes arrive as events |
|
|
132
|
+
| `worlds.buckshot.screenshotEvents.enabled` | `true` | Send screenshots at key moments |
|
|
133
|
+
| `worlds.buckshot.screenshotEvents.minIntervalMs` | `1500` | Minimum gap between automatic screenshots |
|
|
134
|
+
| `worlds.buckshot.display.screenIndex` | empty | Monitor for the game, from 0; empty lets the game choose |
|
|
135
|
+
| `worlds.buckshot.display.muteAudio` | `false` | Start the game muted |
|
|
136
|
+
| `worlds.buckshot.localPeer.name` | `Player 2` | Local player name for local join |
|
|
137
|
+
| `worlds.buckshot.localPeer.muteAudio` | `true` | Mute the local client so the stream does not hear the audio twice |
|
|
138
|
+
|
|
139
|
+
Monitor and mute settings apply the next time the game starts. The rest are in `src/config.ts`.
|
|
140
|
+
|
|
141
|
+
### Limitations and FAQ
|
|
142
|
+
|
|
143
|
+
- Only the Steam Windows build v2.2.0.6 works. A Steam update changes the exe hash, and the World will refuse to start until the extension supports the new build.
|
|
144
|
+
- The local player takes a seat, so one fewer online player fits (a 4-player lobby takes 2 more). Online players do not see the local player in the lobby list, and its name may show as blank in the match. If the local player leaves mid-match, the host sends everyone back to the lobby.
|
|
145
|
+
- Real cross-account Steam multiplayer has not been verified. The Steam invite window is outside the game view, so Coo cannot use it.
|
|
146
|
+
- Events and screenshots start Coo's turns often, so token use adds up. Turn off automatic screenshots or raise the interval to cut it.
|
|
147
|
+
|
|
148
|
+
Implementation, protocol and test notes: [docs/internals.md](https://github.com/Phantivia/cortico-world-buckshot/blob/main/docs/internals.md).
|
|
149
|
+
|
|
150
|
+
## 开发 · Development
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
corepack pnpm install
|
|
154
|
+
corepack pnpm typecheck
|
|
155
|
+
corepack pnpm test
|
|
156
|
+
corepack pnpm build:web # dist/webui.js; also runs on prepack
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`corepack pnpm check:game <exe>` checks a game install (`--launch` also starts it); `corepack pnpm check:native <exe>` runs the bridge in the game's headless engine. Details in [docs/internals.md](https://github.com/Phantivia/cortico-world-buckshot/blob/main/docs/internals.md).
|
|
160
|
+
|
|
161
|
+
## 许可 · License
|
|
162
|
+
|
|
163
|
+
MIT
|
package/assets/icon.png
ADDED
|
Binary file
|
package/assets/icon.svg
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#4a1c24"/>
|
|
5
|
+
<stop offset="1" stop-color="#1e0b10"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="live" x1="0" y1="0" x2="1" y2="0">
|
|
8
|
+
<stop offset="0" stop-color="#ff6b78"/>
|
|
9
|
+
<stop offset="1" stop-color="#d9324a"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
<linearGradient id="blank" x1="0" y1="0" x2="1" y2="0">
|
|
12
|
+
<stop offset="0" stop-color="#8fc6ff"/>
|
|
13
|
+
<stop offset="1" stop-color="#4f86d6"/>
|
|
14
|
+
</linearGradient>
|
|
15
|
+
<linearGradient id="brass" x1="0" y1="0" x2="1" y2="0">
|
|
16
|
+
<stop offset="0" stop-color="#ffe08a"/>
|
|
17
|
+
<stop offset="1" stop-color="#d9a634"/>
|
|
18
|
+
</linearGradient>
|
|
19
|
+
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
|
|
20
|
+
<feGaussianBlur stdDeviation="16"/>
|
|
21
|
+
</filter>
|
|
22
|
+
<g id="shell">
|
|
23
|
+
<!-- crimped top and paper hull -->
|
|
24
|
+
<path d="M -40 26 Q -40 0 -14 0 L 14 0 Q 40 0 40 26 L 40 156 L -40 156 Z"/>
|
|
25
|
+
<!-- crimp folds -->
|
|
26
|
+
<path d="M -20 4 L -12 22 M 0 2 L 0 22 M 20 4 L 12 22" stroke="#ffffff" stroke-opacity="0.45" stroke-width="5" stroke-linecap="round" fill="none"/>
|
|
27
|
+
<!-- highlight -->
|
|
28
|
+
<rect x="-27" y="34" width="11" height="112" rx="5.5" fill="#ffffff" fill-opacity="0.45"/>
|
|
29
|
+
<!-- brass head and rim -->
|
|
30
|
+
<rect x="-42" y="150" width="84" height="50" rx="6" fill="url(#brass)"/>
|
|
31
|
+
<rect x="-50" y="194" width="100" height="18" rx="6" fill="url(#brass)"/>
|
|
32
|
+
<rect x="-30" y="160" width="9" height="30" rx="4.5" fill="#ffffff" fill-opacity="0.55"/>
|
|
33
|
+
</g>
|
|
34
|
+
</defs>
|
|
35
|
+
|
|
36
|
+
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
|
37
|
+
|
|
38
|
+
<!-- warm light over the table -->
|
|
39
|
+
<ellipse cx="256" cy="200" rx="150" ry="110" fill="#ff5a6a" fill-opacity="0.18" filter="url(#glow)"/>
|
|
40
|
+
|
|
41
|
+
<!-- a live shell and a blank shell -->
|
|
42
|
+
<g transform="translate(192 186) rotate(-10) translate(0 -106)">
|
|
43
|
+
<use href="#shell" fill="url(#live)"/>
|
|
44
|
+
</g>
|
|
45
|
+
<g transform="translate(320 186) rotate(10) translate(0 -106)">
|
|
46
|
+
<use href="#shell" fill="url(#blank)"/>
|
|
47
|
+
</g>
|
|
48
|
+
|
|
49
|
+
<!-- Coo's eyes, watching the shells -->
|
|
50
|
+
<g>
|
|
51
|
+
<circle cx="198" cy="378" r="48" fill="#22e3ee"/>
|
|
52
|
+
<circle cx="198" cy="378" r="29" fill="#111322"/>
|
|
53
|
+
<circle cx="209" cy="367" r="9" fill="#ffffff"/>
|
|
54
|
+
<circle cx="314" cy="378" r="48" fill="#22e3ee"/>
|
|
55
|
+
<circle cx="314" cy="378" r="29" fill="#111322"/>
|
|
56
|
+
<circle cx="325" cy="367" r="9" fill="#ffffff"/>
|
|
57
|
+
</g>
|
|
58
|
+
</svg>
|
package/describe.json
CHANGED
|
@@ -451,6 +451,14 @@
|
|
|
451
451
|
},
|
|
452
452
|
"trigger": "debounce"
|
|
453
453
|
},
|
|
454
|
+
{
|
|
455
|
+
"type": "buckshot.peer",
|
|
456
|
+
"description": {
|
|
457
|
+
"zh": "WebUI「本地加入」的本地玩家进入或离开了多人房间。",
|
|
458
|
+
"en": "The local player started from the WebUI joined or left the multiplayer lobby."
|
|
459
|
+
},
|
|
460
|
+
"trigger": "debounce"
|
|
461
|
+
},
|
|
454
462
|
{
|
|
455
463
|
"type": "buckshot.clipboard",
|
|
456
464
|
"description": {
|
package/dist/webui.js
CHANGED
|
@@ -9,10 +9,12 @@ var game = {
|
|
|
9
9
|
const start = ui.button("\u542F\u52A8\u6E38\u620F", { variant: "primary" });
|
|
10
10
|
const stop = ui.button("\u5173\u95ED\u6E38\u620F", { variant: "danger" });
|
|
11
11
|
const capture = ui.button("\u67E5\u770B\u753B\u9762");
|
|
12
|
+
const peerJoin = ui.button("\u672C\u5730\u52A0\u5165");
|
|
13
|
+
const peerLeave = ui.button("\u9000\u51FA\u672C\u5730");
|
|
12
14
|
const preview = ui.h("img");
|
|
13
15
|
preview.alt = "\u6E38\u620F\u5F53\u524D\u753B\u9762";
|
|
14
16
|
preview.style.cssText = "display:none;width:100%;height:auto;margin-top:12px;";
|
|
15
|
-
actions.append(start, stop, capture);
|
|
17
|
+
actions.append(start, stop, capture, peerJoin, peerLeave);
|
|
16
18
|
card.body.append(status, message, actions, preview);
|
|
17
19
|
ctx.root.append(card.el);
|
|
18
20
|
let busy = false;
|
|
@@ -25,19 +27,22 @@ var game = {
|
|
|
25
27
|
{ k: "\u72B6\u6001", v: ui.pill({ offline: "\u672A\u542F\u52A8", loading: "\u542F\u52A8\u4E2D", online: "\u5DF2\u8FDE\u63A5", error: "\u542F\u52A8\u5931\u8D25" }[state.state], state.state === "online" ? "on" : "off") },
|
|
26
28
|
{ k: "\u573A\u666F", v: state.scene ?? "\u2014" },
|
|
27
29
|
{ k: "\u6A21\u5F0F", v: state.mode ?? "\u2014" },
|
|
28
|
-
{ k: "\u8F6E\u6B21", v: state.round === void 0 ? "\u2014" : String(state.round) }
|
|
30
|
+
{ k: "\u8F6E\u6B21", v: state.round === void 0 ? "\u2014" : String(state.round) },
|
|
31
|
+
{ k: "\u672C\u5730\u73A9\u5BB6", v: ui.pill({ offline: "\u672A\u52A0\u5165", starting: "\u542F\u52A8\u4E2D", menu: "\u672A\u8FDB\u623F\u95F4", joined: "\u5728\u623F\u95F4\u91CC" }[state.peer], state.peer === "joined" ? "on" : "off") }
|
|
29
32
|
]));
|
|
30
|
-
message.textContent = state.error;
|
|
33
|
+
message.textContent = state.error || state.peerError;
|
|
31
34
|
start.disabled = busy || state.state === "online" || state.state === "loading";
|
|
32
35
|
stop.disabled = busy || !state.stoppable;
|
|
33
36
|
capture.disabled = busy || state.state !== "online";
|
|
37
|
+
peerJoin.disabled = busy || state.state !== "online" || state.peer !== "offline";
|
|
38
|
+
peerLeave.disabled = busy || state.peer === "offline";
|
|
34
39
|
}
|
|
35
40
|
async function refresh() {
|
|
36
41
|
if (!ctx.signal.aborted) render(await ctx.invoke("state"));
|
|
37
42
|
}
|
|
38
43
|
async function act(method) {
|
|
39
44
|
busy = true;
|
|
40
|
-
start.disabled = stop.disabled = capture.disabled = true;
|
|
45
|
+
start.disabled = stop.disabled = capture.disabled = peerJoin.disabled = peerLeave.disabled = true;
|
|
41
46
|
try {
|
|
42
47
|
render(await ctx.invoke(method));
|
|
43
48
|
await ctx.refresh();
|
|
@@ -54,6 +59,12 @@ var game = {
|
|
|
54
59
|
stop.addEventListener("click", () => {
|
|
55
60
|
void act("stop");
|
|
56
61
|
}, { signal: ctx.signal });
|
|
62
|
+
peerJoin.addEventListener("click", () => {
|
|
63
|
+
void act("peerJoin");
|
|
64
|
+
}, { signal: ctx.signal });
|
|
65
|
+
peerLeave.addEventListener("click", () => {
|
|
66
|
+
void act("peerLeave");
|
|
67
|
+
}, { signal: ctx.signal });
|
|
57
68
|
capture.addEventListener("click", () => {
|
|
58
69
|
void ctx.invokeBinary("capture").then((blob) => {
|
|
59
70
|
if (ctx.signal.aborted) return;
|
package/package.json
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortico-world-buckshot",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Buckshot Roulette game events and
|
|
5
|
+
"description": "Lets a Cortico bot play Buckshot Roulette (Steam, Windows) through live game events and in-game actions; verified on live streams, compatibility with other Personas and non-streaming setups is untested.",
|
|
6
|
+
"author": "Phantivia",
|
|
6
7
|
"license": "MIT",
|
|
7
8
|
"repository": {
|
|
8
9
|
"type": "git",
|
|
9
10
|
"url": "git+https://github.com/Phantivia/cortico-world-buckshot.git"
|
|
10
11
|
},
|
|
11
12
|
"homepage": "https://github.com/Phantivia/cortico-world-buckshot#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Phantivia/cortico-world-buckshot/issues"
|
|
15
|
+
},
|
|
12
16
|
"keywords": [
|
|
13
17
|
"cortico-world",
|
|
14
18
|
"cortico",
|
|
@@ -26,6 +30,24 @@
|
|
|
26
30
|
"kind": "world",
|
|
27
31
|
"api": 6,
|
|
28
32
|
"displayName": "恶魔轮盘",
|
|
33
|
+
"icon": "assets/icon.png",
|
|
34
|
+
"tags": [
|
|
35
|
+
"streaming",
|
|
36
|
+
"game",
|
|
37
|
+
"external-setup",
|
|
38
|
+
"experimental",
|
|
39
|
+
"high-cost"
|
|
40
|
+
],
|
|
41
|
+
"locales": {
|
|
42
|
+
"zh": {
|
|
43
|
+
"displayName": "恶魔轮盘",
|
|
44
|
+
"description": "让 Coo 玩 Steam 版《恶魔轮盘》:装填、开枪、道具、生命和字幕实时推给 Coo,Coo 自己拿枪、用道具、点菜单。\n\n**功能**\n- 单人剧情、Double or Nothing 与多人房间都能玩\n- 装填数、揭示的弹药、生命和字幕作为事件推给 Coo,关键时刻附游戏截图\n- Coo 用工具操作桌上目标、菜单和设置,也能发键盘、鼠标、手柄输入\n- 本地加入:操作员在本机再开一个游戏窗口,和 Coo 同桌对打\n- WebUI 页签可启动、关闭游戏,查看当前画面\n\n**安装后还需要**\n1. 在 Windows 上通过 Steam 安装《Buckshot Roulette》,版本须为 v2.2.0.6\n2. 在 World 页的配置里把「游戏程序」指向 `Buckshot Roulette.exe`\n3. 植入资源包由 World 首次启动游戏时自动生成,无需编译\n4. 详细步骤见 [README](https://github.com/Phantivia/cortico-world-buckshot#安装前准备)\n\n仅 Windows。CortiV 直播验证过的参考实现,和其他 Persona、非直播场景的兼容性有待测试。"
|
|
45
|
+
},
|
|
46
|
+
"en": {
|
|
47
|
+
"displayName": "Buckshot Roulette",
|
|
48
|
+
"description": "Lets Coo play Buckshot Roulette (Steam): loads, shots, items, health and dialogue reach Coo as they happen, and Coo picks up the shotgun, uses items and works the menus itself.\n\n**Features**\n- Plays the story, Double or Nothing and multiplayer lobbies\n- Shell counts, revealed shells, health and dialogue arrive as events, with a screenshot at key moments\n- Coo operates table targets, menus and settings through tools, and can send keyboard, mouse and gamepad input\n- Local join: the operator opens a second game window on the same PC and plays Coo at the same table\n- The WebUI tab starts and closes the game and shows the current view\n\n**After installing you also need**\n1. Buckshot Roulette from Steam on Windows, version v2.2.0.6\n2. Set 游戏程序 (game executable) in the World page settings to `Buckshot Roulette.exe`\n3. The implant pack is generated automatically the first time the World starts the game; nothing to compile\n4. Full steps in the [README](https://github.com/Phantivia/cortico-world-buckshot#before-you-install)\n\nWindows only. A reference implementation verified on CortiV live streams; compatibility with other Personas and non-streaming use has not been tested."
|
|
49
|
+
}
|
|
50
|
+
},
|
|
29
51
|
"webUiClient": "dist/webui.js",
|
|
30
52
|
"describe": "describe.json"
|
|
31
53
|
},
|
|
@@ -33,6 +55,7 @@
|
|
|
33
55
|
"src",
|
|
34
56
|
"scripts",
|
|
35
57
|
"dist",
|
|
58
|
+
"assets",
|
|
36
59
|
"describe.json",
|
|
37
60
|
"CHANGELOG.md",
|
|
38
61
|
"README.md",
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Live check of local peer with the real game. The host process hosts a lobby, a local peer joins it, the match starts
|
|
2
|
+
// and both sides pick up items and fire; each side prints what it saw.
|
|
3
|
+
// --with-remote adds a mock Steam player: another local process the host counts as a Steam member and that sees the
|
|
4
|
+
// member list Steam would show it. It checks three players together, the local peer leaving mid-match (everyone goes
|
|
5
|
+
// back to the lobby) and the Steam player leaving mid-match (the others play on).
|
|
6
|
+
// Usage: pnpm tsx scripts/check-local-peer.ts <Buckshot Roulette.exe> [--with-remote] [--screen=<index>]
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { once } from 'node:events';
|
|
9
|
+
import { createWriteStream } from 'node:fs';
|
|
10
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { BuckshotBridge } from '../src/bridge.ts';
|
|
13
|
+
import { prepareImplant } from '../src/implant.ts';
|
|
14
|
+
import { LOCAL_PEER_ID, LocalPeer } from '../src/peer.ts';
|
|
15
|
+
import type { BuckshotSnapshot } from '../src/protocol.ts';
|
|
16
|
+
import { renderSnapshot } from '../src/render.ts';
|
|
17
|
+
|
|
18
|
+
const REMOTE_ID = '76561190000000002';
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const executableFile = args.find((value) => !value.startsWith('--'));
|
|
21
|
+
if (!executableFile) throw new Error('Usage: pnpm tsx scripts/check-local-peer.ts <Buckshot Roulette.exe> [--with-remote]');
|
|
22
|
+
const withRemote = args.includes('--with-remote');
|
|
23
|
+
const screen = args.find((value) => value.startsWith('--screen='))?.slice('--screen='.length);
|
|
24
|
+
const runtimeDir = resolve('scratch/local-peer-check');
|
|
25
|
+
await mkdir(runtimeDir, { recursive: true });
|
|
26
|
+
const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms));
|
|
27
|
+
|
|
28
|
+
async function snapshot(bridge: BuckshotBridge): Promise<BuckshotSnapshot> {
|
|
29
|
+
const reply = await bridge.request('snapshot', {}, 10000);
|
|
30
|
+
if (!reply.snapshot) throw new Error(reply.detail);
|
|
31
|
+
return reply.snapshot;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function waitFor(bridge: BuckshotBridge, what: string, test: (s: BuckshotSnapshot) => boolean, ms = 60000): Promise<BuckshotSnapshot> {
|
|
35
|
+
const deadline = Date.now() + ms;
|
|
36
|
+
while (Date.now() < deadline) {
|
|
37
|
+
const current = await snapshot(bridge);
|
|
38
|
+
if (test(current)) return current;
|
|
39
|
+
await sleep(500);
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Timed out waiting for ${what}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function press(bridge: BuckshotBridge, label: RegExp, ms = 60000): Promise<void> {
|
|
45
|
+
const current = await waitFor(bridge, String(label), (s) => s.actions.some((a) => label.test(a.label)), ms);
|
|
46
|
+
const target = current.actions.find((a) => label.test(a.label))!;
|
|
47
|
+
const reply = await bridge.request('act', { target: target.id, revision: current.revision }, 10000);
|
|
48
|
+
console.log(`press ${target.label}: ${reply.detail}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function check(condition: boolean, message: string): void {
|
|
52
|
+
if (!condition) throw new Error(`CHECK FAILED: ${message}`);
|
|
53
|
+
console.log(`ok: ${message}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const players = (s: BuckshotSnapshot) => (s.players ?? []).map((p) => `${p.seat}:${p.name || '(空)'}:${p.health ?? '?'}`).sort().join(' ');
|
|
57
|
+
const seatsAndHealth = (s: BuckshotSnapshot) => (s.players ?? []).map((p) => `${p.seat}:${p.health ?? '?'}`).sort().join(' ');
|
|
58
|
+
const inMatch = (count: number) => (s: BuckshotSnapshot) => s.mode === 'multiplayer' && (s.players?.length ?? 0) === count;
|
|
59
|
+
|
|
60
|
+
/** Whoever can act picks up items or the shotgun and fires at another seat, until `shots` shots or the time runs out. */
|
|
61
|
+
async function play(sides: Array<[string, BuckshotBridge]>, shots: number, ms: number): Promise<number> {
|
|
62
|
+
const deadline = Date.now() + ms;
|
|
63
|
+
let fired = 0;
|
|
64
|
+
while (Date.now() < deadline && fired < shots) {
|
|
65
|
+
for (const [name, bridge] of sides) {
|
|
66
|
+
const current = await snapshot(bridge).catch(() => null);
|
|
67
|
+
if (!current) continue;
|
|
68
|
+
const target = current.actions.find((a) => /^shoot seat/.test(a.label) && a.side !== String(current.localSeat))
|
|
69
|
+
?? current.actions.find((a) => /shotgun/.test(a.label))
|
|
70
|
+
?? current.actions.find((a) => a.kind === 'multiplayer interaction' && !/^shoot seat|^jam/.test(a.label));
|
|
71
|
+
if (!target) continue;
|
|
72
|
+
const reply = await bridge.request('act', { target: target.id, revision: current.revision }, 10000);
|
|
73
|
+
console.log(`[${name}] act ${target.label}: ${reply.detail}`);
|
|
74
|
+
if (/^shoot seat/.test(target.label)) fired++;
|
|
75
|
+
await sleep(1500);
|
|
76
|
+
}
|
|
77
|
+
await sleep(1000);
|
|
78
|
+
}
|
|
79
|
+
return fired;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const hostPack = await prepareImplant(executableFile, runtimeDir);
|
|
83
|
+
const peerPack = await prepareImplant(executableFile, runtimeDir, 'local-peer');
|
|
84
|
+
const remotePack = withRemote ? await prepareImplant(executableFile, runtimeDir, 'mock-remote') : '';
|
|
85
|
+
const host = new BuckshotBridge();
|
|
86
|
+
const port = await host.start();
|
|
87
|
+
const log = createWriteStream(join(runtimeDir, 'host.log'));
|
|
88
|
+
const game = spawn(executableFile, ['--windowed', '--audio-driver', 'Dummy', ...(screen ? ['--screen', screen] : []), '--main-pack', hostPack, '--', `--cortico-executable=${executableFile}`, `--cortico-port=${port}`, `--cortico-token=${host.token}`], { cwd: dirname(executableFile), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
89
|
+
game.stdout?.pipe(log); game.stderr?.pipe(log);
|
|
90
|
+
host.on('observation', (event: { kind: string; facts: unknown }) => { if (['load', 'shot', 'health', 'result'].includes(event.kind)) console.log(`[host] ${event.kind} ${JSON.stringify(event.facts)}`); });
|
|
91
|
+
|
|
92
|
+
const peers: LocalPeer[] = [];
|
|
93
|
+
async function enter(id: string, name: string, pack: string, mock: boolean): Promise<LocalPeer> {
|
|
94
|
+
let joined = false;
|
|
95
|
+
const peer = new LocalPeer(host, { id, mockSteamPlayer: mock, executableFile: executableFile!, packFile: pack, logFile: join(runtimeDir, `${mock ? 'remote' : 'peer'}.log`), name, screenIndex: null, muteAudio: true, startupTimeoutMs: 60000, actionTimeoutMs: 10000,
|
|
96
|
+
onChange: (change) => { if (change === 'joined') joined = true; console.log(`${name} ${change}`); } });
|
|
97
|
+
peers.push(peer);
|
|
98
|
+
const lobby = await host.request('peer_lobby', { peer: id }, 10000);
|
|
99
|
+
if (!lobby.ok) throw new Error(lobby.detail);
|
|
100
|
+
await peer.start((lobby as { lobby?: string }).lobby ?? '');
|
|
101
|
+
for (let i = 0; i < 60 && !joined; i++) await sleep(1000);
|
|
102
|
+
if (!joined) throw new Error(`${name} did not join`);
|
|
103
|
+
return peer;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function memberLimit(): Promise<unknown> {
|
|
107
|
+
return (await host.request('peer_lobby', { peer: LOCAL_PEER_ID }, 10000) as { memberLimit?: unknown }).memberLimit;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await once(host, 'connected');
|
|
112
|
+
await press(host, /^skip intro$/, 15000).catch(() => undefined);
|
|
113
|
+
await press(host, /^start multiplayer/);
|
|
114
|
+
await waitFor(host, 'lobby scene', (s) => s.scene === 'mp_lobby');
|
|
115
|
+
await press(host, /^create lobby/);
|
|
116
|
+
for (let i = 0; !(await host.request('peer_lobby', { peer: LOCAL_PEER_ID }, 10000)).ok; i++) {
|
|
117
|
+
if (i === 60) throw new Error('the host did not open a lobby');
|
|
118
|
+
await sleep(1000);
|
|
119
|
+
}
|
|
120
|
+
console.log(`member limit before: ${await memberLimit()}`);
|
|
121
|
+
|
|
122
|
+
if (!withRemote) {
|
|
123
|
+
const peer = await enter(LOCAL_PEER_ID, 'Player 2', peerPack, false);
|
|
124
|
+
await sleep(4000);
|
|
125
|
+
await press(host, /^start game/);
|
|
126
|
+
const views: Array<[string, BuckshotBridge]> = [['host', host], ['peer', peer.bridge]];
|
|
127
|
+
for (const [name, bridge] of views) console.log(`${name}: ${players(await waitFor(bridge, `${name} match`, inMatch(2), 90000))}`);
|
|
128
|
+
await play(views, 6, 240000);
|
|
129
|
+
for (const [name, bridge] of views) {
|
|
130
|
+
const frame = await bridge.request('capture', {}, 10000);
|
|
131
|
+
if (frame.png) await writeFile(join(runtimeDir, `${name}.png`), Buffer.from(frame.png, 'base64'));
|
|
132
|
+
console.log(`--- ${name}\n${renderSnapshot(await snapshot(bridge))}`);
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
// 1. a Steam player and the local peer in one lobby
|
|
136
|
+
const remote = await enter(REMOTE_ID, 'Remote', remotePack, true);
|
|
137
|
+
let peer = await enter(LOCAL_PEER_ID, 'Player 2', peerPack, false);
|
|
138
|
+
check(await memberLimit() === 3, 'the local peer takes one of four Steam seats');
|
|
139
|
+
await sleep(4000);
|
|
140
|
+
const lobbyText = async (bridge: BuckshotBridge) => (await snapshot(bridge)).texts.join(' | ');
|
|
141
|
+
const [hostLobby, remoteLobby, peerLobby] = [await lobbyText(host), await lobbyText(remote.bridge), await lobbyText(peer.bridge)];
|
|
142
|
+
console.log(`host lobby: ${hostLobby}\nremote lobby: ${remoteLobby}\npeer lobby: ${peerLobby}`);
|
|
143
|
+
check(hostLobby.includes('Player 2') && hostLobby.includes('Remote'), 'the host lists both');
|
|
144
|
+
check(!remoteLobby.includes('Player 2'), 'the Steam player does not list the local peer, as with Steam');
|
|
145
|
+
|
|
146
|
+
// 2. three players in a match; the Steam player seats the local peer from the host's setup
|
|
147
|
+
await press(host, /^start game/);
|
|
148
|
+
let views: Array<[string, BuckshotBridge]> = [['host', host], ['remote', remote.bridge], ['peer', peer.bridge]];
|
|
149
|
+
for (const [name, bridge] of views) console.log(`${name}: ${players(await waitFor(bridge, `${name} match`, inMatch(3), 90000))}`);
|
|
150
|
+
await play(views, 4, 200000);
|
|
151
|
+
await sleep(3000);
|
|
152
|
+
const seen = await Promise.all(views.map(async ([, bridge]) => seatsAndHealth(await snapshot(bridge))));
|
|
153
|
+
console.log(`seats and health: ${seen.join(' / ')}`);
|
|
154
|
+
check(seen.every((value) => value === seen[0]), 'seats and health agree on all three');
|
|
155
|
+
|
|
156
|
+
// 3. the local peer leaves mid-match: everyone goes back to the lobby
|
|
157
|
+
await peer.stop();
|
|
158
|
+
for (const [name, bridge] of [['host', host], ['remote', remote.bridge]] as const) await waitFor(bridge, `${name} back in lobby`, (s) => s.scene === 'mp_lobby', 60000);
|
|
159
|
+
check(true, 'host and Steam player are back in the lobby');
|
|
160
|
+
for (let i = 0; !(await host.request('peer_lobby', { peer: LOCAL_PEER_ID }, 10000)).ok; i++) {
|
|
161
|
+
if (i === 60) throw new Error('the host lobby did not reopen');
|
|
162
|
+
await sleep(1000);
|
|
163
|
+
}
|
|
164
|
+
check(await memberLimit() === 4, 'the seat is back for Steam');
|
|
165
|
+
|
|
166
|
+
// 4. the local peer comes back; this time the Steam player leaves mid-match and the other two play on
|
|
167
|
+
peer = await enter(LOCAL_PEER_ID, 'Player 2', peerPack, false);
|
|
168
|
+
await sleep(4000);
|
|
169
|
+
await press(host, /^start game/);
|
|
170
|
+
views = [['host', host], ['remote', remote.bridge], ['peer', peer.bridge]];
|
|
171
|
+
for (const [name, bridge] of views) await waitFor(bridge, `${name} match`, inMatch(3), 90000);
|
|
172
|
+
await play(views, 1, 120000);
|
|
173
|
+
await remote.stop();
|
|
174
|
+
views = [['host', host], ['peer', peer.bridge]];
|
|
175
|
+
for (const [name, bridge] of views) console.log(`${name} after the Steam player left: ${players(await waitFor(bridge, `${name} two players`, inMatch(2), 60000))}`);
|
|
176
|
+
check(await play(views, 2, 150000) === 2, 'host and local peer play on');
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
for (const peer of peers) await peer.stop();
|
|
180
|
+
await host.request('quit', {}, 3000).catch(() => undefined);
|
|
181
|
+
if (game.exitCode === null) { const closed = once(game, 'close'); if (game.kill()) await closed; }
|
|
182
|
+
await host.stop();
|
|
183
|
+
}
|
package/src/bridge.ts
CHANGED
|
@@ -91,6 +91,8 @@ export class BuckshotBridge extends EventEmitter {
|
|
|
91
91
|
request.resolve(reply);
|
|
92
92
|
break;
|
|
93
93
|
}
|
|
94
|
+
// local peer traffic, relayed by LocalPeer
|
|
95
|
+
case 'net': case 'roster': case 'peer': this.emit(message.kind, message); break;
|
|
94
96
|
default: throw new Error('Unknown game bridge message');
|
|
95
97
|
}
|
|
96
98
|
}
|
|
@@ -114,6 +116,11 @@ export class BuckshotBridge extends EventEmitter {
|
|
|
114
116
|
});
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
/** A request the game does not answer. */
|
|
120
|
+
send(action: string, args: Record<string, unknown>): void {
|
|
121
|
+
if (this.socket && !this.socket.destroyed) this.socket.write(JSON.stringify({ action, ...args }) + '\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
117
124
|
private rejectPending(error: Error): void {
|
|
118
125
|
for (const request of this.pending.values()) { clearTimeout(request.timer); request.reject(error); }
|
|
119
126
|
this.pending.clear();
|
package/src/config.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface BuckshotConfigSection {
|
|
|
10
10
|
settleTimeoutMs: number;
|
|
11
11
|
screenshotEvents: { enabled: boolean; minIntervalMs: number };
|
|
12
12
|
display: { screenIndex: number | null; muteAudio: boolean };
|
|
13
|
+
localPeer: { name: string; screenIndex: number | null; muteAudio: boolean };
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export const BUCKSHOT_DEFAULTS: BuckshotConfigSection = {
|
|
@@ -17,6 +18,7 @@ export const BUCKSHOT_DEFAULTS: BuckshotConfigSection = {
|
|
|
17
18
|
startupTimeoutMs: 30000, actionTimeoutMs: 10000, settleTimeoutMs: 30000,
|
|
18
19
|
screenshotEvents: { enabled: true, minIntervalMs: 1500 },
|
|
19
20
|
display: { screenIndex: null, muteAudio: false },
|
|
21
|
+
localPeer: { name: 'Player 2', screenIndex: null, muteAudio: true },
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
export const BUCKSHOT_CONFIG_GROUP: ConfigGroup = {
|
|
@@ -32,6 +34,9 @@ export const BUCKSHOT_CONFIG_GROUP: ConfigGroup = {
|
|
|
32
34
|
'worlds.buckshot.screenshotEvents.minIntervalMs': { type: 'integer', title: '截图最短间隔', minimum: 500, 'x-suffix': 'ms', 'x-hot': true },
|
|
33
35
|
'worlds.buckshot.display.screenIndex': { type: 'integer', title: '显示器编号', minimum: 0, nullable: true, description: '从 0 开始;留空由游戏选择。', 'x-hot': false },
|
|
34
36
|
'worlds.buckshot.display.muteAudio': { type: 'boolean', title: '静音启动', 'x-hot': false },
|
|
37
|
+
'worlds.buckshot.localPeer.name': { type: 'string', title: '本地玩家名字', description: '本地加入时在房间里显示的名字。', 'x-hot': true },
|
|
38
|
+
'worlds.buckshot.localPeer.screenIndex': { type: 'integer', title: '本地客户端显示器', minimum: 0, nullable: true, description: '从 0 开始;留空由游戏选择。', 'x-hot': true },
|
|
39
|
+
'worlds.buckshot.localPeer.muteAudio': { type: 'boolean', title: '本地客户端静音', description: '两个游戏同放声音时,直播里会听到两遍。', 'x-hot': true },
|
|
35
40
|
},
|
|
36
41
|
},
|
|
37
42
|
};
|
package/src/definition.ts
CHANGED
|
@@ -144,6 +144,11 @@ export const BUCKSHOT: WorldDefinition<BuckshotConfigSection> = {
|
|
|
144
144
|
description: { zh: '对局结算画面:单人胜利的奖金,或多人对局的结果。', en: 'The results screen: the prize money for a single-player win, or the multiplayer result.' },
|
|
145
145
|
trigger: 'debounce',
|
|
146
146
|
},
|
|
147
|
+
{
|
|
148
|
+
type: 'buckshot.peer',
|
|
149
|
+
description: { zh: 'WebUI「本地加入」的本地玩家进入或离开了多人房间。', en: 'The local player started from the WebUI joined or left the multiplayer lobby.' },
|
|
150
|
+
trigger: 'debounce',
|
|
151
|
+
},
|
|
147
152
|
{
|
|
148
153
|
type: 'buckshot.clipboard',
|
|
149
154
|
description: { zh: '游戏把多人大厅编号复制到了剪贴板。', en: 'The game copied the multiplayer lobby id to the clipboard.' },
|
package/src/implant.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { readPack, readResource, writePatchedPack } from './pck.ts';
|
|
|
8
8
|
const BRIDGE_FILE = fileURLToPath(new URL('./native/bridge.gd', import.meta.url));
|
|
9
9
|
const BRIDGE_NODE = 'get_node("/root/CorticoBridge")';
|
|
10
10
|
|
|
11
|
-
export interface ScriptHook { path: string; anchor: string; insertion: string; before?: boolean; occurrences?: number; }
|
|
11
|
+
export interface ScriptHook { path: string; anchor: string; insertion: string; before?: boolean; replace?: boolean; occurrences?: number; }
|
|
12
12
|
|
|
13
13
|
export const BUCKSHOT_SCRIPT_HOOKS: readonly ScriptHook[] = [
|
|
14
14
|
{ path: 'scripts/ShellSpawner.gd', anchor: '\tanim_compartment.play("show shells")\n', insertion: `\t${BRIDGE_NODE}.ObserveLoad(temp_live, temp_blank)\n` },
|
|
@@ -43,13 +43,34 @@ export const BUCKSHOT_SCRIPT_HOOKS: readonly ScriptHook[] = [
|
|
|
43
43
|
{ path: 'multiplayer/scripts/global scripts/MP_OutroManager.gd', anchor: '\tdialogue_results.ShowText_Forever(tr("MP_UI MATCH RESULTS"))\n', insertion: `\t${BRIDGE_NODE}.ObserveOutcome("completed")\n` },
|
|
44
44
|
{ path: 'multiplayer/scripts/user scripts/MP_ShotgunInteraction.gd', anchor: '\tvar current_shell = intermediary.game_state.MAIN_shooter_shell\n', insertion: `\t${BRIDGE_NODE}.ObserveShot(str(active_shooter_socket_self), str(active_shooter_socket_target), current_shell)\n` },
|
|
45
45
|
...['firstperson', 'thirdperson'].map((person): ScriptHook => ({ path: 'multiplayer/scripts/user scripts/MP_ItemInteraction.gd', anchor: `\tanimator_items_${person}.play(animation_name)\n`, insertion: `\t${BRIDGE_NODE}.ObserveItem(str(properties.socket_number), active_id, properties.intermediary.game_state.MAIN_item_resource_array)\n` })),
|
|
46
|
+
// Local peer: a second game process sits in the host's lobby as a member Steam does not know about. Its packets,
|
|
47
|
+
// membership and name go through CorticoBridge, which hands every other member to Steam.
|
|
48
|
+
...([
|
|
49
|
+
['multiplayer/scripts/MP_PacketManager.gd', 'Steam.sendP2PPacket(', 'SendP2PPacket(', 2],
|
|
50
|
+
['multiplayer/scripts/MP_PacketManager.gd', 'Steam.getAvailableP2PPacketSize(0)', 'GetAvailableP2PPacketSize(0)', 2],
|
|
51
|
+
['multiplayer/scripts/MP_PacketManager.gd', 'Steam.readP2PPacket(packet_size, 0)', 'ReadP2PPacket(packet_size, 0)', 1],
|
|
52
|
+
['multiplayer/scripts/MP_PacketManager.gd', 'Steam.getFriendPersonaName(', 'PersonaName(', 1],
|
|
53
|
+
['multiplayer/scripts/MP_LobbyManager.gd', 'Steam.getNumLobbyMembers(', 'NumLobbyMembers(', 1],
|
|
54
|
+
['multiplayer/scripts/MP_LobbyManager.gd', 'Steam.getLobbyMemberByIndex(', 'LobbyMemberByIndex(', 1],
|
|
55
|
+
['multiplayer/scripts/MP_LobbyManager.gd', 'Steam.getLobbyOwner(', 'LobbyOwner(', 2],
|
|
56
|
+
['multiplayer/scripts/MP_LobbyManager.gd', 'Steam.joinLobby(', 'JoinLobby(', 1],
|
|
57
|
+
['multiplayer/scripts/MP_LobbyManager.gd', 'Steam.leaveLobby(', 'LeaveLobby(', 1],
|
|
58
|
+
['multiplayer/scripts/MP_LobbyManager.gd', 'Steam.getFriendPersonaName(', 'PersonaName(', 4],
|
|
59
|
+
['multiplayer/scripts/MP_LobbyManager.gd', 'Steam.setLobbyMemberLimit(', 'SetLobbyMemberLimit(', 2],
|
|
60
|
+
['multiplayer/scripts/global scripts/MP_OutroManager.gd', 'Steam.getFriendPersonaName(', 'PersonaName(', 6],
|
|
61
|
+
['multiplayer/scripts/global scripts/MP_UserInstanceHandler.gd', 'Steam.getFriendPersonaName(', 'PersonaName(', 1],
|
|
62
|
+
['multiplayer/scripts/user scripts/MP_UserStateManager.gd', 'Steam.getFriendPersonaName(', 'PersonaName(', 1],
|
|
63
|
+
] as const).map(([path, anchor, call, occurrences]): ScriptHook => ({ path, anchor, insertion: `${BRIDGE_NODE}.${call}`, replace: true, occurrences })),
|
|
64
|
+
// The local peer leaves Steam uninitialised, so it never acts as the account the host is signed in with.
|
|
65
|
+
{ path: 'scripts/Steam.gd', anchor: 'func InitializeSteam():\n', insertion: `\tif ${BRIDGE_NODE}.IsLocalPeer(): return\n` },
|
|
66
|
+
{ path: 'scripts/Steam.gd', anchor: '\tif GlobalVariables.mp_debugging: STEAM_ID = 1234\n', insertion: `\t${BRIDGE_NODE}.AdoptPeerIdentity(self)\n` },
|
|
46
67
|
];
|
|
47
68
|
|
|
48
69
|
export function applyHook(source: string, hook: ScriptHook): string {
|
|
49
70
|
const segments = source.split(hook.anchor);
|
|
50
71
|
if (segments.length === 1) throw new Error(`Game hook anchor missing: ${hook.path}`);
|
|
51
72
|
if (segments.length - 1 !== (hook.occurrences ?? 1)) throw new Error(`Ambiguous game hook anchor: ${hook.path}`);
|
|
52
|
-
return segments.join(hook.before ? hook.insertion + hook.anchor : hook.anchor + hook.insertion);
|
|
73
|
+
return segments.join(hook.replace ? hook.insertion : hook.before ? hook.insertion + hook.anchor : hook.anchor + hook.insertion);
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
/** Adds an autoload to ECFG without re-encoding existing Variant values. */
|
|
@@ -96,10 +117,11 @@ export function setProjectSettings(project: Buffer, values: ReadonlyMap<string,
|
|
|
96
117
|
return Buffer.concat([header, ...records]);
|
|
97
118
|
}
|
|
98
119
|
|
|
99
|
-
|
|
120
|
+
/** A `profile` such as 'local-peer' builds the pack for another local process, which keeps its settings and saves apart. */
|
|
121
|
+
export async function prepareImplant(executableFile: string, runtimeDir: string, profile = ''): Promise<string> {
|
|
100
122
|
await verifyInstallation(executableFile);
|
|
101
123
|
const bridge = await readFile(BRIDGE_FILE);
|
|
102
|
-
const saveProfile = createHash('sha256').update(resolve(runtimeDir)).digest('hex').slice(0, 16);
|
|
124
|
+
const saveProfile = createHash('sha256').update(resolve(runtimeDir)).update(profile ? `:${profile}` : '').digest('hex').slice(0, 16);
|
|
103
125
|
const revision = createHash('sha256').update(bridge).update(JSON.stringify(BUCKSHOT_SCRIPT_HOOKS)).update(setProjectSettings.toString()).update(prepareImplant.toString()).update(saveProfile).digest('hex');
|
|
104
126
|
const directory = join(runtimeDir, BUCKSHOT_PROFILE.id, revision);
|
|
105
127
|
const outputFile = join(directory, 'buckshot.pck');
|
package/src/native/bridge.gd
CHANGED
|
@@ -25,12 +25,23 @@ var scene_id = 0
|
|
|
25
25
|
var seat_health = {}
|
|
26
26
|
var outcome = {}
|
|
27
27
|
var multiplayer_status = {}
|
|
28
|
+
# Local peer, in the second process: its lobby member id and name, and the host's lobby as the host last reported it.
|
|
29
|
+
var peer_id = 0
|
|
30
|
+
var peer_name = ""
|
|
31
|
+
var roster = {"lobby": 0, "owner": 0, "members": []}
|
|
32
|
+
# Local peers, in the host process: member id -> {name, mock}, and the member list each was last sent.
|
|
33
|
+
var local_peers = {}
|
|
34
|
+
var last_rosters = {}
|
|
35
|
+
# Packets relayed between host and local peer, read before Steam's.
|
|
36
|
+
var relay_inbox = []
|
|
28
37
|
|
|
29
38
|
func _init():
|
|
30
39
|
for arg in OS.get_cmdline_user_args():
|
|
31
40
|
if arg.begins_with("--cortico-executable="):
|
|
32
41
|
if !ProjectSettings.load_resource_pack(arg.trim_prefix("--cortico-executable="), false):
|
|
33
42
|
push_error("Cortico could not load the original game resource pack")
|
|
43
|
+
if arg.begins_with("--cortico-peer-id="): peer_id = int(arg.trim_prefix("--cortico-peer-id="))
|
|
44
|
+
if arg.begins_with("--cortico-peer-name="): peer_name = arg.trim_prefix("--cortico-peer-name=")
|
|
34
45
|
|
|
35
46
|
func _ready():
|
|
36
47
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
@@ -95,6 +106,7 @@ func _process(delta):
|
|
|
95
106
|
if elapsed >= 0.1:
|
|
96
107
|
elapsed = 0
|
|
97
108
|
Publish()
|
|
109
|
+
PublishRoster()
|
|
98
110
|
for action in held_actions.keys():
|
|
99
111
|
if Time.get_ticks_msec() >= held_actions[action]:
|
|
100
112
|
ReleaseAction(action)
|
|
@@ -527,6 +539,32 @@ func Handle(request):
|
|
|
527
539
|
else: held_controller_buttons.erase(button)
|
|
528
540
|
Reply(id, true, "input delivered")
|
|
529
541
|
"capture": Capture(id)
|
|
542
|
+
"net":
|
|
543
|
+
relay_inbox.append({"steam_id_remote": int(request.get("from", "0")), "data": Marshalls.base64_to_raw(str(request.get("data", "")))})
|
|
544
|
+
"peer_lobby":
|
|
545
|
+
var refusal = PeerRefusal(int(request.get("peer", "0")))
|
|
546
|
+
Reply(id, refusal == "", refusal if refusal != "" else "lobby open", {"lobby": str(get_node("/root/GlobalSteam").LOBBY_ID), "memberLimit": MemberLimit()})
|
|
547
|
+
"peer_attach": AttachPeer(id, int(request.get("peer", "0")), str(request.get("name", "")), bool(request.get("mock", false)))
|
|
548
|
+
"peer_detach":
|
|
549
|
+
DetachPeer(int(request.get("peer", "0")))
|
|
550
|
+
Reply(id, true, "local peer detached")
|
|
551
|
+
"peer_enter":
|
|
552
|
+
var scene = get_tree().current_scene
|
|
553
|
+
if peer_id == 0 or scene == null or scene.scene_file_path.get_file().get_basename() != "menu":
|
|
554
|
+
Reply(id, false, "the local peer can enter a lobby only from the main menu")
|
|
555
|
+
return
|
|
556
|
+
# as MP_CommandLineChecker.JoinGameOutsideLobby: the lobby scene joins this id once its intro ends
|
|
557
|
+
var globals = get_node("/root/GlobalVariables")
|
|
558
|
+
globals.lobby_id_found_in_command_line = int(request.get("lobby", "0"))
|
|
559
|
+
globals.running_short_intro_in_lobby_scene = true
|
|
560
|
+
globals.command_line_checked = true
|
|
561
|
+
get_tree().change_scene_to_file("res://multiplayer/scenes/mp_lobby.tscn")
|
|
562
|
+
Reply(id, true, "entering lobby")
|
|
563
|
+
"peer_joined":
|
|
564
|
+
SetRoster(request.get("roster", {}), false)
|
|
565
|
+
var response = Steam.CHAT_ROOM_ENTER_RESPONSE_SUCCESS if request.get("ok", false) else Steam.CHAT_ROOM_ENTER_RESPONSE_DOESNT_EXIST
|
|
566
|
+
Steam.lobby_joined.emit(int(request.get("lobby", "0")), 0, false, response)
|
|
567
|
+
"roster": SetRoster(request.get("roster", {}), true)
|
|
530
568
|
"quit":
|
|
531
569
|
ReleaseInputs()
|
|
532
570
|
Reply(id, true, "closing owned game")
|
|
@@ -591,3 +629,163 @@ func Capture(id):
|
|
|
591
629
|
await RenderingServer.frame_post_draw
|
|
592
630
|
var image = get_viewport().get_texture().get_image()
|
|
593
631
|
Reply(id, true, "captured", {"png": Marshalls.raw_to_base64(image.save_png_to_buffer())})
|
|
632
|
+
|
|
633
|
+
# Local peer. The host keeps its Steam lobby; one more game process on this machine sits in it as a member Steam does not
|
|
634
|
+
# know about. The calls below stand in for Steam's in the multiplayer scripts and give Steam everything else, so with no
|
|
635
|
+
# local peer the game behaves as it does without the bridge. Member ids cross the bridge as strings: they exceed 2^53.
|
|
636
|
+
#
|
|
637
|
+
# Steam players can share the lobby: the host hands out seats and relays every packet, so their unmodified games seat the
|
|
638
|
+
# local peer from the host's setup packet although Steam never lists it to them. Two things only Steam would tell them
|
|
639
|
+
# are covered here: the seat the local peer takes (the Steam member limit drops by one) and its leaving mid-match,
|
|
640
|
+
# which they could not see, so everyone goes back to the lobby. A local peer started with mock set is checked as such a
|
|
641
|
+
# Steam player instead: it counts as one and its member list leaves the real local peer out, as Steam's would.
|
|
642
|
+
|
|
643
|
+
func IsLocalPeer():
|
|
644
|
+
return peer_id != 0
|
|
645
|
+
|
|
646
|
+
func AdoptPeerIdentity(steam):
|
|
647
|
+
if peer_id == 0: return
|
|
648
|
+
steam.STEAM_ID = peer_id
|
|
649
|
+
steam.STEAM_NAME = peer_name
|
|
650
|
+
steam.ONLINE = true
|
|
651
|
+
|
|
652
|
+
func Lobby():
|
|
653
|
+
return get_node("/root/GlobalSteam").LOBBY_ID
|
|
654
|
+
|
|
655
|
+
func SendP2PPacket(target, data, send_type, channel):
|
|
656
|
+
if peer_id != 0:
|
|
657
|
+
# members accept packets from the host only; the host relays the rest
|
|
658
|
+
if target == roster.owner: Send({"kind": "net", "from": str(peer_id), "to": str(target), "data": Marshalls.raw_to_base64(data)})
|
|
659
|
+
return true
|
|
660
|
+
if local_peers.has(target):
|
|
661
|
+
Send({"kind": "net", "from": str(get_node("/root/GlobalSteam").STEAM_ID), "to": str(target), "data": Marshalls.raw_to_base64(data)})
|
|
662
|
+
return true
|
|
663
|
+
return Steam.sendP2PPacket(target, data, send_type, channel)
|
|
664
|
+
|
|
665
|
+
func GetAvailableP2PPacketSize(channel):
|
|
666
|
+
if relay_inbox.size() > 0: return relay_inbox[0].data.size()
|
|
667
|
+
if peer_id != 0: return 0
|
|
668
|
+
return Steam.getAvailableP2PPacketSize(channel)
|
|
669
|
+
|
|
670
|
+
func ReadP2PPacket(size, channel):
|
|
671
|
+
if relay_inbox.size() > 0: return relay_inbox.pop_front()
|
|
672
|
+
if peer_id != 0: return {}
|
|
673
|
+
return Steam.readP2PPacket(size, channel)
|
|
674
|
+
|
|
675
|
+
func NumLobbyMembers(lobby):
|
|
676
|
+
if peer_id != 0: return roster.members.size()
|
|
677
|
+
return Steam.getNumLobbyMembers(lobby) + (local_peers.size() if lobby == Lobby() else 0)
|
|
678
|
+
|
|
679
|
+
func LobbyMemberByIndex(lobby, index):
|
|
680
|
+
if peer_id != 0: return roster.members[index].id
|
|
681
|
+
var count = Steam.getNumLobbyMembers(lobby)
|
|
682
|
+
if index < count or lobby != Lobby(): return Steam.getLobbyMemberByIndex(lobby, index)
|
|
683
|
+
return local_peers.keys()[index - count]
|
|
684
|
+
|
|
685
|
+
func LobbyOwner(lobby):
|
|
686
|
+
if peer_id != 0: return roster.owner
|
|
687
|
+
return Steam.getLobbyOwner(lobby)
|
|
688
|
+
|
|
689
|
+
func JoinLobby(lobby):
|
|
690
|
+
if peer_id == 0:
|
|
691
|
+
Steam.joinLobby(lobby)
|
|
692
|
+
return
|
|
693
|
+
# answered by a peer_joined request once the host has the peer in its lobby
|
|
694
|
+
Send({"kind": "peer", "event": "join", "lobby": str(lobby)})
|
|
695
|
+
|
|
696
|
+
func LeaveLobby(lobby):
|
|
697
|
+
if peer_id != 0:
|
|
698
|
+
roster = {"lobby": 0, "owner": 0, "members": []}
|
|
699
|
+
Send({"kind": "peer", "event": "leave"})
|
|
700
|
+
return
|
|
701
|
+
Steam.leaveLobby(lobby)
|
|
702
|
+
if lobby != Lobby(): return
|
|
703
|
+
# As Steam would tell them: the host left and the lobby passed to them. A local peer mid-match exits as from a host
|
|
704
|
+
# that disconnected; one on the lobby screen holds a lobby nobody else is in.
|
|
705
|
+
for member in local_peers.keys():
|
|
706
|
+
Send({"kind": "roster", "peer": str(member), "roster": {"lobby": str(lobby), "owner": str(member), "members": [{"id": str(member), "name": local_peers[member].name}]}})
|
|
707
|
+
local_peers.clear()
|
|
708
|
+
last_rosters.clear()
|
|
709
|
+
|
|
710
|
+
func SetLobbyMemberLimit(lobby, limit):
|
|
711
|
+
Steam.setLobbyMemberLimit(lobby, limit - RealPeerCount() if lobby == Lobby() else limit)
|
|
712
|
+
|
|
713
|
+
func PersonaName(id):
|
|
714
|
+
if local_peers.has(id): return local_peers[id].name
|
|
715
|
+
if peer_id == 0: return Steam.getFriendPersonaName(id)
|
|
716
|
+
for member in roster.members:
|
|
717
|
+
if member.id == id: return member.name
|
|
718
|
+
return ""
|
|
719
|
+
|
|
720
|
+
func RealPeerCount():
|
|
721
|
+
var count = 0
|
|
722
|
+
for member in local_peers.keys():
|
|
723
|
+
if !local_peers[member].mock: count += 1
|
|
724
|
+
return count
|
|
725
|
+
|
|
726
|
+
## Members Steam lists, with mock Steam players counted among them.
|
|
727
|
+
func SteamMemberCount():
|
|
728
|
+
return Steam.getNumLobbyMembers(Lobby()) + local_peers.size() - RealPeerCount()
|
|
729
|
+
|
|
730
|
+
## Why this process cannot take `member` into its lobby now, or "".
|
|
731
|
+
func PeerRefusal(member):
|
|
732
|
+
var steam = get_node("/root/GlobalSteam")
|
|
733
|
+
var scene = get_tree().current_scene
|
|
734
|
+
if peer_id != 0 or member == 0: return "not a host process"
|
|
735
|
+
if steam.LOBBY_ID == 0 or steam.STEAM_ID != steam.HOST_ID: return "not hosting a lobby"
|
|
736
|
+
if scene == null or scene.scene_file_path.get_file().get_basename() != "mp_lobby": return "not on the lobby screen"
|
|
737
|
+
if !local_peers.has(member) and SteamMemberCount() + RealPeerCount() >= steam.lobby_player_limit: return "the lobby is full"
|
|
738
|
+
return ""
|
|
739
|
+
|
|
740
|
+
func AttachPeer(id, member, name, mock):
|
|
741
|
+
var refusal = PeerRefusal(member)
|
|
742
|
+
if refusal != "":
|
|
743
|
+
Reply(id, false, refusal)
|
|
744
|
+
return
|
|
745
|
+
var steam = get_node("/root/GlobalSteam")
|
|
746
|
+
local_peers[member] = {"name": name, "mock": mock}
|
|
747
|
+
if !mock: SetLobbyMemberLimit(steam.LOBBY_ID, steam.lobby_player_limit)
|
|
748
|
+
Steam.lobby_chat_update.emit(steam.LOBBY_ID, member, member, Steam.CHAT_MEMBER_STATE_CHANGE_ENTERED)
|
|
749
|
+
Reply(id, true, "local peer attached", {"roster": Roster(member), "memberLimit": MemberLimit()})
|
|
750
|
+
|
|
751
|
+
func DetachPeer(member):
|
|
752
|
+
if !local_peers.has(member): return
|
|
753
|
+
var mock = local_peers[member].mock
|
|
754
|
+
local_peers.erase(member)
|
|
755
|
+
last_rosters.erase(member)
|
|
756
|
+
var steam = get_node("/root/GlobalSteam")
|
|
757
|
+
var handler = First("MP_UserInstanceHandler.gd")
|
|
758
|
+
if !mock and handler != null and SteamMemberCount() > 1 and handler.removing_instances and !handler.intermediary.game_state.MAIN_running_win_routine:
|
|
759
|
+
handler.Packet_ExitGameWithLobby()
|
|
760
|
+
Steam.lobby_chat_update.emit(steam.LOBBY_ID, member, member, Steam.CHAT_MEMBER_STATE_CHANGE_LEFT)
|
|
761
|
+
if !mock: SetLobbyMemberLimit(steam.LOBBY_ID, steam.lobby_player_limit)
|
|
762
|
+
|
|
763
|
+
func MemberLimit():
|
|
764
|
+
return Steam.call("getLobbyMemberLimit", Lobby()) if Steam.has_method("getLobbyMemberLimit") else null
|
|
765
|
+
|
|
766
|
+
## The member list `member` sees: all of it, or for a mock Steam player what Steam would list.
|
|
767
|
+
func Roster(member):
|
|
768
|
+
var steam = get_node("/root/GlobalSteam")
|
|
769
|
+
var members = []
|
|
770
|
+
for entry in steam.LOBBY_MEMBERS:
|
|
771
|
+
if local_peers[member].mock and local_peers.has(entry.steam_id) and !local_peers[entry.steam_id].mock: continue
|
|
772
|
+
members.append({"id": str(entry.steam_id), "name": entry.steam_name})
|
|
773
|
+
return {"lobby": str(steam.LOBBY_ID), "owner": str(steam.HOST_ID), "members": members}
|
|
774
|
+
|
|
775
|
+
func PublishRoster():
|
|
776
|
+
for member in local_peers.keys():
|
|
777
|
+
var current = Roster(member)
|
|
778
|
+
var serialized = JSON.stringify(current)
|
|
779
|
+
if serialized == last_rosters.get(member, ""): continue
|
|
780
|
+
last_rosters[member] = serialized
|
|
781
|
+
Send({"kind": "roster", "peer": str(member), "roster": current})
|
|
782
|
+
|
|
783
|
+
func SetRoster(value, announce):
|
|
784
|
+
if !(value is Dictionary): return
|
|
785
|
+
var members = []
|
|
786
|
+
for member in value.get("members", []): members.append({"id": int(member.get("id", "0")), "name": str(member.get("name", ""))})
|
|
787
|
+
var next = {"lobby": int(value.get("lobby", "0")), "owner": int(value.get("owner", "0")), "members": members}
|
|
788
|
+
if JSON.stringify(next) == JSON.stringify(roster): return
|
|
789
|
+
roster = next
|
|
790
|
+
# as Steam's lobby_chat_update: the lobby scripts read the members again
|
|
791
|
+
if announce and Lobby() != 0 and Lobby() == roster.lobby: Steam.lobby_chat_update.emit(roster.lobby, roster.owner, roster.owner, Steam.CHAT_MEMBER_STATE_CHANGE_ENTERED)
|
package/src/peer.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { BuckshotBridge } from './bridge.ts';
|
|
5
|
+
import type { BuckshotSnapshot } from './protocol.ts';
|
|
6
|
+
|
|
7
|
+
/** The lobby member id the local peer takes; no Steam account has it. Strings, since ids exceed 2^53. */
|
|
8
|
+
export const LOCAL_PEER_ID = '76561190000000001';
|
|
9
|
+
|
|
10
|
+
export interface LocalPeerOptions {
|
|
11
|
+
/** The lobby member id, LOCAL_PEER_ID unless a check runs several peers. */
|
|
12
|
+
id: string;
|
|
13
|
+
/** Checks how Steam players fare: the host counts this peer as one and lists it what Steam would. */
|
|
14
|
+
mockSteamPlayer?: boolean;
|
|
15
|
+
executableFile: string;
|
|
16
|
+
packFile: string;
|
|
17
|
+
logFile: string;
|
|
18
|
+
name: string;
|
|
19
|
+
screenIndex: number | null;
|
|
20
|
+
muteAudio: boolean;
|
|
21
|
+
startupTimeoutMs: number;
|
|
22
|
+
actionTimeoutMs: number;
|
|
23
|
+
/** Called when the peer enters or leaves the host's lobby, and when its process ends. */
|
|
24
|
+
onChange(change: 'joined' | 'left' | 'closed'): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A second game process on this machine that joins the host game's multiplayer lobby. The host keeps its Steam lobby;
|
|
29
|
+
* the peer never initialises Steam and sits in that lobby as a member only the two bridges know about. Its packets,
|
|
30
|
+
* the host's member list and its join and leave requests pass through here.
|
|
31
|
+
*/
|
|
32
|
+
export class LocalPeer {
|
|
33
|
+
readonly bridge = new BuckshotBridge();
|
|
34
|
+
private game: ChildProcess | null = null;
|
|
35
|
+
private requests = Promise.resolve();
|
|
36
|
+
private detached = false;
|
|
37
|
+
joined = false;
|
|
38
|
+
|
|
39
|
+
constructor(private readonly host: BuckshotBridge, private readonly options: LocalPeerOptions) {}
|
|
40
|
+
|
|
41
|
+
get running(): boolean { return this.game !== null && this.game.exitCode === null; }
|
|
42
|
+
|
|
43
|
+
/** Starts the peer and sends it from its main menu into the host's lobby `lobby`. */
|
|
44
|
+
async start(lobby: string): Promise<void> {
|
|
45
|
+
const { options, bridge, host } = this;
|
|
46
|
+
const port = await bridge.start();
|
|
47
|
+
const log = createWriteStream(options.logFile, { flags: 'a' });
|
|
48
|
+
log.on('error', () => undefined);
|
|
49
|
+
const args = ['--windowed'];
|
|
50
|
+
if (options.screenIndex !== null) args.push('--screen', String(options.screenIndex));
|
|
51
|
+
if (options.muteAudio) args.push('--audio-driver', 'Dummy');
|
|
52
|
+
const game = spawn(options.executableFile, [...args, '--main-pack', options.packFile, '--', `--cortico-executable=${options.executableFile}`, `--cortico-port=${port}`, `--cortico-token=${bridge.token}`, `--cortico-peer-id=${options.id}`, `--cortico-peer-name=${options.name}`],
|
|
53
|
+
{ cwd: dirname(options.executableFile), windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
54
|
+
this.game = game;
|
|
55
|
+
game.stdout?.pipe(log, { end: false }); game.stderr?.pipe(log, { end: false });
|
|
56
|
+
game.once('exit', () => { log.end(); void this.detach().then(() => options.onChange('closed')); });
|
|
57
|
+
|
|
58
|
+
host.on('net', this.fromHost);
|
|
59
|
+
host.on('roster', this.rosterFromHost);
|
|
60
|
+
bridge.on('net', this.fromPeer);
|
|
61
|
+
bridge.on('peer', this.lobbyRequest);
|
|
62
|
+
bridge.once('disconnected', () => { void this.detach(); });
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await this.until('游戏连接', (resolve, reject) => {
|
|
66
|
+
bridge.once('connected', (hello: { pid: number; version: string }) => hello.pid === game.pid && hello.version === 'v2.2.0.6' ? resolve() : reject(new Error('本地客户端的进程或版本与启动请求不匹配。')));
|
|
67
|
+
game.once('error', reject);
|
|
68
|
+
game.once('exit', () => reject(new Error('本地客户端在连接前退出。')));
|
|
69
|
+
});
|
|
70
|
+
await this.until('主菜单', (resolve) => {
|
|
71
|
+
const check = (snapshot: BuckshotSnapshot) => { if (snapshot.scene === 'menu') { bridge.off('snapshot', check); resolve(); } };
|
|
72
|
+
if (bridge.snapshot) check(bridge.snapshot);
|
|
73
|
+
bridge.on('snapshot', check);
|
|
74
|
+
});
|
|
75
|
+
const reply = await bridge.request('peer_enter', { lobby }, options.actionTimeoutMs);
|
|
76
|
+
if (!reply.ok) throw new Error(reply.detail);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
await this.stop();
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async stop(): Promise<void> {
|
|
84
|
+
const game = this.game;
|
|
85
|
+
if (game?.exitCode === null) await this.bridge.request('quit', {}, this.options.actionTimeoutMs).catch(() => undefined);
|
|
86
|
+
await this.detach();
|
|
87
|
+
await this.bridge.stop();
|
|
88
|
+
if (game?.pid && game.exitCode === null) await new Promise<void>((resolve) => { game.once('close', () => resolve()); if (!game.kill()) resolve(); });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private until(what: string, wait: (resolve: () => void, reject: (error: Error) => void) => void): Promise<void> {
|
|
92
|
+
return new Promise<void>((resolve, reject) => {
|
|
93
|
+
const timer = setTimeout(() => reject(new Error(`等待本地客户端${what}超时;检查 game-peer.log。`)), this.options.startupTimeoutMs);
|
|
94
|
+
wait(() => { clearTimeout(timer); resolve(); }, (error) => { clearTimeout(timer); reject(error); });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private readonly fromHost = (message: { to?: unknown; from?: unknown; data?: unknown }) => {
|
|
99
|
+
if (message.to === this.options.id) this.bridge.send('net', { from: message.from, data: message.data });
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// The peer talks to the host only, whatever member it addresses.
|
|
103
|
+
private readonly fromPeer = (message: { data?: unknown }) => { this.host.send('net', { from: this.options.id, data: message.data }); };
|
|
104
|
+
|
|
105
|
+
private readonly rosterFromHost = (message: { peer?: unknown; roster?: unknown }) => { if (message.peer === this.options.id) this.bridge.send('roster', { roster: message.roster }); };
|
|
106
|
+
|
|
107
|
+
/** The peer's lobby scripts asked to join or leave; the host answers in order. */
|
|
108
|
+
private readonly lobbyRequest = (message: { event?: unknown; lobby?: unknown }) => {
|
|
109
|
+
this.requests = this.requests.then(async () => {
|
|
110
|
+
if (message.event === 'leave') { await this.leave(); return; }
|
|
111
|
+
if (message.event !== 'join') return;
|
|
112
|
+
const reply = await this.host.request('peer_attach', { peer: this.options.id, name: this.options.name, mock: this.options.mockSteamPlayer === true }, this.options.actionTimeoutMs).catch(() => null);
|
|
113
|
+
const roster = (reply as { roster?: { lobby?: string } } | null)?.roster;
|
|
114
|
+
const ok = reply?.ok === true && roster?.lobby === message.lobby;
|
|
115
|
+
if (reply?.ok && !ok) await this.leave();
|
|
116
|
+
this.bridge.send('peer_joined', { lobby: message.lobby, ok, roster: ok ? roster : {} });
|
|
117
|
+
if (ok && !this.joined) { this.joined = true; this.options.onChange('joined'); }
|
|
118
|
+
}).catch(() => undefined);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
private async leave(): Promise<void> {
|
|
122
|
+
await this.host.request('peer_detach', { peer: this.options.id }, this.options.actionTimeoutMs).catch(() => undefined);
|
|
123
|
+
if (this.joined) { this.joined = false; this.options.onChange('left'); }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Takes the peer out of the host's lobby for good and stops relaying. */
|
|
127
|
+
private async detach(): Promise<void> {
|
|
128
|
+
if (this.detached) return;
|
|
129
|
+
this.detached = true;
|
|
130
|
+
this.host.off('net', this.fromHost);
|
|
131
|
+
this.host.off('roster', this.rosterFromHost);
|
|
132
|
+
await this.requests;
|
|
133
|
+
await this.leave();
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/webui/client.ts
CHANGED
|
@@ -11,10 +11,12 @@ const game: WebUiPanel = {
|
|
|
11
11
|
const start = ui.button('启动游戏', { variant: 'primary' });
|
|
12
12
|
const stop = ui.button('关闭游戏', { variant: 'danger' });
|
|
13
13
|
const capture = ui.button('查看画面');
|
|
14
|
+
const peerJoin = ui.button('本地加入');
|
|
15
|
+
const peerLeave = ui.button('退出本地');
|
|
14
16
|
const preview = ui.h('img');
|
|
15
17
|
preview.alt = '游戏当前画面';
|
|
16
18
|
preview.style.cssText = 'display:none;width:100%;height:auto;margin-top:12px;';
|
|
17
|
-
actions.append(start, stop, capture);
|
|
19
|
+
actions.append(start, stop, capture, peerJoin, peerLeave);
|
|
18
20
|
card.body.append(status, message, actions, preview);
|
|
19
21
|
ctx.root.append(card.el);
|
|
20
22
|
let busy = false;
|
|
@@ -26,21 +28,26 @@ const game: WebUiPanel = {
|
|
|
26
28
|
{ k: '场景', v: state.scene ?? '—' },
|
|
27
29
|
{ k: '模式', v: state.mode ?? '—' },
|
|
28
30
|
{ k: '轮次', v: state.round === undefined ? '—' : String(state.round) },
|
|
31
|
+
{ k: '本地玩家', v: ui.pill({ offline: '未加入', starting: '启动中', menu: '未进房间', joined: '在房间里' }[state.peer], state.peer === 'joined' ? 'on' : 'off') },
|
|
29
32
|
]));
|
|
30
|
-
message.textContent = state.error;
|
|
33
|
+
message.textContent = state.error || state.peerError;
|
|
31
34
|
start.disabled = busy || state.state === 'online' || state.state === 'loading';
|
|
32
35
|
stop.disabled = busy || !state.stoppable;
|
|
33
36
|
capture.disabled = busy || state.state !== 'online';
|
|
37
|
+
peerJoin.disabled = busy || state.state !== 'online' || state.peer !== 'offline';
|
|
38
|
+
peerLeave.disabled = busy || state.peer === 'offline';
|
|
34
39
|
}
|
|
35
40
|
async function refresh() { if (!ctx.signal.aborted) render(await ctx.invoke<BuckshotGameState>('state')); }
|
|
36
|
-
async function act(method: 'start' | 'stop') {
|
|
37
|
-
busy = true; start.disabled = stop.disabled = capture.disabled = true;
|
|
41
|
+
async function act(method: 'start' | 'stop' | 'peerJoin' | 'peerLeave') {
|
|
42
|
+
busy = true; start.disabled = stop.disabled = capture.disabled = peerJoin.disabled = peerLeave.disabled = true;
|
|
38
43
|
try { render(await ctx.invoke<BuckshotGameState>(method)); await ctx.refresh(); }
|
|
39
44
|
catch (error) { message.textContent = error instanceof Error ? error.message : String(error); }
|
|
40
45
|
finally { busy = false; await refresh().catch(() => undefined); }
|
|
41
46
|
}
|
|
42
47
|
start.addEventListener('click', () => { void act('start'); }, { signal: ctx.signal });
|
|
43
48
|
stop.addEventListener('click', () => { void act('stop'); }, { signal: ctx.signal });
|
|
49
|
+
peerJoin.addEventListener('click', () => { void act('peerJoin'); }, { signal: ctx.signal });
|
|
50
|
+
peerLeave.addEventListener('click', () => { void act('peerLeave'); }, { signal: ctx.signal });
|
|
44
51
|
capture.addEventListener('click', () => {
|
|
45
52
|
void ctx.invokeBinary('capture').then((blob) => {
|
|
46
53
|
if (ctx.signal.aborted) return;
|
package/src/webui/types.ts
CHANGED
package/src/world.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { BUCKSHOT_PANEL_DECLS } from './webui/panels.ts';
|
|
|
16
16
|
import type { BuckshotGameState } from './webui/types.ts';
|
|
17
17
|
import { describeObservation, frameKey } from './events.ts';
|
|
18
18
|
import { Settlement } from './settle.ts';
|
|
19
|
+
import { LOCAL_PEER_ID, LocalPeer } from './peer.ts';
|
|
19
20
|
|
|
20
21
|
const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
|
|
21
22
|
|
|
@@ -39,6 +40,10 @@ export class BuckshotWorld implements World {
|
|
|
39
40
|
private readonly narrator = new StateNarrator();
|
|
40
41
|
/** While an input waits for its settlement: the observations go into its receipt instead of events. */
|
|
41
42
|
private settling: { observations: string[] } | null = null;
|
|
43
|
+
/** The second local game process that joins this game's multiplayer lobby; see LocalPeer. */
|
|
44
|
+
private peer: LocalPeer | null = null;
|
|
45
|
+
private peerStarting: Promise<void> | null = null;
|
|
46
|
+
private peerError = '';
|
|
42
47
|
|
|
43
48
|
constructor(private readonly ctx: WorldContext<BuckshotConfigSection>) {}
|
|
44
49
|
|
|
@@ -83,6 +88,8 @@ export class BuckshotWorld implements World {
|
|
|
83
88
|
if (panel !== 'game') throw new Error('未知面板。');
|
|
84
89
|
if (method === 'start') await this.startGame();
|
|
85
90
|
else if (method === 'stop') await this.stopGame();
|
|
91
|
+
else if (method === 'peerJoin') await this.startPeer();
|
|
92
|
+
else if (method === 'peerLeave') await this.stopPeer();
|
|
86
93
|
else if (method === 'capture') {
|
|
87
94
|
const reply = await this.request('capture', {});
|
|
88
95
|
if (!reply.ok || !reply.png) throw new Error(reply.detail);
|
|
@@ -99,7 +106,8 @@ export class BuckshotWorld implements World {
|
|
|
99
106
|
|
|
100
107
|
private gameState(): BuckshotGameState {
|
|
101
108
|
const snapshot = this.bridge?.snapshot;
|
|
102
|
-
|
|
109
|
+
const peer = this.peerStarting ? 'starting' : this.peer?.joined ? 'joined' : this.peer ? 'menu' : 'offline';
|
|
110
|
+
return { state: this.state, error: this.error, stoppable: this.game !== null || this.starting !== null, scene: snapshot?.scene, mode: snapshot?.mode, round: snapshot?.round, peer, peerError: this.peerError };
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
async start(host: WorldHost): Promise<void> {
|
|
@@ -112,6 +120,7 @@ export class BuckshotWorld implements World {
|
|
|
112
120
|
if (this.frameTimer) clearTimeout(this.frameTimer);
|
|
113
121
|
this.frameTimer = null;
|
|
114
122
|
this.frameWanted = false;
|
|
123
|
+
await this.stopPeer();
|
|
115
124
|
if (this.ctx.cfg.closeOnStop) await this.stopGame();
|
|
116
125
|
else { await this.bridge?.stop(); this.bridge = null; this.game = null; this.state = 'offline'; }
|
|
117
126
|
await this.eventTail;
|
|
@@ -205,7 +214,7 @@ export class BuckshotWorld implements World {
|
|
|
205
214
|
if (['shot', 'reveal', 'revival', 'result', 'dialogue'].includes(event.kind)) this.scheduleFrame();
|
|
206
215
|
});
|
|
207
216
|
bridge.on('fault', (error: Error) => { this.state = 'error'; this.error = error.message; void this.emit('error', error.message); });
|
|
208
|
-
bridge.on('disconnected', () => { if (this.bridge === bridge) { this.state = 'offline'; void this.emit('disconnected', '游戏事件桥已断开。'); } });
|
|
217
|
+
bridge.on('disconnected', () => { void this.stopPeer(); if (this.bridge === bridge) { this.state = 'offline'; void this.emit('disconnected', '游戏事件桥已断开。'); } });
|
|
209
218
|
const port = await bridge.start();
|
|
210
219
|
const logDir = join(this.ctx.dataDir, 'buckshot');
|
|
211
220
|
await mkdir(logDir, { recursive: true });
|
|
@@ -245,7 +254,51 @@ export class BuckshotWorld implements World {
|
|
|
245
254
|
}
|
|
246
255
|
}
|
|
247
256
|
|
|
257
|
+
/** Starts the local peer once this game hosts a lobby on its lobby screen; it joins that lobby on its own. */
|
|
258
|
+
async startPeer(): Promise<void> {
|
|
259
|
+
if (this.peerStarting) return this.peerStarting;
|
|
260
|
+
if (this.peer) throw new Error('本地客户端已在运行。');
|
|
261
|
+
const bridge = this.bridge;
|
|
262
|
+
if (!bridge || this.state !== 'online') throw new Error('先启动游戏并等它连接。');
|
|
263
|
+
this.peerError = '';
|
|
264
|
+
this.peerStarting = (async () => {
|
|
265
|
+
const lobby = await bridge.request('peer_lobby', { peer: LOCAL_PEER_ID }, this.ctx.cfg.actionTimeoutMs);
|
|
266
|
+
if (!lobby.ok) throw new Error(`${{ 'not hosting a lobby': '可缇还没有在多人模式里建房间。', 'not on the lobby screen': '房间已经离开大厅界面(对局进行中)。', 'the lobby is full': '房间已满。' }[lobby.detail] ?? lobby.detail}`);
|
|
267
|
+
const executableFile = normalizeExecutableFile(this.ctx.cfg.executableFile);
|
|
268
|
+
const runtimeDir = this.ctx.cfg.runtimeDir || join(this.ctx.botDir, 'runtimes', 'buckshot');
|
|
269
|
+
const packFile = await prepareImplant(executableFile, runtimeDir, 'local-peer');
|
|
270
|
+
const logDir = join(this.ctx.dataDir, 'buckshot');
|
|
271
|
+
await mkdir(logDir, { recursive: true });
|
|
272
|
+
const options = this.ctx.cfg.localPeer;
|
|
273
|
+
const name = options.name.trim() || 'Player 2';
|
|
274
|
+
const peer = new LocalPeer(bridge, {
|
|
275
|
+
id: LOCAL_PEER_ID, executableFile, packFile, logFile: join(logDir, 'game-peer.log'), name, screenIndex: options.screenIndex, muteAudio: options.muteAudio,
|
|
276
|
+
startupTimeoutMs: this.ctx.cfg.startupTimeoutMs, actionTimeoutMs: this.ctx.cfg.actionTimeoutMs,
|
|
277
|
+
onChange: (change) => {
|
|
278
|
+
if (change === 'joined') void this.emit('peer', `本地玩家 ${name} 加入了房间。`);
|
|
279
|
+
if (change === 'left') void this.emit('peer', `本地玩家 ${name} 离开了房间。`);
|
|
280
|
+
if (change === 'closed' && this.peer === peer) this.peer = null;
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
this.peer = peer;
|
|
284
|
+
await peer.start((lobby as { lobby?: string }).lobby ?? '');
|
|
285
|
+
})().catch((error) => {
|
|
286
|
+
this.peer = null;
|
|
287
|
+
this.peerError = error instanceof Error ? error.message : String(error);
|
|
288
|
+
throw error;
|
|
289
|
+
}).finally(() => { this.peerStarting = null; });
|
|
290
|
+
return this.peerStarting;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async stopPeer(): Promise<void> {
|
|
294
|
+
if (this.peerStarting) await this.peerStarting.catch(() => undefined);
|
|
295
|
+
const peer = this.peer;
|
|
296
|
+
this.peer = null;
|
|
297
|
+
await peer?.stop();
|
|
298
|
+
}
|
|
299
|
+
|
|
248
300
|
async stopGame(): Promise<void> {
|
|
301
|
+
await this.stopPeer();
|
|
249
302
|
if (this.frameTimer) clearTimeout(this.frameTimer);
|
|
250
303
|
this.frameTimer = null;
|
|
251
304
|
this.frameWanted = false;
|