screenstocks-bridge 0.2.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.
- screenstocks_bridge-0.2.0/LICENSE +7 -0
- screenstocks_bridge-0.2.0/PKG-INFO +57 -0
- screenstocks_bridge-0.2.0/README.md +34 -0
- screenstocks_bridge-0.2.0/pyproject.toml +33 -0
- screenstocks_bridge-0.2.0/setup.cfg +4 -0
- screenstocks_bridge-0.2.0/src/screenstocks_bridge/__init__.py +8 -0
- screenstocks_bridge-0.2.0/src/screenstocks_bridge/client.py +272 -0
- screenstocks_bridge-0.2.0/src/screenstocks_bridge/models.py +188 -0
- screenstocks_bridge-0.2.0/src/screenstocks_bridge/protocol.py +16 -0
- screenstocks_bridge-0.2.0/src/screenstocks_bridge.egg-info/PKG-INFO +57 -0
- screenstocks_bridge-0.2.0/src/screenstocks_bridge.egg-info/SOURCES.txt +11 -0
- screenstocks_bridge-0.2.0/src/screenstocks_bridge.egg-info/dependency_links.txt +1 -0
- screenstocks_bridge-0.2.0/src/screenstocks_bridge.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
MIT No Attribution
|
|
2
|
+
|
|
3
|
+
Copyright 2026 fuubox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: screenstocks-bridge
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for the local Screen Stocks BepInEx bridge
|
|
5
|
+
License-Expression: MIT-0
|
|
6
|
+
Project-URL: Homepage, https://github.com/fuubox/screen-stocks-bridge
|
|
7
|
+
Project-URL: Documentation, https://github.com/fuubox/screen-stocks-bridge#readme
|
|
8
|
+
Project-URL: Repository, https://github.com/fuubox/screen-stocks-bridge
|
|
9
|
+
Project-URL: Issues, https://github.com/fuubox/screen-stocks-bridge/issues
|
|
10
|
+
Keywords: screen-stocks,bepinex,game-modding,bridge
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Screen Stocks Python Bridge
|
|
25
|
+
|
|
26
|
+
The `screenstocks-bridge` package is a Python client for the local API exposed
|
|
27
|
+
by the Screen Stocks Bridge BepInEx plugin. It requires the plugin to be
|
|
28
|
+
installed in the game and the game to be running. The package itself does not
|
|
29
|
+
install or contact the game backend; it connects to the plugin on loopback.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
python -m pip install screenstocks-bridge
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Install the matching BepInEx plugin from the
|
|
38
|
+
[GitHub releases](https://github.com/fuubox/screen-stocks-bridge/releases),
|
|
39
|
+
then launch Screen Stocks. The default bridge address is `127.0.0.1:48721`.
|
|
40
|
+
Read the token from `BepInEx/config/screenstocks.bridge.cfg` or the BepInEx
|
|
41
|
+
log.
|
|
42
|
+
|
|
43
|
+
## Connect
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from screenstocks_bridge import BridgeClient
|
|
47
|
+
|
|
48
|
+
with BridgeClient(token="YOUR_TOKEN") as bridge:
|
|
49
|
+
state = bridge.snapshot()
|
|
50
|
+
print(state["ready"], state["cash"])
|
|
51
|
+
print(bridge.upgrades())
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The client supports state and activity reads, trades, upgrade purchases, and
|
|
55
|
+
auto-action controls. See the
|
|
56
|
+
[bridge usage guide](https://github.com/fuubox/screen-stocks-bridge/blob/main/docs/bridge-usage.md)
|
|
57
|
+
for the full API and its online behavior.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Screen Stocks Python Bridge
|
|
2
|
+
|
|
3
|
+
The `screenstocks-bridge` package is a Python client for the local API exposed
|
|
4
|
+
by the Screen Stocks Bridge BepInEx plugin. It requires the plugin to be
|
|
5
|
+
installed in the game and the game to be running. The package itself does not
|
|
6
|
+
install or contact the game backend; it connects to the plugin on loopback.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```powershell
|
|
11
|
+
python -m pip install screenstocks-bridge
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Install the matching BepInEx plugin from the
|
|
15
|
+
[GitHub releases](https://github.com/fuubox/screen-stocks-bridge/releases),
|
|
16
|
+
then launch Screen Stocks. The default bridge address is `127.0.0.1:48721`.
|
|
17
|
+
Read the token from `BepInEx/config/screenstocks.bridge.cfg` or the BepInEx
|
|
18
|
+
log.
|
|
19
|
+
|
|
20
|
+
## Connect
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from screenstocks_bridge import BridgeClient
|
|
24
|
+
|
|
25
|
+
with BridgeClient(token="YOUR_TOKEN") as bridge:
|
|
26
|
+
state = bridge.snapshot()
|
|
27
|
+
print(state["ready"], state["cash"])
|
|
28
|
+
print(bridge.upgrades())
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The client supports state and activity reads, trades, upgrade purchases, and
|
|
32
|
+
auto-action controls. See the
|
|
33
|
+
[bridge usage guide](https://github.com/fuubox/screen-stocks-bridge/blob/main/docs/bridge-usage.md)
|
|
34
|
+
for the full API and its online behavior.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "screenstocks-bridge"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Python client for the local Screen Stocks BepInEx bridge"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT-0"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["screen-stocks", "bepinex", "game-modding", "bridge"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/fuubox/screen-stocks-bridge"
|
|
28
|
+
Documentation = "https://github.com/fuubox/screen-stocks-bridge#readme"
|
|
29
|
+
Repository = "https://github.com/fuubox/screen-stocks-bridge"
|
|
30
|
+
Issues = "https://github.com/fuubox/screen-stocks-bridge/issues"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Python client for the local Screen Stocks BepInEx bridge."""
|
|
2
|
+
|
|
3
|
+
from .client import BridgeClient
|
|
4
|
+
from .models import AutoAction, AutoActions, Cooldown, HumanActivity, HumanActivityPage, Position, Snapshot, Stock, TradeCooldowns, TradeReceipt
|
|
5
|
+
from .protocol import BridgeError
|
|
6
|
+
|
|
7
|
+
__all__ = ["AutoAction", "AutoActions", "BridgeClient", "BridgeError", "Cooldown", "HumanActivity", "HumanActivityPage", "Position",
|
|
8
|
+
"Snapshot", "Stock", "TradeCooldowns", "TradeReceipt"]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Thread-safe JSON-lines client for the local Screen Stocks bridge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import queue
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .protocol import MAX_FRAME_BYTES, BridgeError, _ReaderStopped
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BridgeClient:
|
|
17
|
+
"""Connect to the in-game loopback bridge and issue state or trade calls."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, host: str = "127.0.0.1", port: int = 48721,
|
|
20
|
+
token: str = "", timeout: float = 5.0) -> None:
|
|
21
|
+
self.host = host
|
|
22
|
+
self.port = port
|
|
23
|
+
self.token = token
|
|
24
|
+
self.timeout = timeout
|
|
25
|
+
self._socket: socket.socket | None = None
|
|
26
|
+
self._send_lock = threading.Lock()
|
|
27
|
+
self._pending_lock = threading.Lock()
|
|
28
|
+
self._pending: dict[str, queue.Queue[dict[str, Any] | BaseException]] = {}
|
|
29
|
+
self._callbacks_lock = threading.Lock()
|
|
30
|
+
self._callbacks: list[Callable[[dict[str, Any]], None]] = []
|
|
31
|
+
self._events: queue.Queue[dict[str, Any] | None] = queue.Queue(maxsize=128)
|
|
32
|
+
self._trade_events: queue.Queue[dict[str, Any] | None] = queue.Queue(maxsize=128)
|
|
33
|
+
self._closed = threading.Event()
|
|
34
|
+
self._reader: threading.Thread | None = None
|
|
35
|
+
self._event_worker: threading.Thread | None = None
|
|
36
|
+
|
|
37
|
+
def connect(self) -> BridgeClient:
|
|
38
|
+
"""Open the authenticated TCP connection. Returns this client."""
|
|
39
|
+
if self._socket is not None:
|
|
40
|
+
return self
|
|
41
|
+
if not self.token:
|
|
42
|
+
raise ValueError("A bridge token is required.")
|
|
43
|
+
sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
|
44
|
+
sock.settimeout(None)
|
|
45
|
+
self._socket = sock
|
|
46
|
+
self._closed.clear()
|
|
47
|
+
self._reader = threading.Thread(target=self._read_loop, name="screenstocks-reader", daemon=True)
|
|
48
|
+
self._event_worker = threading.Thread(target=self._event_loop, name="screenstocks-events", daemon=True)
|
|
49
|
+
self._reader.start()
|
|
50
|
+
self._event_worker.start()
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
54
|
+
"""Send an RPC call and return its result object."""
|
|
55
|
+
if self._socket is None or self._closed.is_set():
|
|
56
|
+
raise BridgeError("not_connected", "Call connect() before making a request.")
|
|
57
|
+
request_id = uuid.uuid4().hex
|
|
58
|
+
waiter: queue.Queue[dict[str, Any] | BaseException] = queue.Queue(maxsize=1)
|
|
59
|
+
with self._pending_lock:
|
|
60
|
+
self._pending[request_id] = waiter
|
|
61
|
+
frame = {
|
|
62
|
+
"id": request_id,
|
|
63
|
+
"method": method,
|
|
64
|
+
"token": self.token,
|
|
65
|
+
"params": params or {},
|
|
66
|
+
}
|
|
67
|
+
try:
|
|
68
|
+
encoded = (json.dumps(frame, separators=(",", ":"), allow_nan=False) + "\n").encode("utf-8")
|
|
69
|
+
if len(encoded) - 1 > MAX_FRAME_BYTES:
|
|
70
|
+
raise BridgeError("frame_too_large", "Request exceeds the 16 KiB frame limit.")
|
|
71
|
+
with self._send_lock:
|
|
72
|
+
if self._closed.is_set() or self._socket is None:
|
|
73
|
+
raise BridgeError("not_connected", "The bridge connection is closed.")
|
|
74
|
+
self._socket.sendall(encoded)
|
|
75
|
+
try:
|
|
76
|
+
response = waiter.get(timeout=self.timeout)
|
|
77
|
+
except queue.Empty as exc:
|
|
78
|
+
raise BridgeError("timeout", "The bridge did not answer before the request timeout.") from exc
|
|
79
|
+
if isinstance(response, BaseException):
|
|
80
|
+
raise BridgeError("disconnected", str(response)) from response
|
|
81
|
+
if not response.get("ok"):
|
|
82
|
+
error = response.get("error") or {}
|
|
83
|
+
raise BridgeError(str(error.get("code", "remote_error")), str(error.get("message", "Bridge request failed.")))
|
|
84
|
+
result = response.get("result")
|
|
85
|
+
return result if isinstance(result, dict) else {}
|
|
86
|
+
finally:
|
|
87
|
+
with self._pending_lock:
|
|
88
|
+
self._pending.pop(request_id, None)
|
|
89
|
+
|
|
90
|
+
def snapshot(self) -> dict[str, Any]:
|
|
91
|
+
"""Return the current in-memory player and visible-market snapshot."""
|
|
92
|
+
return self.request("state.snapshot")
|
|
93
|
+
|
|
94
|
+
def human_activity(self, stock_id: str, limit: int = 32,
|
|
95
|
+
before_tick: int | None = None) -> dict[str, Any]:
|
|
96
|
+
"""Return one page of aggregate graph activity for a visible stock."""
|
|
97
|
+
params: dict[str, Any] = {"stockId": stock_id, "limit": limit}
|
|
98
|
+
if before_tick is not None:
|
|
99
|
+
params["beforeTick"] = before_tick
|
|
100
|
+
return self.request("market.human_activity", params)
|
|
101
|
+
|
|
102
|
+
def subscribe_market(self, callback: Callable[[dict[str, Any]], None]) -> None:
|
|
103
|
+
"""Subscribe to market.updated and trade.completed event dictionaries."""
|
|
104
|
+
with self._callbacks_lock:
|
|
105
|
+
if callback not in self._callbacks:
|
|
106
|
+
self._callbacks.append(callback)
|
|
107
|
+
self.request("state.subscribe")
|
|
108
|
+
|
|
109
|
+
def trade(self, action: str, stock_id: str, percent: float | None = None) -> dict[str, Any]:
|
|
110
|
+
"""Submit one explicit allowlisted trade; completion arrives as an event."""
|
|
111
|
+
params: dict[str, Any] = {"action": action, "stockId": stock_id}
|
|
112
|
+
if percent is not None:
|
|
113
|
+
params["percent"] = percent
|
|
114
|
+
return self.request("trade.submit", params)
|
|
115
|
+
|
|
116
|
+
def upgrades(self) -> dict[str, Any]:
|
|
117
|
+
"""Return the game's dynamically discovered upgrade catalog and current levels."""
|
|
118
|
+
return self.request("upgrades.snapshot")
|
|
119
|
+
|
|
120
|
+
def purchase_upgrade(self, upgrade_id: str, quantity: int = 1) -> dict[str, Any]:
|
|
121
|
+
"""Ask the running game to process an upgrade through its in-process method.
|
|
122
|
+
|
|
123
|
+
The bridge itself only talks to localhost. The game may communicate with its
|
|
124
|
+
server in online mode; server state remains authoritative. The returned status
|
|
125
|
+
is ``submitted``.
|
|
126
|
+
Refresh :meth:`upgrades` to observe the resulting level.
|
|
127
|
+
"""
|
|
128
|
+
return self.request("upgrades.purchase", {"upgradeId": upgrade_id, "quantity": quantity})
|
|
129
|
+
|
|
130
|
+
def auto_actions(self) -> dict[str, Any]:
|
|
131
|
+
"""Return dynamically discovered auto-action slots and cooldown state."""
|
|
132
|
+
return self.request("auto_actions.snapshot")
|
|
133
|
+
|
|
134
|
+
def add_auto_action(self, stock_id: str, action_type: str, condition: str,
|
|
135
|
+
target_price: float, amount_percentage: int) -> dict[str, Any]:
|
|
136
|
+
"""Add an auto action in an available game slot and return its slot index."""
|
|
137
|
+
return self.request("auto_actions.add", {
|
|
138
|
+
"stockId": stock_id, "actionType": action_type, "condition": condition,
|
|
139
|
+
"targetPrice": target_price, "amountPercentage": amount_percentage,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
def update_auto_action(self, slot_index: int, stock_id: str, action_type: str,
|
|
143
|
+
condition: str, target_price: float,
|
|
144
|
+
amount_percentage: int) -> dict[str, Any]:
|
|
145
|
+
"""Replace an auto action's complete configuration by its discovered slot index."""
|
|
146
|
+
return self.request("auto_actions.update", {
|
|
147
|
+
"slotIndex": slot_index, "stockId": stock_id, "actionType": action_type,
|
|
148
|
+
"condition": condition, "targetPrice": target_price,
|
|
149
|
+
"amountPercentage": amount_percentage,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
def remove_auto_action(self, slot_index: int) -> dict[str, Any]:
|
|
153
|
+
"""Remove the configured auto action occupying slot_index."""
|
|
154
|
+
return self.request("auto_actions.remove", {"slotIndex": slot_index})
|
|
155
|
+
|
|
156
|
+
def set_auto_action_enabled(self, slot_index: int, enabled: bool) -> dict[str, Any]:
|
|
157
|
+
"""Enable or disable one configured auto action."""
|
|
158
|
+
return self.request("auto_actions.set_enabled", {"slotIndex": slot_index, "enabled": enabled})
|
|
159
|
+
|
|
160
|
+
def set_auto_actions_active(self, active: bool) -> dict[str, Any]:
|
|
161
|
+
"""Set the game's global auto-action switch."""
|
|
162
|
+
return self.request("auto_actions.set_active", {"active": active})
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
"""Close the socket and stop its reader and event worker threads."""
|
|
166
|
+
self._fail_pending(_ReaderStopped("Client closed."))
|
|
167
|
+
self._closed.set()
|
|
168
|
+
sock, self._socket = self._socket, None
|
|
169
|
+
if sock is not None:
|
|
170
|
+
try:
|
|
171
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
try:
|
|
175
|
+
sock.close()
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
try:
|
|
179
|
+
self._events.put_nowait(None)
|
|
180
|
+
except queue.Full:
|
|
181
|
+
pass
|
|
182
|
+
try:
|
|
183
|
+
self._trade_events.put_nowait(None)
|
|
184
|
+
except queue.Full:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
def _read_loop(self) -> None:
|
|
188
|
+
buffer = bytearray()
|
|
189
|
+
sock = self._socket
|
|
190
|
+
try:
|
|
191
|
+
if sock is None:
|
|
192
|
+
return
|
|
193
|
+
while not self._closed.is_set():
|
|
194
|
+
chunk = sock.recv(4096)
|
|
195
|
+
if not chunk:
|
|
196
|
+
raise _ReaderStopped("Bridge closed the connection.")
|
|
197
|
+
buffer.extend(chunk)
|
|
198
|
+
while True:
|
|
199
|
+
newline = buffer.find(b"\n")
|
|
200
|
+
if newline < 0:
|
|
201
|
+
if len(buffer) > MAX_FRAME_BYTES:
|
|
202
|
+
raise BridgeError("frame_too_large", "Bridge response exceeds the 16 KiB frame limit.")
|
|
203
|
+
break
|
|
204
|
+
if newline > MAX_FRAME_BYTES:
|
|
205
|
+
raise BridgeError("frame_too_large", "Bridge response exceeds the 16 KiB frame limit.")
|
|
206
|
+
raw = bytes(buffer[:newline]).rstrip(b"\r")
|
|
207
|
+
del buffer[:newline + 1]
|
|
208
|
+
message = json.loads(raw.decode("utf-8"))
|
|
209
|
+
if "event" in message:
|
|
210
|
+
self._queue_event(message)
|
|
211
|
+
else:
|
|
212
|
+
with self._pending_lock:
|
|
213
|
+
waiter = self._pending.get(str(message.get("id", "")))
|
|
214
|
+
if waiter is not None:
|
|
215
|
+
waiter.put(message)
|
|
216
|
+
except BaseException as exc:
|
|
217
|
+
if not self._closed.is_set():
|
|
218
|
+
self._fail_pending(exc)
|
|
219
|
+
self._closed.set()
|
|
220
|
+
|
|
221
|
+
def _queue_event(self, event: dict[str, Any]) -> None:
|
|
222
|
+
if event.get("event") == "trade.completed":
|
|
223
|
+
# Preserve trade outcomes; a full completion queue applies TCP backpressure.
|
|
224
|
+
self._trade_events.put(event)
|
|
225
|
+
return
|
|
226
|
+
try:
|
|
227
|
+
self._events.put_nowait(event)
|
|
228
|
+
except queue.Full:
|
|
229
|
+
try:
|
|
230
|
+
self._events.get_nowait()
|
|
231
|
+
except queue.Empty:
|
|
232
|
+
pass
|
|
233
|
+
try:
|
|
234
|
+
self._events.put_nowait(event)
|
|
235
|
+
except queue.Full:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
def _event_loop(self) -> None:
|
|
239
|
+
while True:
|
|
240
|
+
if self._closed.is_set() and self._events.empty() and self._trade_events.empty():
|
|
241
|
+
return
|
|
242
|
+
try:
|
|
243
|
+
event = self._trade_events.get_nowait()
|
|
244
|
+
except queue.Empty:
|
|
245
|
+
try:
|
|
246
|
+
event = self._events.get(timeout=0.25)
|
|
247
|
+
except queue.Empty:
|
|
248
|
+
continue
|
|
249
|
+
if event is None:
|
|
250
|
+
continue
|
|
251
|
+
with self._callbacks_lock:
|
|
252
|
+
callbacks = tuple(self._callbacks)
|
|
253
|
+
for callback in callbacks:
|
|
254
|
+
try:
|
|
255
|
+
callback(event)
|
|
256
|
+
except Exception:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
def _fail_pending(self, error: BaseException) -> None:
|
|
260
|
+
with self._pending_lock:
|
|
261
|
+
waiters = tuple(self._pending.values())
|
|
262
|
+
for waiter in waiters:
|
|
263
|
+
try:
|
|
264
|
+
waiter.put_nowait(error)
|
|
265
|
+
except queue.Full:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
def __enter__(self) -> BridgeClient:
|
|
269
|
+
return self.connect()
|
|
270
|
+
|
|
271
|
+
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
|
|
272
|
+
self.close()
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Optional typed views over bridge dictionaries.
|
|
2
|
+
|
|
3
|
+
Each model retains its source dictionary as ``raw`` so callers can access fields
|
|
4
|
+
added by later protocol versions without waiting for a client release.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class HumanActivity:
|
|
15
|
+
sample_tick: int
|
|
16
|
+
up: int
|
|
17
|
+
down: int
|
|
18
|
+
total: int
|
|
19
|
+
net: int
|
|
20
|
+
up_impact: float
|
|
21
|
+
down_impact: float
|
|
22
|
+
total_impact: float
|
|
23
|
+
net_impact: float
|
|
24
|
+
raw: dict[str, Any] = field(repr=False)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_dict(cls, value: dict[str, Any]) -> HumanActivity:
|
|
28
|
+
return cls(int(value.get("sampleTick", 0)), int(value.get("up", 0)),
|
|
29
|
+
int(value.get("down", 0)), int(value.get("total", 0)),
|
|
30
|
+
int(value.get("net", 0)), float(value.get("upImpact", 0)),
|
|
31
|
+
float(value.get("downImpact", 0)), float(value.get("totalImpact", 0)),
|
|
32
|
+
float(value.get("netImpact", 0)), value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Stock:
|
|
37
|
+
stock_id: str
|
|
38
|
+
name: str
|
|
39
|
+
price: float
|
|
40
|
+
unlocked: bool
|
|
41
|
+
raw: dict[str, Any] = field(repr=False)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, value: dict[str, Any]) -> Stock:
|
|
45
|
+
return cls(str(value.get("stockId", "")), str(value.get("name", "")),
|
|
46
|
+
float(value.get("price", 0)), bool(value.get("unlocked", False)),
|
|
47
|
+
value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class HumanActivityPage:
|
|
52
|
+
stock_id: str
|
|
53
|
+
samples: list[HumanActivity]
|
|
54
|
+
has_more: bool
|
|
55
|
+
next_before_tick: int
|
|
56
|
+
raw: dict[str, Any] = field(repr=False)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, value: dict[str, Any]) -> HumanActivityPage:
|
|
60
|
+
return cls(str(value.get("stockId", "")),
|
|
61
|
+
[HumanActivity.from_dict(item) for item in value.get("samples", [])],
|
|
62
|
+
bool(value.get("hasMore", False)), int(value.get("nextBeforeTick", 0)), value)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Position:
|
|
67
|
+
stock_id: str
|
|
68
|
+
shares_owned: str
|
|
69
|
+
average_buy_price: str
|
|
70
|
+
shares_shorted: str
|
|
71
|
+
average_short_price: str
|
|
72
|
+
raw: dict[str, Any] = field(repr=False)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_dict(cls, value: dict[str, Any]) -> Position:
|
|
76
|
+
return cls(str(value.get("stockId", "")), str(value.get("sharesOwned", "0")),
|
|
77
|
+
str(value.get("averageBuyPrice", "0")), str(value.get("sharesShorted", "0")),
|
|
78
|
+
str(value.get("averageShortPrice", "0")), value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Cooldown:
|
|
83
|
+
duration_seconds: int
|
|
84
|
+
available_at_unix_seconds: int
|
|
85
|
+
remaining_seconds: int
|
|
86
|
+
active: bool
|
|
87
|
+
raw: dict[str, Any] = field(repr=False)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_dict(cls, value: dict[str, Any]) -> Cooldown:
|
|
91
|
+
return cls(int(value.get("durationSeconds", 0)),
|
|
92
|
+
int(value.get("availableAtUnixSeconds", 0)),
|
|
93
|
+
int(value.get("remainingSeconds", 0)), bool(value.get("active", False)), value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class TradeCooldowns:
|
|
98
|
+
server_now_unix_seconds: int
|
|
99
|
+
server_clock_synchronized: bool
|
|
100
|
+
buy: Cooldown
|
|
101
|
+
short_trade: Cooldown
|
|
102
|
+
raw: dict[str, Any] = field(repr=False)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_dict(cls, value: dict[str, Any]) -> TradeCooldowns:
|
|
106
|
+
return cls(int(value.get("serverNowUnixSeconds", 0)),
|
|
107
|
+
bool(value.get("serverClockSynchronized", False)),
|
|
108
|
+
Cooldown.from_dict(value.get("buy", {})),
|
|
109
|
+
Cooldown.from_dict(value.get("shortTrade", {})), value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class AutoAction:
|
|
114
|
+
slot_index: int
|
|
115
|
+
stock_id: str
|
|
116
|
+
action_type: str
|
|
117
|
+
condition: str
|
|
118
|
+
target_price: float
|
|
119
|
+
amount_percentage: int
|
|
120
|
+
enabled: bool
|
|
121
|
+
cooldown_until_unix_seconds: int
|
|
122
|
+
cooldown_duration_seconds: int
|
|
123
|
+
cooldown_remaining_seconds: int
|
|
124
|
+
on_cooldown: bool
|
|
125
|
+
raw: dict[str, Any] = field(repr=False)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_dict(cls, value: dict[str, Any]) -> AutoAction:
|
|
129
|
+
return cls(int(value.get("slotIndex", -1)), str(value.get("stockId", "")),
|
|
130
|
+
str(value.get("actionType", "")), str(value.get("condition", "")),
|
|
131
|
+
float(value.get("targetPrice", 0)), int(value.get("amountPercentage", 0)),
|
|
132
|
+
bool(value.get("enabled", False)), int(value.get("cooldownUntilUnixSeconds", 0)),
|
|
133
|
+
int(value.get("cooldownDurationSeconds", 0)),
|
|
134
|
+
int(value.get("cooldownRemainingSeconds", 0)),
|
|
135
|
+
bool(value.get("onCooldown", False)), value)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class AutoActions:
|
|
140
|
+
ready: bool
|
|
141
|
+
unlocked: bool
|
|
142
|
+
active: bool
|
|
143
|
+
slot_limit: int
|
|
144
|
+
configured_count: int
|
|
145
|
+
can_add_action: bool
|
|
146
|
+
cooldown_duration_seconds: int
|
|
147
|
+
actions: list[AutoAction]
|
|
148
|
+
raw: dict[str, Any] = field(repr=False)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_dict(cls, value: dict[str, Any]) -> AutoActions:
|
|
152
|
+
return cls(bool(value.get("ready", False)), bool(value.get("unlocked", False)),
|
|
153
|
+
bool(value.get("active", False)), int(value.get("slotLimit", 0)),
|
|
154
|
+
int(value.get("configuredCount", 0)), bool(value.get("canAddAction", False)),
|
|
155
|
+
int(value.get("cooldownDurationSeconds", 0)),
|
|
156
|
+
[AutoAction.from_dict(item) for item in value.get("actions", [])], value)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class Snapshot:
|
|
161
|
+
ready: bool
|
|
162
|
+
server_tick: int
|
|
163
|
+
cash: str
|
|
164
|
+
level: int
|
|
165
|
+
stocks: list[Stock]
|
|
166
|
+
positions: list[Position]
|
|
167
|
+
cooldowns: TradeCooldowns
|
|
168
|
+
auto_actions: AutoActions
|
|
169
|
+
raw: dict[str, Any] = field(repr=False)
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_dict(cls, value: dict[str, Any]) -> Snapshot:
|
|
173
|
+
return cls(bool(value.get("ready", False)), int(value.get("serverTick", 0)),
|
|
174
|
+
str(value.get("cash", "0")), int(value.get("level", 0)),
|
|
175
|
+
[Stock.from_dict(item) for item in value.get("stocks", [])],
|
|
176
|
+
[Position.from_dict(item) for item in value.get("positions", [])],
|
|
177
|
+
TradeCooldowns.from_dict(value.get("cooldowns", {})),
|
|
178
|
+
AutoActions.from_dict(value.get("autoActions", {})), value)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class TradeReceipt:
|
|
183
|
+
status: str
|
|
184
|
+
raw: dict[str, Any] = field(repr=False)
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def from_dict(cls, value: dict[str, Any]) -> TradeReceipt:
|
|
188
|
+
return cls(str(value.get("status", "")), value)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Protocol constants and errors shared by the Python bridge client."""
|
|
2
|
+
|
|
3
|
+
MAX_FRAME_BYTES = 16 * 1024
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BridgeError(RuntimeError):
|
|
7
|
+
"""A transport, protocol, or game-side API error."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, code: str, message: str) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.code = code
|
|
12
|
+
self.message = message
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _ReaderStopped(Exception):
|
|
16
|
+
pass
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: screenstocks-bridge
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for the local Screen Stocks BepInEx bridge
|
|
5
|
+
License-Expression: MIT-0
|
|
6
|
+
Project-URL: Homepage, https://github.com/fuubox/screen-stocks-bridge
|
|
7
|
+
Project-URL: Documentation, https://github.com/fuubox/screen-stocks-bridge#readme
|
|
8
|
+
Project-URL: Repository, https://github.com/fuubox/screen-stocks-bridge
|
|
9
|
+
Project-URL: Issues, https://github.com/fuubox/screen-stocks-bridge/issues
|
|
10
|
+
Keywords: screen-stocks,bepinex,game-modding,bridge
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Screen Stocks Python Bridge
|
|
25
|
+
|
|
26
|
+
The `screenstocks-bridge` package is a Python client for the local API exposed
|
|
27
|
+
by the Screen Stocks Bridge BepInEx plugin. It requires the plugin to be
|
|
28
|
+
installed in the game and the game to be running. The package itself does not
|
|
29
|
+
install or contact the game backend; it connects to the plugin on loopback.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
python -m pip install screenstocks-bridge
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Install the matching BepInEx plugin from the
|
|
38
|
+
[GitHub releases](https://github.com/fuubox/screen-stocks-bridge/releases),
|
|
39
|
+
then launch Screen Stocks. The default bridge address is `127.0.0.1:48721`.
|
|
40
|
+
Read the token from `BepInEx/config/screenstocks.bridge.cfg` or the BepInEx
|
|
41
|
+
log.
|
|
42
|
+
|
|
43
|
+
## Connect
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from screenstocks_bridge import BridgeClient
|
|
47
|
+
|
|
48
|
+
with BridgeClient(token="YOUR_TOKEN") as bridge:
|
|
49
|
+
state = bridge.snapshot()
|
|
50
|
+
print(state["ready"], state["cash"])
|
|
51
|
+
print(bridge.upgrades())
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The client supports state and activity reads, trades, upgrade purchases, and
|
|
55
|
+
auto-action controls. See the
|
|
56
|
+
[bridge usage guide](https://github.com/fuubox/screen-stocks-bridge/blob/main/docs/bridge-usage.md)
|
|
57
|
+
for the full API and its online behavior.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/screenstocks_bridge/__init__.py
|
|
5
|
+
src/screenstocks_bridge/client.py
|
|
6
|
+
src/screenstocks_bridge/models.py
|
|
7
|
+
src/screenstocks_bridge/protocol.py
|
|
8
|
+
src/screenstocks_bridge.egg-info/PKG-INFO
|
|
9
|
+
src/screenstocks_bridge.egg-info/SOURCES.txt
|
|
10
|
+
src/screenstocks_bridge.egg-info/dependency_links.txt
|
|
11
|
+
src/screenstocks_bridge.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
screenstocks_bridge
|