raidlock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/mcp/tools.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from ..core import Board, repos
|
|
4
|
+
from ..core.errors import IdentityError, Precondition
|
|
5
|
+
from ..core.transcript import _clean
|
|
6
|
+
|
|
7
|
+
ID = {"type": "string"}
|
|
8
|
+
FENCE = {"type": "integer"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _schema(required, **props):
|
|
12
|
+
return {"type": "object", "properties": props, "required": list(required)}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TOOLS = [
|
|
16
|
+
{
|
|
17
|
+
"name": "claimable",
|
|
18
|
+
"description": "Cards you can claim now, and why others are blocked.",
|
|
19
|
+
"inputSchema": _schema(()),
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "claim",
|
|
23
|
+
"description": "Take a card: lock its paths. Returns the fence.",
|
|
24
|
+
"inputSchema": _schema(("id",), id=ID),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "heartbeat",
|
|
28
|
+
"description": "Renew your lease on a card.",
|
|
29
|
+
"inputSchema": _schema(("id",), id=ID, fence=FENCE),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "note",
|
|
33
|
+
"description": "Add a one-line progress note; renews the lease.",
|
|
34
|
+
"inputSchema": _schema(("id", "text"), id=ID, text={"type": "string"}, fence=FENCE),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "lock",
|
|
38
|
+
"description": "Add lock paths (<repo>/<path>, comma-separated) to your card.",
|
|
39
|
+
"inputSchema": _schema(("id", "paths"), id=ID, paths={"type": "string"}, fence=FENCE),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "move",
|
|
43
|
+
"description": "Move your card to review or blocked. Only the lead closes cards.",
|
|
44
|
+
"inputSchema": _schema(
|
|
45
|
+
("id", "lane"), id=ID, lane={"enum": ["review", "blocked"]}, note={"type": "string"}, fence=FENCE
|
|
46
|
+
),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "msg",
|
|
50
|
+
"description": "Message an agent; card ties it to a task.",
|
|
51
|
+
"inputSchema": _schema(("to", "text"), to={"type": "string"}, text={"type": "string"}, card=ID),
|
|
52
|
+
},
|
|
53
|
+
{"name": "inbox", "description": "Read your new messages.", "inputSchema": _schema(())},
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _annotations(name):
|
|
58
|
+
if name == "claimable":
|
|
59
|
+
return {"readOnlyHint": True, "openWorldHint": False}
|
|
60
|
+
return {
|
|
61
|
+
"readOnlyHint": False,
|
|
62
|
+
"destructiveHint": False,
|
|
63
|
+
"idempotentHint": name == "heartbeat",
|
|
64
|
+
"openWorldHint": False,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
for tool in TOOLS:
|
|
69
|
+
tool["annotations"] = _annotations(tool["name"])
|
|
70
|
+
SPECS = {t["name"]: t["inputSchema"] for t in TOOLS}
|
|
71
|
+
LEAD_REFUSAL = "the MCP server never acts as the lead; the lead uses the raidlock CLI"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Session:
|
|
75
|
+
def __init__(self, board_path=None, env=None):
|
|
76
|
+
base = dict(os.environ if env is None else env)
|
|
77
|
+
base.pop("RAIDLOCK_ROLE", None)
|
|
78
|
+
self.env = base
|
|
79
|
+
self.board_path = board_path
|
|
80
|
+
self.fences = {}
|
|
81
|
+
|
|
82
|
+
def board(self):
|
|
83
|
+
return Board.open(self.board_path, env=self.env)
|
|
84
|
+
|
|
85
|
+
def caller(self, board, card=None, fence=None):
|
|
86
|
+
if fence is None and card:
|
|
87
|
+
fence = self.fences.get(card)
|
|
88
|
+
c = board.caller(fence=fence, env=self.env)
|
|
89
|
+
if c.is_lead or (c.name and c.name == board.lead):
|
|
90
|
+
raise IdentityError(LEAD_REFUSAL, agent=c.name, hint="set RAIDLOCK_AGENT and RAIDLOCK_TOKEN to a worker")
|
|
91
|
+
return c
|
|
92
|
+
|
|
93
|
+
def call(self, name, args):
|
|
94
|
+
check_args(name, args)
|
|
95
|
+
board = self.board()
|
|
96
|
+
return _clean(getattr(self, "t_" + name)(board, args))
|
|
97
|
+
|
|
98
|
+
def t_claimable(self, b, a):
|
|
99
|
+
c = self.caller(b)
|
|
100
|
+
ready, waiting = [], []
|
|
101
|
+
for cid, v in sorted(b.claimable(agent=c.name or None).items()):
|
|
102
|
+
if v["ok"]:
|
|
103
|
+
ready.append({"id": cid, "title": v["title"], **({"takeover": v["takeover"]} if v["takeover"] else {})})
|
|
104
|
+
else:
|
|
105
|
+
waiting.append({"id": cid, "why": "; ".join(v["reasons"])})
|
|
106
|
+
return {"ready": ready, "blocked": waiting}
|
|
107
|
+
|
|
108
|
+
def t_claim(self, b, a):
|
|
109
|
+
r = b.claim(a["id"], self.caller(b), checkout=os.getcwd() if repos.find_root(os.getcwd()) else None)
|
|
110
|
+
self.fences[a["id"]] = r["fence"]
|
|
111
|
+
return {"card": r["card"], "fence": r["fence"], "expires": r["expires"], "paths": r["paths"]}
|
|
112
|
+
|
|
113
|
+
def t_heartbeat(self, b, a):
|
|
114
|
+
r = b.heartbeat(a["id"], self.caller(b, a["id"], a.get("fence")))
|
|
115
|
+
return {"card": r["card"], "expires": r["expires"], **({"resumed": True} if r.get("resumed") else {})}
|
|
116
|
+
|
|
117
|
+
def t_note(self, b, a):
|
|
118
|
+
b.note(a["id"], self.caller(b, a["id"], a.get("fence")), a["text"])
|
|
119
|
+
return {"card": a["id"], "noted": True}
|
|
120
|
+
|
|
121
|
+
def t_lock(self, b, a):
|
|
122
|
+
r = b.lock_paths(a["id"], self.caller(b, a["id"], a.get("fence")), a["paths"])
|
|
123
|
+
return {"card": r["card"], "locks": r["locks"]}
|
|
124
|
+
|
|
125
|
+
def t_move(self, b, a):
|
|
126
|
+
if a["lane"] not in ("review", "blocked"):
|
|
127
|
+
raise Precondition("move takes review or blocked", lane=a["lane"])
|
|
128
|
+
r = b.move(a["id"], a["lane"], self.caller(b, a["id"], a.get("fence")), note=a.get("note"))
|
|
129
|
+
return {"card": r["card"], "lane": r["lane"]}
|
|
130
|
+
|
|
131
|
+
def t_msg(self, b, a):
|
|
132
|
+
m = b.send(self.caller(b), a["to"], a["text"], task=a.get("card") or None)
|
|
133
|
+
out = {"to": a["to"], "status": m.get("status", "")}
|
|
134
|
+
if m.get("dropped"):
|
|
135
|
+
out["dropped"] = True
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
def t_inbox(self, b, a):
|
|
139
|
+
r = b.inbox(self.caller(b))
|
|
140
|
+
msgs = [
|
|
141
|
+
{
|
|
142
|
+
k: v
|
|
143
|
+
for k, v in (
|
|
144
|
+
("from", m.get("from")),
|
|
145
|
+
("card", m.get("task")),
|
|
146
|
+
("sent", m.get("sent")),
|
|
147
|
+
("text", m.get("text")),
|
|
148
|
+
)
|
|
149
|
+
if v
|
|
150
|
+
}
|
|
151
|
+
for m in r["messages"]
|
|
152
|
+
]
|
|
153
|
+
out = {"messages": msgs}
|
|
154
|
+
if r.get("queued"):
|
|
155
|
+
out["queued"] = r["queued"]
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def check_args(name, args):
|
|
160
|
+
spec = SPECS[name]
|
|
161
|
+
if not isinstance(args, dict):
|
|
162
|
+
raise Precondition("arguments must be an object")
|
|
163
|
+
extra = sorted(set(args) - set(spec["properties"]))
|
|
164
|
+
if extra:
|
|
165
|
+
raise Precondition(f"unknown argument(s): {', '.join(extra)}", tool=name)
|
|
166
|
+
for key in spec["required"]:
|
|
167
|
+
if key not in args:
|
|
168
|
+
raise Precondition(f"missing argument: {key}", tool=name)
|
|
169
|
+
for key, value in args.items():
|
|
170
|
+
prop = spec["properties"][key]
|
|
171
|
+
if "enum" in prop:
|
|
172
|
+
if value not in prop["enum"]:
|
|
173
|
+
raise Precondition(f"{key} must be one of {', '.join(prop['enum'])}", tool=name)
|
|
174
|
+
elif prop["type"] == "integer":
|
|
175
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
176
|
+
raise Precondition(f"{key} must be an integer", tool=name)
|
|
177
|
+
elif not isinstance(value, str) or not value.strip():
|
|
178
|
+
raise Precondition(f"{key} must be a non-empty string", tool=name)
|
raidlock/notify.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import datetime
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from xml.sax.saxutils import escape
|
|
8
|
+
|
|
9
|
+
from .core import db
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def send_notification(title, body):
|
|
13
|
+
if sys.platform == "darwin":
|
|
14
|
+
executable = shutil.which("terminal-notifier")
|
|
15
|
+
if executable:
|
|
16
|
+
command = [executable, "-title", title, "-message", body]
|
|
17
|
+
else:
|
|
18
|
+
executable = shutil.which("osascript")
|
|
19
|
+
if not executable:
|
|
20
|
+
return False
|
|
21
|
+
script = "on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run"
|
|
22
|
+
command = [executable, "-e", script, title, body]
|
|
23
|
+
elif sys.platform == "win32":
|
|
24
|
+
executable = shutil.which("powershell") or shutil.which("pwsh")
|
|
25
|
+
if not executable:
|
|
26
|
+
return False
|
|
27
|
+
xml = (
|
|
28
|
+
'<toast><visual><binding template="ToastGeneric"><text>'
|
|
29
|
+
+ escape(title)
|
|
30
|
+
+ "</text><text>"
|
|
31
|
+
+ escape(body)
|
|
32
|
+
+ "</text></binding></visual></toast>"
|
|
33
|
+
)
|
|
34
|
+
payload = base64.b64encode(xml.encode("utf-8")).decode("ascii")
|
|
35
|
+
script = (
|
|
36
|
+
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, "
|
|
37
|
+
"ContentType = WindowsRuntime] > $null; "
|
|
38
|
+
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, "
|
|
39
|
+
"ContentType = WindowsRuntime] > $null; "
|
|
40
|
+
"$xml = New-Object Windows.Data.Xml.Dom.XmlDocument; "
|
|
41
|
+
f'$xml.LoadXml([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("{payload}"))); '
|
|
42
|
+
"$toast = [Windows.UI.Notifications.ToastNotification]::new($xml); "
|
|
43
|
+
'[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Raidlock").Show($toast)'
|
|
44
|
+
)
|
|
45
|
+
encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii")
|
|
46
|
+
command = [executable, "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]
|
|
47
|
+
elif sys.platform.startswith("linux"):
|
|
48
|
+
executable = shutil.which("notify-send")
|
|
49
|
+
if not executable:
|
|
50
|
+
return False
|
|
51
|
+
command = [executable, "--", title, body]
|
|
52
|
+
else:
|
|
53
|
+
return False
|
|
54
|
+
try:
|
|
55
|
+
return subprocess.run(command, capture_output=True, timeout=10, check=False).returncode == 0
|
|
56
|
+
except (OSError, subprocess.SubprocessError):
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_time(value):
|
|
61
|
+
try:
|
|
62
|
+
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
63
|
+
return parsed.replace(tzinfo=datetime.UTC) if parsed.tzinfo is None else parsed
|
|
64
|
+
except (AttributeError, TypeError, ValueError):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Watcher:
|
|
69
|
+
def __init__(self, board, notify=None, debounce=60):
|
|
70
|
+
self.board = board
|
|
71
|
+
self.notify = notify or send_notification
|
|
72
|
+
self.debounce = debounce
|
|
73
|
+
self.version = None
|
|
74
|
+
self.cursor = 0
|
|
75
|
+
self.cards = []
|
|
76
|
+
self.leases = {}
|
|
77
|
+
self.active = set()
|
|
78
|
+
self.sent = {}
|
|
79
|
+
|
|
80
|
+
def poll(self, now=None, monotonic=None):
|
|
81
|
+
now = now or datetime.datetime.now(datetime.UTC)
|
|
82
|
+
tick = time.monotonic() if monotonic is None else monotonic
|
|
83
|
+
version = self.board.version()
|
|
84
|
+
if version != self.version:
|
|
85
|
+
self.cards = self.board.cards()
|
|
86
|
+
self.leases = self.board.leases()
|
|
87
|
+
self.version = version
|
|
88
|
+
events = []
|
|
89
|
+
active = set()
|
|
90
|
+
for card in self.cards:
|
|
91
|
+
cid = card["id"]
|
|
92
|
+
if card["lane"] == "review":
|
|
93
|
+
key = ("review", cid)
|
|
94
|
+
active.add(key)
|
|
95
|
+
events.append((key, "Card entered review", f"{cid}: {card['title']}"))
|
|
96
|
+
for cid, lease in self.leases.items():
|
|
97
|
+
expires = _parse_time(lease.get("expires"))
|
|
98
|
+
if expires and 0 < (expires - now).total_seconds() < 300:
|
|
99
|
+
key = ("lease", cid, lease.get("fence"), lease.get("expires"))
|
|
100
|
+
active.add(key)
|
|
101
|
+
events.append((key, "Lease expires soon", f"{cid}: {lease.get('agent', '')} (under 5 minutes)"))
|
|
102
|
+
for name, status in self.board.statuses().items():
|
|
103
|
+
if status["status"] == "stalled":
|
|
104
|
+
key = ("stalled", name, status.get("card", ""))
|
|
105
|
+
active.add(key)
|
|
106
|
+
events.append((key, "Agent stalled", f"{name}: {status.get('card', '')}"))
|
|
107
|
+
for record in self.board.transcript.read(since=self.cursor):
|
|
108
|
+
self.cursor = max(self.cursor, record["seq"])
|
|
109
|
+
if record.get("kind") == "msg" and record.get("to") == self.board.lead:
|
|
110
|
+
key = ("message", record.get("messageId", record["seq"]))
|
|
111
|
+
body = "\n".join(p.get("text", "") for p in record.get("parts", []) if p.get("kind") == "text")
|
|
112
|
+
events.append((key, "Message to lead", body))
|
|
113
|
+
delivered = 0
|
|
114
|
+
for key, title, body in events:
|
|
115
|
+
if key in self.active or tick - self.sent.get(key, float("-inf")) < self.debounce:
|
|
116
|
+
continue
|
|
117
|
+
self.sent[key] = tick
|
|
118
|
+
delivered += bool(self.notify(f"Raidlock: {title}", body))
|
|
119
|
+
self.active = active
|
|
120
|
+
self.sent = {key: value for key, value in self.sent.items() if tick - value < self.debounce}
|
|
121
|
+
return delivered
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _setup(parser):
|
|
125
|
+
parser.add_argument("--once", action="store_true", help="Check current events once and exit")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _handle(ctx, args):
|
|
129
|
+
watcher = Watcher(ctx.board)
|
|
130
|
+
count = 0
|
|
131
|
+
try:
|
|
132
|
+
while True:
|
|
133
|
+
count += watcher.poll()
|
|
134
|
+
if args.once:
|
|
135
|
+
break
|
|
136
|
+
db.close_all()
|
|
137
|
+
time.sleep(2)
|
|
138
|
+
except KeyboardInterrupt:
|
|
139
|
+
pass
|
|
140
|
+
return {"notifications": count}, ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def register_cli(add_command):
|
|
144
|
+
add_command("notify", "watch the board for desktop notifications", _setup, _handle)
|
raidlock/party.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Raid-theme party roles, boss board and loot, derived from recorded card activity on every read. Display only:
|
|
2
|
+
nothing is stored and nothing is granted. Item names are original to raidlock.
|
|
3
|
+
|
|
4
|
+
Tank counts leases held now on blocked cards plus claimed cards that were blocked or that other cards depend on. Healer
|
|
5
|
+
counts reviews and passing verify runs on other agents' cards plus moving someone else's card out of blocked. DPS counts
|
|
6
|
+
finished cards the agent owns and leases held now on cards that are not blocked. The highest score wins; ties go to DPS,
|
|
7
|
+
then Tank, then Healer. Agents with no activity get no role. Cancelled cards never count.
|
|
8
|
+
|
|
9
|
+
A boss is a card with BOSS_DEPENDENTS or more transitive dependents, or one the lead marked with edit --boss on. Its HP
|
|
10
|
+
is the share of the boss and its dependents still open; it is slain when all of them are done. Every done card drops one
|
|
11
|
+
item for its owner; the rarity comes from dependents, changes-requested reviews, a first-try verify pass and recorded
|
|
12
|
+
run time, and the name from a hash of the card id, so the same card always drops the same item.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
import hashlib
|
|
17
|
+
|
|
18
|
+
from .core import clock
|
|
19
|
+
from .web.replay import data_of, history
|
|
20
|
+
|
|
21
|
+
ROLES = ("dps", "tank", "healer")
|
|
22
|
+
BOSS_DEPENDENTS = 3
|
|
23
|
+
RARITIES = ("Common", "Uncommon", "Rare", "Epic", "Legendary")
|
|
24
|
+
ADJECTIVES = (
|
|
25
|
+
("Worn", "Patched", "Sturdy", "Plain"),
|
|
26
|
+
("Polished", "Tempered", "Keen", "Lucky"),
|
|
27
|
+
("Runed", "Moonlit", "Gilded", "Whispering"),
|
|
28
|
+
("Stormbound", "Emberforged", "Starlit", "Thornwoven"),
|
|
29
|
+
("Sunforged", "Everflame", "Worldroot", "Firstlight"),
|
|
30
|
+
)
|
|
31
|
+
NOUNS = (
|
|
32
|
+
"Lantern",
|
|
33
|
+
"Whetstone",
|
|
34
|
+
"Cloak",
|
|
35
|
+
"Compass",
|
|
36
|
+
"Quill",
|
|
37
|
+
"Buckler",
|
|
38
|
+
"Satchel",
|
|
39
|
+
"Ring",
|
|
40
|
+
"Spyglass",
|
|
41
|
+
"Hammer",
|
|
42
|
+
"Amulet",
|
|
43
|
+
"Boots",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def live(cards):
|
|
48
|
+
return [c for c in cards if c.get("lane") != "cancelled"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def dependents(cards):
|
|
52
|
+
ids = {c["id"] for c in cards}
|
|
53
|
+
back = {cid: set() for cid in ids}
|
|
54
|
+
for c in cards:
|
|
55
|
+
for dep in c.get("depends", []):
|
|
56
|
+
if dep in back and dep != c["id"]:
|
|
57
|
+
back[dep].add(c["id"])
|
|
58
|
+
out = {}
|
|
59
|
+
for cid in sorted(ids):
|
|
60
|
+
seen, stack = set(), sorted(back[cid])
|
|
61
|
+
while stack:
|
|
62
|
+
nxt = stack.pop()
|
|
63
|
+
if nxt == cid or nxt in seen:
|
|
64
|
+
continue
|
|
65
|
+
seen.add(nxt)
|
|
66
|
+
stack.extend(sorted(back[nxt]))
|
|
67
|
+
out[cid] = seen
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _claimers(card):
|
|
72
|
+
out = set()
|
|
73
|
+
for n in card.get("notes", []):
|
|
74
|
+
words = str(n.get("text", "")).split()
|
|
75
|
+
if len(words) >= 3 and words[:2] == ["claimed", "by"]:
|
|
76
|
+
out.add(words[2])
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _unblockers(card):
|
|
81
|
+
out, lane = [], ""
|
|
82
|
+
for n in card.get("notes", []):
|
|
83
|
+
text = str(n.get("text", ""))
|
|
84
|
+
if not text.startswith("-> "):
|
|
85
|
+
continue
|
|
86
|
+
nxt = text[3:].split(" ", 1)[0]
|
|
87
|
+
if lane == "blocked" and nxt != "blocked":
|
|
88
|
+
out.append(n.get("by", ""))
|
|
89
|
+
lane = nxt
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def roles(board):
|
|
94
|
+
cards = live(board.cards())
|
|
95
|
+
names = sorted(board.agents())
|
|
96
|
+
score = {n: dict.fromkeys(ROLES, 0) for n in names}
|
|
97
|
+
deps = dependents(cards)
|
|
98
|
+
|
|
99
|
+
def add(name, role):
|
|
100
|
+
if name in score:
|
|
101
|
+
score[name][role] += 1
|
|
102
|
+
|
|
103
|
+
lanes = {c["id"]: c.get("lane") for c in cards}
|
|
104
|
+
for lease in sorted(board.leases().values(), key=lambda x: x.get("card", "")):
|
|
105
|
+
add(lease.get("agent", ""), "tank" if lanes.get(lease.get("card")) == "blocked" else "dps")
|
|
106
|
+
for c in cards:
|
|
107
|
+
owner = c.get("owner", "")
|
|
108
|
+
hard = deps.get(c["id"]) or any(str(n.get("text", "")).startswith("-> blocked") for n in c.get("notes", []))
|
|
109
|
+
if hard:
|
|
110
|
+
for name in _claimers(c):
|
|
111
|
+
add(name, "tank")
|
|
112
|
+
if c.get("lane") == "done" and owner:
|
|
113
|
+
add(owner, "dps")
|
|
114
|
+
for r in c.get("reviews", []):
|
|
115
|
+
if r.get("verdict") in ("approve", "changes") and r.get("by") != owner:
|
|
116
|
+
add(r.get("by", ""), "healer")
|
|
117
|
+
for v in c.get("verifications", []):
|
|
118
|
+
if v.get("passed") and v.get("by") != owner:
|
|
119
|
+
add(v.get("by", ""), "healer")
|
|
120
|
+
for name in _unblockers(c):
|
|
121
|
+
if name != owner:
|
|
122
|
+
add(name, "healer")
|
|
123
|
+
out = {}
|
|
124
|
+
for name in names:
|
|
125
|
+
s = score[name]
|
|
126
|
+
best = max(ROLES, key=lambda r: (s[r], -ROLES.index(r)))
|
|
127
|
+
out[name] = {"role": best if s[best] else "", **s}
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _done_at(board):
|
|
132
|
+
out = {}
|
|
133
|
+
for r in history(board)[0]:
|
|
134
|
+
if r.get("kind") == "status" and data_of(r).get("lane") == "done" and r.get("taskId"):
|
|
135
|
+
out[r["taskId"]] = r.get("ts", "")
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def bosses(cards, deps, done_at):
|
|
140
|
+
by_id = {c["id"]: c for c in cards}
|
|
141
|
+
out = []
|
|
142
|
+
for cid in sorted(by_id):
|
|
143
|
+
under = deps.get(cid, set())
|
|
144
|
+
if len(under) < BOSS_DEPENDENTS and not by_id[cid].get("boss"):
|
|
145
|
+
continue
|
|
146
|
+
group = [cid, *sorted(under)]
|
|
147
|
+
still = [x for x in group if by_id[x].get("lane") != "done"]
|
|
148
|
+
slain = not still
|
|
149
|
+
out.append(
|
|
150
|
+
{
|
|
151
|
+
"id": cid,
|
|
152
|
+
"title": by_id[cid].get("title", cid),
|
|
153
|
+
"dependents": len(under),
|
|
154
|
+
"total": len(group),
|
|
155
|
+
"open": len(still),
|
|
156
|
+
"hp": round(100 * len(still) / len(group)),
|
|
157
|
+
"slain": slain,
|
|
158
|
+
"slain_at": max(done_at.get(x) or by_id[x].get("updated", "") for x in group) if slain else "",
|
|
159
|
+
"final": by_id[max(group, key=lambda x: done_at.get(x) or by_id[x].get("updated", ""))].get("owner", "")
|
|
160
|
+
if slain
|
|
161
|
+
else "",
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
alive = sorted((b for b in out if not b["slain"]), key=lambda b: (-b["hp"], b["id"]))
|
|
165
|
+
dead = sorted((b for b in out if b["slain"]), key=lambda b: (b["slain_at"], b["id"]), reverse=True)
|
|
166
|
+
return alive + dead
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def tier(card, dependents):
|
|
170
|
+
changes = sum(1 for r in card.get("reviews", []) if r.get("verdict") == "changes")
|
|
171
|
+
runs = card.get("verifications") or []
|
|
172
|
+
first_pass = bool(runs) and bool(runs[0].get("passed"))
|
|
173
|
+
hours = sum(int(r.get("duration_ms") or 0) for r in card.get("costs", [])) / 3600000.0
|
|
174
|
+
score = min(dependents, 4) + min(changes, 2) + int(first_pass) + (2 if hours >= 4 else 1 if hours >= 1 else 0)
|
|
175
|
+
return min(4, (score + 1) // 2)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def item_name(card_id, rank):
|
|
179
|
+
digest = hashlib.sha256(f"{card_id}\n{rank}".encode()).digest()
|
|
180
|
+
words = ADJECTIVES[rank]
|
|
181
|
+
return f"{words[digest[0] % len(words)]} {NOUNS[digest[1] % len(NOUNS)]}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def loot(cards, deps, done_at):
|
|
185
|
+
bags = {}
|
|
186
|
+
for c in cards:
|
|
187
|
+
if c.get("lane") != "done" or not c.get("owner"):
|
|
188
|
+
continue
|
|
189
|
+
rank = tier(c, len(deps.get(c["id"], ())))
|
|
190
|
+
bags.setdefault(c["owner"], []).append(
|
|
191
|
+
{
|
|
192
|
+
"card": c["id"],
|
|
193
|
+
"title": c.get("title", c["id"]),
|
|
194
|
+
"item": item_name(c["id"], rank),
|
|
195
|
+
"rarity": RARITIES[rank],
|
|
196
|
+
"tier": rank,
|
|
197
|
+
"at": done_at.get(c["id"]) or c.get("updated", ""),
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
out = []
|
|
201
|
+
for name, items in sorted(bags.items()):
|
|
202
|
+
out.append({"agent": name, "items": sorted(items, key=lambda i: (i["at"], i["card"]))})
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def payload(board, query):
|
|
207
|
+
cards = live(board.cards())
|
|
208
|
+
deps = dependents(cards)
|
|
209
|
+
done_at = _done_at(board)
|
|
210
|
+
return {
|
|
211
|
+
"bosses": bosses(cards, deps, done_at),
|
|
212
|
+
"bags": loot(cards, deps, done_at),
|
|
213
|
+
"boss_dependents": BOSS_DEPENDENTS,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def slain_bosses(cards):
|
|
218
|
+
cards = live(cards)
|
|
219
|
+
return [
|
|
220
|
+
{k: b[k] for k in ("id", "title", "total", "final", "slain_at")}
|
|
221
|
+
for b in bosses(cards, dependents(cards), {})
|
|
222
|
+
if b["slain"]
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def streak(cards, today=None):
|
|
227
|
+
days = set()
|
|
228
|
+
for c in cards:
|
|
229
|
+
when = clock.parse(c.get("updated", "")) if c.get("lane") == "done" else None
|
|
230
|
+
if when:
|
|
231
|
+
days.add(when.astimezone().date())
|
|
232
|
+
day = today or datetime.datetime.now().astimezone().date()
|
|
233
|
+
if day not in days:
|
|
234
|
+
day -= datetime.timedelta(days=1)
|
|
235
|
+
count = 0
|
|
236
|
+
while day in days:
|
|
237
|
+
count += 1
|
|
238
|
+
day -= datetime.timedelta(days=1)
|
|
239
|
+
return count
|