forgecommandline 0.8.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.
- forgecli/__init__.py +2 -0
- forgecli/ads.py +322 -0
- forgecli/agent.py +1093 -0
- forgecli/art.py +574 -0
- forgecli/attach.py +246 -0
- forgecli/brief.py +215 -0
- forgecli/browsercheck.py +99 -0
- forgecli/checkpoints.py +81 -0
- forgecli/city.py +833 -0
- forgecli/cli.py +1668 -0
- forgecli/config.py +194 -0
- forgecli/courier.py +177 -0
- forgecli/courier_data.py +164 -0
- forgecli/courier_hd.py +431 -0
- forgecli/courier_hd_data.bin +0 -0
- forgecli/courier_hd_data.py +19 -0
- forgecli/ghlogin.py +76 -0
- forgecli/github.py +54 -0
- forgecli/guards.py +403 -0
- forgecli/hooks.py +50 -0
- forgecli/httpapi.py +434 -0
- forgecli/images.py +110 -0
- forgecli/keys.py +152 -0
- forgecli/keyvault.py +320 -0
- forgecli/mcp.py +674 -0
- forgecli/memory.py +42 -0
- forgecli/openapi.py +331 -0
- forgecli/parallel.py +308 -0
- forgecli/prices.py +72 -0
- forgecli/procs.py +144 -0
- forgecli/providers.py +758 -0
- forgecli/quality.py +192 -0
- forgecli/repomap.py +78 -0
- forgecli/roster.py +227 -0
- forgecli/safety.py +93 -0
- forgecli/sandbox.py +83 -0
- forgecli/sessions.py +97 -0
- forgecli/skills.py +446 -0
- forgecli/tools.py +1184 -0
- forgecli/ui.py +860 -0
- forgecli/worktrees.py +79 -0
- forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
- forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
- forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
- forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
- forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
- forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
- forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
- forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
- forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
- forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
- forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
- forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
- forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
- forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
- forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
- forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
- forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
- forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
- forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
- forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
- forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
- forgecommandline-0.8.0.dist-info/METADATA +357 -0
- forgecommandline-0.8.0.dist-info/RECORD +113 -0
- forgecommandline-0.8.0.dist-info/WHEEL +5 -0
- forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
- forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/city.py
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
"""The pixel city: what Forge is doing, acted out by tiny tiger-cub workers on a neon street.
|
|
2
|
+
|
|
3
|
+
There are no spinners here. Every animation is driven by a real state change reported by the
|
|
4
|
+
agent (ui.py calls City.set when a model call starts, a tool runs, a provider fails, a turn ends):
|
|
5
|
+
|
|
6
|
+
idle sits on the bench under the lamp, tail swishing
|
|
7
|
+
queued waits by the depot, tapping a foot, "..." over its head
|
|
8
|
+
thinking at the desk, thought bubble; every few seconds gets up and paces
|
|
9
|
+
coding at the desk typing, code scrolling up the monitor
|
|
10
|
+
processing pushes a wheelbarrow of glowing parcels from the depot to the FORGE door and back
|
|
11
|
+
researching walks to the archive, pulls a document, reads it under a magnifier
|
|
12
|
+
delegating hands parcels to helper cubs who wheel them down the street (several at once)
|
|
13
|
+
waiting on the bench with a coffee while rate limits pass, countdown under the sign
|
|
14
|
+
completed jumps and cheers, confetti
|
|
15
|
+
failed drops the parcels, red "!"
|
|
16
|
+
cancelled holds up a paw, STOP
|
|
17
|
+
|
|
18
|
+
Sprites are small string sheets, drawn by code, so nothing is generated per task. The street
|
|
19
|
+
behind them is cached and redrawn only twice a second. Timing comes from the clock alone, so
|
|
20
|
+
the same moment always draws the same frame. Rendering happens on the UI thread from plain
|
|
21
|
+
attributes, so it never blocks the agent. FORGE_MOTION=reduce holds still poses (no walking,
|
|
22
|
+
flicker or confetti). Terminals without truecolor get a one-line text version.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
from . import art
|
|
32
|
+
from .art import (AMBER, CYAN, GREEN, INK, LAVENDER, MAGENTA, NIGHT, RED, Canvas, hash32, mix, scale)
|
|
33
|
+
|
|
34
|
+
H = 18 # scene height in pixels = 9 terminal rows
|
|
35
|
+
FEET = 14 # the sidewalk row characters stand on
|
|
36
|
+
HORIZON = 12 # buildings end here; sidewalk 13-14, curb 15, puddles 16-17
|
|
37
|
+
SEAT = 12 # bench and stool seats
|
|
38
|
+
MIN_WIDTH = 90 # narrower terminals get the one-line version
|
|
39
|
+
|
|
40
|
+
STATES = ("idle", "queued", "thinking", "coding", "processing", "researching", "delegating", "waiting",
|
|
41
|
+
"completed", "failed", "cancelled")
|
|
42
|
+
FINAL = ("completed", "failed", "cancelled")
|
|
43
|
+
SIGN = {"idle": "IDLE", "queued": "QUEUE", "thinking": "THINK", "coding": "CODE", "processing": "RUN",
|
|
44
|
+
"researching": "READ", "delegating": "TEAM", "waiting": "WAIT", "completed": "DONE",
|
|
45
|
+
"failed": "OOPS", "cancelled": "STOP"}
|
|
46
|
+
SIGN_COL = {"completed": GREEN, "failed": RED, "cancelled": AMBER, "waiting": AMBER, "thinking": CYAN,
|
|
47
|
+
"researching": CYAN, "idle": LAVENDER}
|
|
48
|
+
|
|
49
|
+
WALK_SPEED = 22.0 # pixels per second when walking somewhere
|
|
50
|
+
PUSH_SPEED = 15.0 # with a loaded wheelbarrow
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------------------------
|
|
53
|
+
# Sprite sheet. Facing right, 8 wide; the body is drawn from these rows, arms and legs by pose.
|
|
54
|
+
# f fur s ear k stripe e eye n nose w muzzle c cloth C strap d cloth shade
|
|
55
|
+
HEAD = [".s..s...",
|
|
56
|
+
".ffkff..",
|
|
57
|
+
".fffefn.",
|
|
58
|
+
".kffwww."]
|
|
59
|
+
BODY = ["..cCcc..",
|
|
60
|
+
"..cccc..",
|
|
61
|
+
"..dddd.."]
|
|
62
|
+
LEGS = [((1, 0), (5, 0)), ((3, 0), (4, 0)), ((2, 0), (6, 0)), ((3, 0), (4, 0))] # stride frames
|
|
63
|
+
# arms: list of (dx, dy, key) on top of the body; dy counts from the head's top row
|
|
64
|
+
ARMS = {
|
|
65
|
+
"stand": [(3, 5, "f"), (3, 6, "p")],
|
|
66
|
+
"walk0": [(2, 5, "f"), (1, 6, "p")],
|
|
67
|
+
"walk1": [(4, 5, "f"), (5, 6, "p")],
|
|
68
|
+
"push": [(5, 5, "f"), (6, 5, "f"), (7, 5, "p")],
|
|
69
|
+
"pull": [(2, 5, "f"), (1, 5, "f"), (0, 5, "p")],
|
|
70
|
+
"carry": [(5, 4, "f"), (6, 3, "p"), (7, 3, "p")],
|
|
71
|
+
"type0": [(5, 5, "f"), (6, 5, "p")],
|
|
72
|
+
"type1": [(5, 5, "f"), (6, 4, "p")],
|
|
73
|
+
"think": [(5, 4, "f"), (6, 3, "p")],
|
|
74
|
+
"cheer": [(5, 3, "f"), (6, 2, "f"), (6, 1, "p"), (1, 3, "f"), (0, 2, "f"), (0, 1, "p")],
|
|
75
|
+
"startle": [(6, 3, "f"), (7, 2, "p"), (1, 3, "f"), (0, 2, "p")],
|
|
76
|
+
"stop": [(5, 4, "f"), (6, 3, "f"), (7, 2, "p"), (7, 1, "p")],
|
|
77
|
+
"reach": [(5, 3, "f"), (6, 2, "f"), (6, 1, "p")],
|
|
78
|
+
"read": [(5, 5, "f"), (6, 5, "p")],
|
|
79
|
+
"cup": [(5, 5, "f"), (6, 4, "p")],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
CREW = [ # cloth colours: the main cub wears Forge cyan; helpers wear magenta, amber, lime
|
|
83
|
+
{"c": (34, 176, 214), "C": (140, 240, 255), "d": (20, 104, 150)},
|
|
84
|
+
{"c": (214, 58, 178), "C": (255, 150, 230), "d": (130, 30, 112)},
|
|
85
|
+
{"c": (226, 150, 40), "C": (255, 214, 130), "d": (150, 90, 20)},
|
|
86
|
+
{"c": (90, 200, 90), "C": (180, 255, 170), "d": (40, 120, 50)},
|
|
87
|
+
]
|
|
88
|
+
BASE = {"f": art.FUR, "s": art.FUR_SHADE, "k": art.STRIPE, "e": CYAN, "n": art.NOSE, "w": art.CREAM,
|
|
89
|
+
"p": art.CREAM, "b": (36, 18, 46)}
|
|
90
|
+
|
|
91
|
+
PARCELS = [AMBER, CYAN, MAGENTA, (255, 140, 70)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cub(cv: Canvas, x: int, yb: int, facing: int = 1, pose: str = "stand", step: int = 0, crew: int = 0,
|
|
95
|
+
sit: bool = False, blink: bool = False, look_up: bool = False, dim: float = 1.0) -> None:
|
|
96
|
+
"""Draw a tiger-cub worker. x is the left of its 8-pixel box, yb the row its feet are on."""
|
|
97
|
+
pal = dict(BASE)
|
|
98
|
+
pal.update(CREW[crew % len(CREW)])
|
|
99
|
+
if dim < 1:
|
|
100
|
+
pal = {k: scale(v, dim) for k, v in pal.items()}
|
|
101
|
+
|
|
102
|
+
def P(dx, dy, key, top):
|
|
103
|
+
X = x + (dx if facing > 0 else 7 - dx)
|
|
104
|
+
cv.put(X, top + dy, pal[key])
|
|
105
|
+
|
|
106
|
+
walking = pose.startswith("walk") or pose in ("push", "pull")
|
|
107
|
+
bob = 1 if (walking and step % 2 == 1) else 0
|
|
108
|
+
top = (yb - 7 - bob) if not sit else (yb - 7)
|
|
109
|
+
# tail, behind the body; swishes with the stride or slowly when sitting
|
|
110
|
+
ty = 4 + (step % 2 if not sit else 0)
|
|
111
|
+
P(0, ty, "f", top)
|
|
112
|
+
P(0, ty - 1, "k", top)
|
|
113
|
+
for dy, row in enumerate(HEAD):
|
|
114
|
+
for dx, ch in enumerate(row):
|
|
115
|
+
if ch == ".":
|
|
116
|
+
continue
|
|
117
|
+
if ch == "e":
|
|
118
|
+
if blink:
|
|
119
|
+
ch = "k"
|
|
120
|
+
elif look_up:
|
|
121
|
+
P(dx, dy - 1, "e", top) if dy - 1 >= 1 else None
|
|
122
|
+
ch = "f"
|
|
123
|
+
P(dx, dy, ch, top)
|
|
124
|
+
for dy, row in enumerate(BODY):
|
|
125
|
+
for dx, ch in enumerate(row):
|
|
126
|
+
if ch != ".":
|
|
127
|
+
P(dx, 4 + dy, ch, top)
|
|
128
|
+
if sit:
|
|
129
|
+
# sitting: legs forward over the seat edge
|
|
130
|
+
for dx, dy in ((4, 7), (5, 7), (6, 7), (6, 8)):
|
|
131
|
+
P(dx, dy, "b", top)
|
|
132
|
+
else:
|
|
133
|
+
for dx, _ in LEGS[step % 4]:
|
|
134
|
+
P(dx, 7, "b", top)
|
|
135
|
+
if bob:
|
|
136
|
+
P(dx, 8, "b", top)
|
|
137
|
+
for dx, dy, key in ARMS.get(pose, ARMS["stand"]):
|
|
138
|
+
P(dx, dy, key, top)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parcel(cv: Canvas, x: float, y: float, col, halo: float = 0.3) -> None:
|
|
142
|
+
"""A glowing 2x2 parcel with a soft halo."""
|
|
143
|
+
xi, yi = int(round(x)), int(round(y))
|
|
144
|
+
if halo:
|
|
145
|
+
for dx in range(-1, 3):
|
|
146
|
+
for dy in range(-1, 3):
|
|
147
|
+
if dx in (-1, 2) or dy in (-1, 2):
|
|
148
|
+
cv.put(xi + dx, yi + dy, col, halo * (0.5 if dx in (-1, 2) and dy in (-1, 2) else 1))
|
|
149
|
+
cv.put(xi, yi, mix(col, (255, 255, 255), 0.45))
|
|
150
|
+
cv.put(xi + 1, yi, col)
|
|
151
|
+
cv.put(xi, yi + 1, col)
|
|
152
|
+
cv.put(xi + 1, yi + 1, scale(col, 0.7))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
SPOKES = [(0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1)] # clockwise
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def barrow(cv: Canvas, x: float, yb: int, cargo: int = 0, dim: float = 1.0) -> None:
|
|
159
|
+
"""The wheelbarrow: handle on the left at x, wheel on the right. The wheel's spoke turns with
|
|
160
|
+
the distance travelled, so it rolls exactly as fast as it moves."""
|
|
161
|
+
xi = int(round(x))
|
|
162
|
+
metal = scale((150, 158, 205), dim)
|
|
163
|
+
body = scale((84, 88, 150), dim)
|
|
164
|
+
dark = scale((48, 46, 96), dim)
|
|
165
|
+
cv.put(xi, yb - 2, dark)
|
|
166
|
+
cv.put(xi + 1, yb - 3, dark)
|
|
167
|
+
for dx in range(2, 9):
|
|
168
|
+
cv.put(xi + dx, yb - 4, metal)
|
|
169
|
+
cv.put(xi + dx, yb - 3, body if dx < 8 else dark)
|
|
170
|
+
cv.put(xi + 3, yb - 2, dark)
|
|
171
|
+
cv.put(xi + 3, yb - 1, dark)
|
|
172
|
+
cv.put(xi + 3, yb, dark)
|
|
173
|
+
cx, cy = xi + 7, yb - 1
|
|
174
|
+
for dx, dy in SPOKES:
|
|
175
|
+
cv.put(cx + dx, cy + dy, scale((30, 20, 44), dim))
|
|
176
|
+
cv.put(cx, cy, metal)
|
|
177
|
+
sx, sy = SPOKES[int(math.floor(x / 1.15)) % 8]
|
|
178
|
+
cv.put(cx + sx, cy + sy, scale((150, 230, 255), dim))
|
|
179
|
+
for i in range(min(3, cargo)):
|
|
180
|
+
parcel(cv, xi + 2 + i * 2, yb - 6, PARCELS[i], 0.28 * dim)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------------------------
|
|
184
|
+
class Layout:
|
|
185
|
+
"""Where everything stands, for a given width."""
|
|
186
|
+
|
|
187
|
+
def __init__(self, w: int):
|
|
188
|
+
self.w = w
|
|
189
|
+
self.depot = 2 # crates from x=2 to 10
|
|
190
|
+
self.load = 12 # the cub stands here to load the wheelbarrow
|
|
191
|
+
self.door = w - 9 # the FORGE door, 6 wide
|
|
192
|
+
self.unload = self.door - 17 # worker x when the barrow's nose is at the door
|
|
193
|
+
self.desk = int(w * 0.30) # desk 9 wide, stool in front
|
|
194
|
+
self.archive = int(w * 0.50) # shelf 7 wide; reading spot to its right
|
|
195
|
+
self.bench = int(w * 0.68) # bench 9 wide, lamp 2 to its right
|
|
196
|
+
self.sign = (w - 25, w - 2) # neon sign above the door
|
|
197
|
+
|
|
198
|
+
def spot(self, state: str) -> int:
|
|
199
|
+
return {"idle": self.bench + 1, "waiting": self.bench + 1, "queued": self.load,
|
|
200
|
+
"processing": self.load, "delegating": self.load, "thinking": self.desk - 7,
|
|
201
|
+
"coding": self.desk - 7, "researching": self.archive - 9}.get(state, self.load)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def background(w: int, t: float, motion: bool) -> Canvas:
|
|
205
|
+
"""The street: sky, skyline, props. Cached by the caller."""
|
|
206
|
+
L = Layout(w)
|
|
207
|
+
cv = Canvas(w, H)
|
|
208
|
+
wins = art.skyline(cv, HORIZON, seed=w * 7 + 3, moon=(int(w * 0.16), 3))
|
|
209
|
+
art.windows_on(cv, wins, t, motion)
|
|
210
|
+
# sidewalk
|
|
211
|
+
for y, col in ((13, (40, 26, 76)), (14, (34, 22, 66))):
|
|
212
|
+
for x in range(w):
|
|
213
|
+
cv.px[y][x] = scale(col, 0.85) if (x + (y * 3)) % 9 == 0 else col
|
|
214
|
+
# depot: stacked crates
|
|
215
|
+
crate, edge = (118, 66, 46), (170, 104, 64)
|
|
216
|
+
for cx, cy in ((2, 12), (5, 12), (8, 12), (3, 9), (6, 9)):
|
|
217
|
+
cv.rect(cx, cy, 3, 3, crate)
|
|
218
|
+
cv.rect(cx, cy, 3, 1, edge)
|
|
219
|
+
cv.put(cx + 1, cy + 1, (230, 160, 90))
|
|
220
|
+
# the FORGE door: a lit doorway in a dark wall, cyan neon frame
|
|
221
|
+
d = L.door
|
|
222
|
+
cv.rect(d - 2, 3, 10, HORIZON - 2, (22, 12, 46))
|
|
223
|
+
cv.rect(d, 7, 6, FEET - 6, (60, 24, 90))
|
|
224
|
+
for y in range(7, FEET + 1):
|
|
225
|
+
cv.put(d - 1, y, CYAN)
|
|
226
|
+
cv.light(d - 1, y, CYAN, 0.7)
|
|
227
|
+
cv.put(d + 6, y, CYAN)
|
|
228
|
+
cv.light(d + 6, y, CYAN, 0.7)
|
|
229
|
+
for x in range(d - 1, d + 7):
|
|
230
|
+
cv.put(x, 6, CYAN)
|
|
231
|
+
cv.light(x, 6, CYAN, 0.7)
|
|
232
|
+
cv.rect(d + 1, 8, 4, FEET - 8, (120, 50, 150))
|
|
233
|
+
# the sign above it: a dark board with a magenta border (the word is drawn each frame)
|
|
234
|
+
s0, s1 = L.sign
|
|
235
|
+
cv.rect(s0, 0, s1 - s0, 6, (14, 8, 30))
|
|
236
|
+
for x in range(s0, s1):
|
|
237
|
+
cv.put(x, 0, MAGENTA, 0.6)
|
|
238
|
+
cv.put(x, 5, MAGENTA, 0.6)
|
|
239
|
+
# desk with monitor, stool
|
|
240
|
+
k = L.desk
|
|
241
|
+
wood, leg = (88, 56, 110), (50, 34, 70)
|
|
242
|
+
cv.rect(k, 10, 9, 1, wood)
|
|
243
|
+
cv.put(k + 1, 11, leg); cv.put(k + 1, 12, leg); cv.put(k + 1, 13, leg); cv.put(k + 1, 14, leg)
|
|
244
|
+
cv.put(k + 7, 11, leg); cv.put(k + 7, 12, leg); cv.put(k + 7, 13, leg); cv.put(k + 7, 14, leg)
|
|
245
|
+
cv.rect(k + 3, 5, 6, 5, (30, 30, 60))
|
|
246
|
+
cv.rect(k + 4, 6, 4, 3, (10, 20, 40))
|
|
247
|
+
cv.put(k + 5, 9, (60, 60, 100)); cv.put(k + 6, 9, (60, 60, 100))
|
|
248
|
+
cv.rect(k - 5, SEAT, 4, 1, (110, 80, 140))
|
|
249
|
+
cv.put(k - 4, 13, leg); cv.put(k - 4, 14, leg); cv.put(k - 2, 13, leg); cv.put(k - 2, 14, leg)
|
|
250
|
+
# archive shelf with books, a floor pile beside it
|
|
251
|
+
a = L.archive
|
|
252
|
+
cv.rect(a, 4, 7, FEET - 3, (54, 34, 84))
|
|
253
|
+
for row, y in enumerate((5, 8, 11)):
|
|
254
|
+
for i in range(5):
|
|
255
|
+
h = 2 if (hash32(a, row, i) % 3) else 1
|
|
256
|
+
col = (PARCELS + [GREEN, LAVENDER])[hash32(row, i, 9) % 6]
|
|
257
|
+
cv.rect(a + 1 + i, y + (2 - h), 1, h, scale(col, 0.75))
|
|
258
|
+
cv.rect(a, y + 2, 7, 1, (90, 60, 120))
|
|
259
|
+
# bench and lamp
|
|
260
|
+
b = L.bench
|
|
261
|
+
cv.rect(b, SEAT, 9, 1, (120, 80, 60))
|
|
262
|
+
cv.rect(b, SEAT - 2, 9, 1, (96, 62, 48))
|
|
263
|
+
for x in (b + 1, b + 7):
|
|
264
|
+
cv.put(x, 13, (60, 40, 40)); cv.put(x, 14, (60, 40, 40))
|
|
265
|
+
lp = b + 11
|
|
266
|
+
for y in range(4, FEET + 1):
|
|
267
|
+
cv.put(lp, y, (70, 60, 100))
|
|
268
|
+
cv.rect(lp - 1, 3, 3, 1, (90, 80, 120))
|
|
269
|
+
cv.put(lp, 4, (255, 226, 160))
|
|
270
|
+
cv.light(lp, 4, AMBER, 2.2)
|
|
271
|
+
cv.light(lp, 5, AMBER, 1.2)
|
|
272
|
+
cv.bloom(radius=1, strength=0.55)
|
|
273
|
+
# a cone of lamplight on the pavement
|
|
274
|
+
for y in range(6, FEET + 1):
|
|
275
|
+
spread = (y - 5) // 2
|
|
276
|
+
for x in range(lp - spread, lp + spread + 1):
|
|
277
|
+
cv.put(x, y, AMBER, 0.06)
|
|
278
|
+
return cv
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------------------------
|
|
282
|
+
class City:
|
|
283
|
+
"""Holds the current state and draws frames. set()/blip() are called from any thread; frame()
|
|
284
|
+
is called by the UI's redraw timer."""
|
|
285
|
+
|
|
286
|
+
LINGER = 3.5 # completed / failed / cancelled stay on screen this long
|
|
287
|
+
|
|
288
|
+
def __init__(self):
|
|
289
|
+
self.lock = threading.Lock()
|
|
290
|
+
self.state = "idle"
|
|
291
|
+
self.since = time.monotonic()
|
|
292
|
+
self.detail = ""
|
|
293
|
+
self.helpers = 0
|
|
294
|
+
self.wait_until = 0.0
|
|
295
|
+
self.wait_total = 0.0
|
|
296
|
+
self.swap_at = -99.0
|
|
297
|
+
self.x = None # where the main cub is
|
|
298
|
+
self.facing = 1
|
|
299
|
+
self.arrived = None # when it reached the spot for the current state
|
|
300
|
+
self.walked = 0.0 # distance walked, drives the stride
|
|
301
|
+
self.last = None
|
|
302
|
+
self.anchor = None # where an end-of-turn animation is played
|
|
303
|
+
self._bg = (None, None)
|
|
304
|
+
self.motion = os.environ.get("FORGE_MOTION", "").lower() not in ("reduce", "off", "0", "no")
|
|
305
|
+
|
|
306
|
+
# -- state changes (called by the agent's UI hooks) ----------------------------------------
|
|
307
|
+
def set(self, state: str, detail: str = "", wait: float = 0.0) -> None:
|
|
308
|
+
if state not in STATES:
|
|
309
|
+
return
|
|
310
|
+
with self.lock:
|
|
311
|
+
now = time.monotonic()
|
|
312
|
+
if state != self.state:
|
|
313
|
+
self.state = state
|
|
314
|
+
self.since = now
|
|
315
|
+
self.arrived = None
|
|
316
|
+
self.anchor = None
|
|
317
|
+
self.detail = detail
|
|
318
|
+
if state == "waiting" and wait:
|
|
319
|
+
self.wait_until = now + wait
|
|
320
|
+
self.wait_total = wait
|
|
321
|
+
|
|
322
|
+
def blip(self) -> None:
|
|
323
|
+
"""A provider failed and Forge is switching to the next one: a spark, the sign flickers."""
|
|
324
|
+
with self.lock:
|
|
325
|
+
self.swap_at = time.monotonic()
|
|
326
|
+
|
|
327
|
+
def set_helpers(self, n: int) -> None:
|
|
328
|
+
with self.lock:
|
|
329
|
+
self.helpers = max(0, min(3, n))
|
|
330
|
+
|
|
331
|
+
def settled(self) -> bool:
|
|
332
|
+
"""True once an end-of-turn animation has finished playing."""
|
|
333
|
+
return self.state in FINAL and time.monotonic() - self.since > self.LINGER
|
|
334
|
+
|
|
335
|
+
# -- drawing ----------------------------------------------------------------------------------
|
|
336
|
+
def frame(self, width: int, now: float | None = None) -> str:
|
|
337
|
+
"""The scene as half-block ANSI text (H/2 lines), or one plain line if it doesn't fit."""
|
|
338
|
+
now = time.monotonic() if now is None else now
|
|
339
|
+
w = min(width, 240)
|
|
340
|
+
if w < MIN_WIDTH:
|
|
341
|
+
return self.line(width, now)
|
|
342
|
+
with self.lock:
|
|
343
|
+
cv = self.draw(w, now)
|
|
344
|
+
return cv.ansi()
|
|
345
|
+
|
|
346
|
+
def draw(self, w: int, now: float) -> Canvas:
|
|
347
|
+
motion = self.motion
|
|
348
|
+
L = Layout(w)
|
|
349
|
+
bucket = int(now * 2) if motion else 0
|
|
350
|
+
key = (w, bucket)
|
|
351
|
+
if self._bg[0] != key:
|
|
352
|
+
self._bg = (key, background(w, bucket / 2, motion))
|
|
353
|
+
cv = self._bg[1].copy()
|
|
354
|
+
t = now if motion else 0.0
|
|
355
|
+
state = self.state
|
|
356
|
+
age = now - self.since
|
|
357
|
+
self._move(L, state, now)
|
|
358
|
+
here = self.arrived is not None
|
|
359
|
+
at = (now - self.arrived) if here else 0.0
|
|
360
|
+
if not motion:
|
|
361
|
+
at = 1.6
|
|
362
|
+
x = self.x
|
|
363
|
+
blink = motion and (now + 0.37 * hash32(int(x))) % 4.2 < 0.14
|
|
364
|
+
|
|
365
|
+
# the wheelbarrow is parked at the depot unless someone is using it
|
|
366
|
+
working_barrow = state in ("processing",) and here
|
|
367
|
+
if not working_barrow and state != "delegating":
|
|
368
|
+
barrow(cv, L.load + 8, FEET, 0)
|
|
369
|
+
|
|
370
|
+
# helpers: in delegating, and alongside processing when tasks run in parallel
|
|
371
|
+
crew = self.helpers if state in ("delegating", "processing") else 0
|
|
372
|
+
if state == "delegating":
|
|
373
|
+
crew = max(1, crew)
|
|
374
|
+
if crew and here:
|
|
375
|
+
start = L.load + 9 if state == "delegating" else L.load
|
|
376
|
+
for i in range(crew):
|
|
377
|
+
ph = self._loop(at + (i + 1) * 2.9, start, L.unload, L, cv, from_hand=(x + 7, FEET - 5)
|
|
378
|
+
if state == "delegating" else None)
|
|
379
|
+
self._cub_with_barrow(cv, ph, crew=i + 1, dim=0.82)
|
|
380
|
+
|
|
381
|
+
draw_main = True
|
|
382
|
+
if not here:
|
|
383
|
+
pose = "walk%d" % ((int(self.walked / 3)) % 2)
|
|
384
|
+
cub(cv, int(x), FEET, self.facing, pose, int(self.walked / 2.2), blink=blink)
|
|
385
|
+
draw_main = False
|
|
386
|
+
if draw_main:
|
|
387
|
+
self._act(cv, L, state, t, at, age, x, blink, working_barrow)
|
|
388
|
+
self._sign(cv, L, state, t, now)
|
|
389
|
+
art.street(cv, 15, t, motion)
|
|
390
|
+
return cv
|
|
391
|
+
|
|
392
|
+
def _move(self, L: Layout, state: str, now: float) -> None:
|
|
393
|
+
"""Walk the main cub towards the spot for this state; record when it arrives."""
|
|
394
|
+
target = L.spot(state)
|
|
395
|
+
if state in FINAL:
|
|
396
|
+
if self.anchor is None:
|
|
397
|
+
self.anchor = self.x if self.x is not None else target
|
|
398
|
+
target = self.anchor
|
|
399
|
+
if self.x is None:
|
|
400
|
+
self.x = float(target)
|
|
401
|
+
last = self.last if self.last is not None else now
|
|
402
|
+
dt = max(0.0, min(0.25, now - last))
|
|
403
|
+
self.last = now
|
|
404
|
+
if state == "processing" and self.arrived is not None:
|
|
405
|
+
return # the delivery loop moves it now
|
|
406
|
+
if not self.motion:
|
|
407
|
+
self.x = float(target)
|
|
408
|
+
gap = target - self.x
|
|
409
|
+
if abs(gap) > 0.5:
|
|
410
|
+
stepx = math.copysign(min(abs(gap), WALK_SPEED * dt), gap)
|
|
411
|
+
self.x += stepx
|
|
412
|
+
self.walked += abs(stepx)
|
|
413
|
+
self.facing = 1 if gap > 0 else -1
|
|
414
|
+
self.arrived = None
|
|
415
|
+
elif self.arrived is None:
|
|
416
|
+
self.x = float(target)
|
|
417
|
+
self.arrived = now
|
|
418
|
+
self.facing = 1
|
|
419
|
+
|
|
420
|
+
def _loop(self, t: float, xa: float, xb: float, L: Layout, cv: Canvas, from_hand=None) -> dict:
|
|
421
|
+
"""One wheelbarrow delivery cycle: load at xa, push to xb, unload into the door, pull back.
|
|
422
|
+
Pure function of t, so several cubs can run it with different offsets."""
|
|
423
|
+
d = max(4.0, xb - xa)
|
|
424
|
+
travel = d / PUSH_SPEED
|
|
425
|
+
back = d / (PUSH_SPEED * 1.25)
|
|
426
|
+
T_load, T_unload = 1.3, 1.1
|
|
427
|
+
period = T_load + travel + T_unload + back
|
|
428
|
+
p = t % period
|
|
429
|
+
res = {"x": xa, "facing": 1, "cargo": 0, "pose": "push", "moving": False}
|
|
430
|
+
if p < T_load:
|
|
431
|
+
n = int(p / 0.4)
|
|
432
|
+
res["cargo"] = min(3, n)
|
|
433
|
+
f = (p % 0.4) / 0.4
|
|
434
|
+
if n < 3:
|
|
435
|
+
src = from_hand or (L.depot + 4 + (n % 2) * 3, 9)
|
|
436
|
+
dst = (xa + 10 + n * 2, FEET - 6)
|
|
437
|
+
bx = src[0] + (dst[0] - src[0]) * f
|
|
438
|
+
by = src[1] + (dst[1] - src[1]) * f - math.sin(f * math.pi) * 4
|
|
439
|
+
parcel(cv, bx, by, PARCELS[n], 0.3)
|
|
440
|
+
elif p < T_load + travel:
|
|
441
|
+
f = (p - T_load) / travel
|
|
442
|
+
f = f * f * (3 - 2 * f) * 0.35 + f * 0.65 # a little ease at each end
|
|
443
|
+
res.update(x=xa + d * f, cargo=3, moving=True)
|
|
444
|
+
elif p < T_load + travel + T_unload:
|
|
445
|
+
q = p - T_load - travel
|
|
446
|
+
n = int(q / 0.35)
|
|
447
|
+
res.update(x=xb, cargo=max(0, 3 - n))
|
|
448
|
+
if n < 3:
|
|
449
|
+
f = (q % 0.35) / 0.35
|
|
450
|
+
sx, sy = xb + 10 + (2 - n) * 2, FEET - 6
|
|
451
|
+
tx, ty = L.door + 2, FEET - 4
|
|
452
|
+
parcel(cv, sx + (tx - sx) * f, sy + (ty - sy) * f - math.sin(f * math.pi) * 3,
|
|
453
|
+
PARCELS[2 - n], 0.3 * (1 - f))
|
|
454
|
+
glow = 0.25 + 0.25 * math.sin(q * 12)
|
|
455
|
+
for y in range(8, FEET):
|
|
456
|
+
for xx in range(L.door + 1, L.door + 5):
|
|
457
|
+
cv.put(xx, y, (180, 255, 255), glow * 0.5)
|
|
458
|
+
else:
|
|
459
|
+
f = (p - T_load - travel - T_unload) / back
|
|
460
|
+
res.update(x=xb - d * f, facing=-1, pose="pull", moving=True)
|
|
461
|
+
return res
|
|
462
|
+
|
|
463
|
+
def _cub_with_barrow(self, cv: Canvas, ph: dict, crew: int = 0, dim: float = 1.0) -> None:
|
|
464
|
+
x = ph["x"]
|
|
465
|
+
step = int(x / 2.2) if ph["moving"] else 1
|
|
466
|
+
barrow(cv, x + 8, FEET, ph["cargo"], dim)
|
|
467
|
+
cub(cv, int(round(x)), FEET, ph["facing"], ph["pose"], step, crew=crew, dim=dim)
|
|
468
|
+
|
|
469
|
+
def _act(self, cv, L, state, t, at, age, x, blink, working_barrow) -> None:
|
|
470
|
+
"""The main cub's activity once it is in place."""
|
|
471
|
+
xi = int(round(x))
|
|
472
|
+
if state == "processing":
|
|
473
|
+
ph = self._loop(at, L.load, L.unload, L, cv)
|
|
474
|
+
self._cub_with_barrow(cv, ph)
|
|
475
|
+
self.x = ph["x"] # keep track of it so the next state walks from here
|
|
476
|
+
self.arrived = self.arrived # still in place for the loop
|
|
477
|
+
return
|
|
478
|
+
if state == "delegating":
|
|
479
|
+
# the dispatcher: lifts a parcel from the depot and hands it over
|
|
480
|
+
ph = (at % 1.2) / 1.2
|
|
481
|
+
cub(cv, xi, FEET, 1, "carry" if ph > 0.3 else "reach", 1, blink=blink)
|
|
482
|
+
if ph > 0.3:
|
|
483
|
+
parcel(cv, xi + 6, FEET - 7, PARCELS[int(at / 1.2) % 4], 0.3)
|
|
484
|
+
return
|
|
485
|
+
if state in ("thinking", "coding"):
|
|
486
|
+
self._desk(cv, L, state, t, at, xi, blink)
|
|
487
|
+
return
|
|
488
|
+
if state == "researching":
|
|
489
|
+
self._research(cv, L, t, at, blink)
|
|
490
|
+
return
|
|
491
|
+
if state in ("idle", "waiting"):
|
|
492
|
+
sx = L.bench + 1
|
|
493
|
+
cub(cv, sx, SEAT - 1, 1, "cup" if state == "waiting" else "stand", 0, sit=True, blink=blink)
|
|
494
|
+
if state == "waiting":
|
|
495
|
+
cx, cy = sx + 7, SEAT - 5
|
|
496
|
+
cv.put(cx, cy, (240, 240, 250)); cv.put(cx, cy + 1, (240, 240, 250))
|
|
497
|
+
cv.put(cx + 1, cy, (200, 120, 60))
|
|
498
|
+
for i in range(3):
|
|
499
|
+
k = (t * 1.6 + i / 3) % 1
|
|
500
|
+
cv.put(cx + int(math.sin(k * 6 + i) * 1.2), int(cy - 1 - k * 4), (220, 220, 255), 0.5 * (1 - k))
|
|
501
|
+
return
|
|
502
|
+
if state == "queued":
|
|
503
|
+
step = int(t * 3) % 2
|
|
504
|
+
cub(cv, xi, FEET, 1, "stand", step * 2, blink=blink)
|
|
505
|
+
for i in range(3):
|
|
506
|
+
on = int(t * 2.5) % 4 > i
|
|
507
|
+
cv.put(xi + 3 + i * 2, FEET - 10, INK, 0.9 if on else 0.25)
|
|
508
|
+
return
|
|
509
|
+
if state == "completed":
|
|
510
|
+
hop = abs(math.sin(age * 7)) * 2.5 if age < 2.2 else 0
|
|
511
|
+
cub(cv, xi, FEET - int(hop), 1, "cheer", 1, blink=False)
|
|
512
|
+
self._confetti(cv, xi + 4, FEET - 8, age)
|
|
513
|
+
return
|
|
514
|
+
if state == "failed":
|
|
515
|
+
cub(cv, xi, FEET, 1, "startle", 1)
|
|
516
|
+
for i in range(3):
|
|
517
|
+
vx = (hash32(i, 5) % 7 - 3) * 6
|
|
518
|
+
tt = min(age, 0.9)
|
|
519
|
+
bx = xi + 6 + vx * tt
|
|
520
|
+
by = min(FEET - 1, FEET - 7 - 8 * tt + 26 * tt * tt)
|
|
521
|
+
parcel(cv, bx, by, PARCELS[i], 0.2)
|
|
522
|
+
if int(age * 3) % 2 == 0 or not self.motion:
|
|
523
|
+
cv.text("!", xi + 4, FEET - 13, RED, art.FONT3, 1)
|
|
524
|
+
if age < 1.0 and self.motion:
|
|
525
|
+
for i in range(6):
|
|
526
|
+
a = i * 1.05 + age * 3
|
|
527
|
+
r = 2 + age * 5
|
|
528
|
+
cv.put(int(xi + 8 + math.cos(a) * r), int(FEET - 3 + math.sin(a) * r * 0.5), (140, 120, 160), 0.6 * (1 - age))
|
|
529
|
+
return
|
|
530
|
+
if state == "cancelled":
|
|
531
|
+
cub(cv, xi, FEET, 1, "stop", 1, blink=blink)
|
|
532
|
+
ox, oy = xi + 9, FEET - 12
|
|
533
|
+
for dx in range(5):
|
|
534
|
+
for dy in range(5):
|
|
535
|
+
if (dx, dy) not in ((0, 0), (4, 0), (0, 4), (4, 4)):
|
|
536
|
+
cv.put(ox + dx, oy + dy, (220, 40, 60))
|
|
537
|
+
cv.rect(ox + 1, oy + 2, 3, 1, (255, 240, 240))
|
|
538
|
+
return
|
|
539
|
+
cub(cv, xi, FEET, 1, "stand", 1, blink=blink)
|
|
540
|
+
|
|
541
|
+
def _desk(self, cv, L, state, t, at, xi, blink) -> None:
|
|
542
|
+
k = L.desk
|
|
543
|
+
mon = (k + 4, 6)
|
|
544
|
+
if state == "coding":
|
|
545
|
+
pose = "type%d" % (int(t * 9) % 2)
|
|
546
|
+
cub(cv, k - 7, SEAT - 1, 1, pose, 0, sit=True, blink=blink)
|
|
547
|
+
for r in range(3):
|
|
548
|
+
line = int(t * 5) + r
|
|
549
|
+
n = 1 + hash32(line, 77) % 4
|
|
550
|
+
col = (CYAN, MAGENTA, GREEN, AMBER)[hash32(line, 3) % 4]
|
|
551
|
+
for i in range(n):
|
|
552
|
+
cv.put(mon[0] + i, mon[1] + r, col)
|
|
553
|
+
cv.put(mon[0] + 3, mon[1] + 2, INK, 0.9 if int(t * 3) % 2 else 0.2)
|
|
554
|
+
return
|
|
555
|
+
# thinking: sit and look up with a thought bubble; every few seconds, pace about
|
|
556
|
+
cyc = at % 9.0
|
|
557
|
+
if cyc < 6.0 or not self.motion:
|
|
558
|
+
cub(cv, k - 7, SEAT - 1, 1, "think", 0, sit=True, blink=blink, look_up=int(t) % 3 == 0)
|
|
559
|
+
bx, by = k - 1, 2
|
|
560
|
+
cv.put(k - 2, 5, INK, 0.6)
|
|
561
|
+
cv.put(k - 1, 4, INK, 0.7)
|
|
562
|
+
cv.ellipse(bx + 4, by + 1, 5, 2.6, (230, 222, 255), 0.95)
|
|
563
|
+
for i in range(3):
|
|
564
|
+
lit = int(t * 3) % 3 == i
|
|
565
|
+
cv.put(bx + 2 + i * 2, by + 1, CYAN if lit else (120, 110, 170))
|
|
566
|
+
cv.put(mon[0] + int(t * 2) % 4, mon[1] + 1, CYAN, 0.4)
|
|
567
|
+
else:
|
|
568
|
+
q = cyc - 6.0
|
|
569
|
+
px = k - 7 + (q * 8 if q < 1.5 else (3 - q) * 8)
|
|
570
|
+
face = 1 if q < 1.5 else -1
|
|
571
|
+
cub(cv, int(px), FEET, face, "walk%d" % (int(q * 4) % 2), int(q * 6), blink=blink)
|
|
572
|
+
|
|
573
|
+
def _research(self, cv, L, t, at, blink) -> None:
|
|
574
|
+
a = L.archive
|
|
575
|
+
shelf, read = a - 7, a + 9
|
|
576
|
+
cyc = at % 7.0
|
|
577
|
+
if not self.motion:
|
|
578
|
+
cyc = 4.0
|
|
579
|
+
if cyc < 1.0: # reach up and pull a document
|
|
580
|
+
cub(cv, shelf, FEET, 1, "reach", 1, blink=blink)
|
|
581
|
+
if cyc > 0.5:
|
|
582
|
+
cv.rect(shelf + 6, FEET - 9, 2, 2, (240, 240, 250))
|
|
583
|
+
elif cyc < 2.4: # walk to the reading spot holding it
|
|
584
|
+
f = (cyc - 1.0) / 1.4
|
|
585
|
+
x = shelf + (read - shelf) * f
|
|
586
|
+
cub(cv, int(x), FEET, 1, "read", int(x / 2.2))
|
|
587
|
+
cv.rect(int(x) + 7, FEET - 4, 2, 3, (240, 240, 250))
|
|
588
|
+
elif cyc < 5.6: # read it under a magnifier
|
|
589
|
+
cub(cv, read, FEET, 1, "read", 1, blink=blink)
|
|
590
|
+
cv.rect(read + 7, FEET - 5, 2, 3, (240, 240, 250))
|
|
591
|
+
cv.put(read + 7, FEET - 4, (120, 120, 160))
|
|
592
|
+
g = (t * 1.4) % 1
|
|
593
|
+
cv.put(read + 7 + int(g * 2), FEET - 5 + int(g * 3), CYAN)
|
|
594
|
+
cv.light(read + 7, FEET - 4, CYAN, 0.2)
|
|
595
|
+
else: # walk back for the next one
|
|
596
|
+
f = (cyc - 5.6) / 1.4
|
|
597
|
+
x = read + (shelf - read) * f
|
|
598
|
+
cub(cv, int(x), FEET, -1, "walk%d" % (int(x / 3) % 2), int(x / 2.2))
|
|
599
|
+
pile = int(at / 7.0) % 5
|
|
600
|
+
for i in range(pile):
|
|
601
|
+
cv.rect(read + 10, FEET - i, 3, 1, (220, 220, 235) if i % 2 else (190, 190, 215))
|
|
602
|
+
|
|
603
|
+
def _confetti(self, cv, ox, oy, age) -> None:
|
|
604
|
+
if age > 2.6:
|
|
605
|
+
return
|
|
606
|
+
for i in range(22):
|
|
607
|
+
ang = (hash32(i, 41) % 360) * math.pi / 180
|
|
608
|
+
sp = 8 + hash32(i, 7) % 10
|
|
609
|
+
tt = age if self.motion else 0.35
|
|
610
|
+
x = ox + math.cos(ang) * sp * tt
|
|
611
|
+
y = oy + math.sin(ang) * sp * tt * 0.6 - 10 * tt + 14 * tt * tt
|
|
612
|
+
col = (MAGENTA, CYAN, AMBER, GREEN, INK)[i % 5]
|
|
613
|
+
cv.put(int(x), int(y), col, max(0.0, 1 - tt / 2.6))
|
|
614
|
+
|
|
615
|
+
def _sign(self, cv, L, state, t, now) -> None:
|
|
616
|
+
s0, s1 = L.sign
|
|
617
|
+
word = SIGN[state]
|
|
618
|
+
col = SIGN_COL.get(state, MAGENTA)
|
|
619
|
+
swap = now - self.swap_at
|
|
620
|
+
if 0 <= swap < 1.6:
|
|
621
|
+
word, col = "SWAP", CYAN
|
|
622
|
+
if self.motion and int(swap * 10) % 3 == 0:
|
|
623
|
+
col = scale(CYAN, 0.35)
|
|
624
|
+
# a rare neon flicker
|
|
625
|
+
if self.motion and hash32(int(now * 8), 99) % 97 == 0:
|
|
626
|
+
col = scale(col, 0.4)
|
|
627
|
+
wd = len(word) * 4 - 1
|
|
628
|
+
x0 = s0 + (s1 - s0 - wd) // 2
|
|
629
|
+
tmp = Canvas(wd, 5, (0, 0, 0))
|
|
630
|
+
tmp.text(word, 0, 0, col, art.FONT3, 1)
|
|
631
|
+
for y in range(5):
|
|
632
|
+
for x in range(wd):
|
|
633
|
+
if tmp.px[y][x] != (0, 0, 0):
|
|
634
|
+
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
|
635
|
+
cv.put(x0 + x + dx, 0 + y + dy + 0, col, 0.18)
|
|
636
|
+
for y in range(5):
|
|
637
|
+
for x in range(wd):
|
|
638
|
+
if tmp.px[y][x] != (0, 0, 0):
|
|
639
|
+
cv.put(x0 + x, y, mix(col, (255, 255, 255), 0.35))
|
|
640
|
+
if state == "waiting" and self.wait_total:
|
|
641
|
+
left = max(0.0, self.wait_until - now) / self.wait_total
|
|
642
|
+
n = int((s1 - s0 - 4) * left)
|
|
643
|
+
cv.rect(s0 + 2, 5, n, 1, AMBER)
|
|
644
|
+
if 0 <= swap < 0.8 and self.x is not None:
|
|
645
|
+
# a spark jumps over the worker: the model behind it changed
|
|
646
|
+
xi = int(self.x)
|
|
647
|
+
for i, (dx, dy) in enumerate(((3, -11), (4, -10), (3, -9), (5, -9), (4, -8))):
|
|
648
|
+
cv.put(xi + dx, FEET + dy, (255, 240, 120))
|
|
649
|
+
|
|
650
|
+
# -- terminals without colour --------------------------------------------------------------
|
|
651
|
+
def line(self, width: int, now: float | None = None) -> str:
|
|
652
|
+
"""One plain line: a tiny text street for terminals that can't draw the city."""
|
|
653
|
+
now = time.monotonic() if now is None else now
|
|
654
|
+
st = self.state
|
|
655
|
+
t = now if self.motion else 0.0
|
|
656
|
+
if st in ("processing", "delegating"):
|
|
657
|
+
n = 18
|
|
658
|
+
p = t * 6 % (2 * n)
|
|
659
|
+
pos = int(p if p < n else 2 * n - p)
|
|
660
|
+
cart = "o=[■]" if p < n else "o=[ ]"
|
|
661
|
+
track = "." * pos + cart + "." * (n - pos)
|
|
662
|
+
return f" ▤ {track} ▯ {SIGN[st].lower()}"
|
|
663
|
+
faces = {"thinking": "(=・ω・=) ?", "coding": "(=^‿^=)⌨", "researching": "(=・ᴥ・=)📄",
|
|
664
|
+
"waiting": "(=-ω-=)☕", "queued": "(=・ω・=) …", "completed": "\\(=^▽^=)/ ✦",
|
|
665
|
+
"failed": "(=;ω;=) !", "cancelled": "(=・ω・=)✋", "idle": "(=^ω^=)"}
|
|
666
|
+
return f" {faces.get(st, '(=^ω^=)')} {SIGN[st].lower()}"
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# --- the little wheelbarrow -------------------------------------------------------------------
|
|
670
|
+
# One line, about as wide as the word "Thinking", standing in for the spinner next to the status
|
|
671
|
+
# word. It follows what Forge is really doing:
|
|
672
|
+
# thinking / coding wheels loads from the pile (left) to the house (right) and back
|
|
673
|
+
# a tool is running pushes a load to the house and waits at the door until the tool finishes
|
|
674
|
+
# tool finished unloads at the house, walks back empty for the next load
|
|
675
|
+
# waiting (limits) stops where it is, zZ
|
|
676
|
+
# completed delivers the last load, the house shows a tick
|
|
677
|
+
# failed the load spills, the house goes red
|
|
678
|
+
# cancelled stops where it is
|
|
679
|
+
# Cargo colour shows the kind of work: lavender thinking, amber running, cyan reading, magenta helpers.
|
|
680
|
+
|
|
681
|
+
TRACK = 6 # cells the barrow travels: about as wide as the word 'Thinking'
|
|
682
|
+
_SPEED = 5.0 # cells per second
|
|
683
|
+
_PAUSE = 0.5 # seconds spent loading / unloading at each end
|
|
684
|
+
_WHEEL = "◐◓◑◒" # quarter turns, so the wheel rolls as it goes
|
|
685
|
+
TOOL_STATES = ("processing", "researching", "delegating")
|
|
686
|
+
_CARGO = {"thinking": "#c3a6ff", "coding": "#c3a6ff", "processing": "#ffb347",
|
|
687
|
+
"researching": "#3ef0ff", "delegating": "#ff4fd8"}
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
class Barrow:
|
|
691
|
+
"""State follows UI.act(); pieces(now) moves it towards where that state wants it."""
|
|
692
|
+
|
|
693
|
+
def __init__(self, motion: bool = True):
|
|
694
|
+
self.lock = threading.Lock()
|
|
695
|
+
self.motion = motion
|
|
696
|
+
self.state = "idle"
|
|
697
|
+
self.pos = 0.0
|
|
698
|
+
self.right = True # facing / pushing towards the house
|
|
699
|
+
self.loaded = False
|
|
700
|
+
self.kind = "thinking" # what the current load is
|
|
701
|
+
self.pause_until = 0.0 # loading / unloading in progress until then
|
|
702
|
+
self.pause_kind = "" # "load" | "unload"
|
|
703
|
+
self.last = None
|
|
704
|
+
self.delivered = 0
|
|
705
|
+
self.walked = 0.0 # total distance pushed (drives the walk-cycle frame)
|
|
706
|
+
|
|
707
|
+
def on(self, state: str) -> None:
|
|
708
|
+
with self.lock:
|
|
709
|
+
if state == self.state:
|
|
710
|
+
return
|
|
711
|
+
self.state = state
|
|
712
|
+
if state in TOOL_STATES:
|
|
713
|
+
self.kind = state
|
|
714
|
+
elif state in ("thinking", "coding") and not self.loaded:
|
|
715
|
+
self.kind = state
|
|
716
|
+
if state == "idle":
|
|
717
|
+
self.pos, self.loaded, self.right, self.pause_until = 0.0, False, True, 0.0
|
|
718
|
+
|
|
719
|
+
def _target(self) -> float | None:
|
|
720
|
+
st = self.state
|
|
721
|
+
if st in ("waiting", "failed", "cancelled", "idle", "queued"):
|
|
722
|
+
return None # stay put
|
|
723
|
+
if st == "completed":
|
|
724
|
+
return float(TRACK) # the last load goes in
|
|
725
|
+
if st in TOOL_STATES: # fetch a load if empty, then wait at the door
|
|
726
|
+
return float(TRACK) if self.loaded else 0.0
|
|
727
|
+
# thinking / coding: shuttle back and forth
|
|
728
|
+
return float(TRACK) if self.loaded else 0.0
|
|
729
|
+
|
|
730
|
+
def _step(self, now: float) -> None:
|
|
731
|
+
last = self.last if self.last is not None else now
|
|
732
|
+
dt = max(0.0, min(0.25, now - last))
|
|
733
|
+
self.last = now
|
|
734
|
+
if not self.motion:
|
|
735
|
+
self.pos, self.loaded = TRACK / 2, self.state not in ("failed", "idle")
|
|
736
|
+
return
|
|
737
|
+
if now < self.pause_until:
|
|
738
|
+
return
|
|
739
|
+
if self.pause_kind == "load":
|
|
740
|
+
self.loaded = True
|
|
741
|
+
elif self.pause_kind == "unload":
|
|
742
|
+
if self.loaded:
|
|
743
|
+
self.delivered += 1
|
|
744
|
+
self.loaded = False
|
|
745
|
+
self.pause_kind = ""
|
|
746
|
+
target = self._target()
|
|
747
|
+
if target is None:
|
|
748
|
+
return
|
|
749
|
+
gap = target - self.pos
|
|
750
|
+
if abs(gap) > 0.01:
|
|
751
|
+
self.right = gap > 0
|
|
752
|
+
step = min(abs(gap), getattr(self, "speed", _SPEED) * dt)
|
|
753
|
+
self.pos += math.copysign(step, gap)
|
|
754
|
+
self.walked += step
|
|
755
|
+
return
|
|
756
|
+
# arrived
|
|
757
|
+
at_pile, at_house = self.pos <= 0.01, self.pos >= TRACK - 0.01
|
|
758
|
+
if at_pile and not self.loaded and self.state != "completed":
|
|
759
|
+
self.pause_kind, self.pause_until = "load", now + _PAUSE
|
|
760
|
+
elif at_house and self.loaded:
|
|
761
|
+
# tools: park at the door until the tool is done; otherwise unload now
|
|
762
|
+
if self.state not in TOOL_STATES:
|
|
763
|
+
self.pause_kind, self.pause_until = "unload", now + _PAUSE
|
|
764
|
+
elif at_pile and self.state in ("thinking", "coding"):
|
|
765
|
+
self.pause_kind, self.pause_until = "load", now + _PAUSE
|
|
766
|
+
|
|
767
|
+
def pieces(self, now: float) -> list[tuple[str, str]]:
|
|
768
|
+
"""(text, style) pieces for the line at time now."""
|
|
769
|
+
with self.lock:
|
|
770
|
+
self._step(now)
|
|
771
|
+
st, pos, right, loaded = self.state, self.pos, self.right, self.loaded
|
|
772
|
+
busy = now < self.pause_until
|
|
773
|
+
kind = self.kind
|
|
774
|
+
x = max(0, min(TRACK, int(round(pos))))
|
|
775
|
+
moving = self.motion and not busy and self._target() is not None and abs((self._target() or 0) - pos) > 0.01
|
|
776
|
+
if st == "failed":
|
|
777
|
+
loaded = False
|
|
778
|
+
wheel = _WHEEL[(int(pos * 2) if right else -int(pos * 2)) % 4] if moving else "◐"
|
|
779
|
+
cargo = ("▆", "bold " + _CARGO.get(kind, "#ffb347")) if loaded else ("▂", "#6b5f9e")
|
|
780
|
+
if st == "failed":
|
|
781
|
+
cargo = ("▂", "#ff5f6d")
|
|
782
|
+
if self.motion and busy: # lifting a parcel in or out: it bobs
|
|
783
|
+
cargo = ("▄" if int(now * 6) % 2 else cargo[0], cargo[1])
|
|
784
|
+
worker = ("ȯ" if moving and int(pos * 2) % 2 else "o", "bold #3ef0ff")
|
|
785
|
+
if st == "cancelled":
|
|
786
|
+
worker = ("o", "#6b5f9e")
|
|
787
|
+
cart = [worker, ("╼" if right else "╾", "#a096d2"), cargo, (wheel, "#e8e0ff")]
|
|
788
|
+
if not right:
|
|
789
|
+
cart.reverse()
|
|
790
|
+
track_w = TRACK + 4
|
|
791
|
+
lane = [("·", "#3a2f5c")] * track_w
|
|
792
|
+
for i, piece in enumerate(cart):
|
|
793
|
+
lane[min(track_w - 1, x + i)] = piece
|
|
794
|
+
at_house = x >= TRACK
|
|
795
|
+
pulse = self.motion and int(now * 3) % 2
|
|
796
|
+
pile = ("▤", "bold #ffb347" if (x == 0 and busy) else "#b8813a")
|
|
797
|
+
house_style = "#9c3c8c"
|
|
798
|
+
if at_house and (busy or (st in TOOL_STATES and loaded)):
|
|
799
|
+
house_style = "bold #ff4fd8" if pulse or not self.motion else "#ff4fd8"
|
|
800
|
+
house = ("⌂", house_style)
|
|
801
|
+
if st == "completed" and not loaded and at_house:
|
|
802
|
+
house = ("✓", "bold #7dff9a")
|
|
803
|
+
elif st == "failed":
|
|
804
|
+
house = ("⌂", "bold #ff5f6d")
|
|
805
|
+
out = [pile, (" ", "")] + lane + [(" ", ""), house]
|
|
806
|
+
if st == "waiting":
|
|
807
|
+
out.append((" zZ", "#6b5f9e"))
|
|
808
|
+
return out
|
|
809
|
+
|
|
810
|
+
def snapshot(self, now: float) -> dict:
|
|
811
|
+
"""Where everything is at time now, for the pixel courier (courier.py)."""
|
|
812
|
+
with self.lock:
|
|
813
|
+
self._step(now)
|
|
814
|
+
busy = now < self.pause_until
|
|
815
|
+
left = max(0.0, self.pause_until - now) / _PAUSE if busy else 0.0
|
|
816
|
+
if busy and self.pause_kind == "load":
|
|
817
|
+
fill = 1.0 - left
|
|
818
|
+
elif busy and self.pause_kind == "unload":
|
|
819
|
+
fill = left
|
|
820
|
+
else:
|
|
821
|
+
fill = 1.0 if self.loaded else 0.0
|
|
822
|
+
target = self._target()
|
|
823
|
+
moving = self.motion and not busy and target is not None and abs(target - self.pos) > 0.01
|
|
824
|
+
at_door = self.pos >= TRACK - 0.01
|
|
825
|
+
return {"state": self.state, "pos": self.pos, "track": TRACK, "right": self.right,
|
|
826
|
+
"moving": moving, "walked": self.walked, "kind": self.kind, "fill": fill,
|
|
827
|
+
"loaded": self.loaded, "busy": busy, "at_door": at_door,
|
|
828
|
+
"parked": at_door and self.loaded and self.state in TOOL_STATES,
|
|
829
|
+
"motion": self.motion, "delivered": self.delivered}
|
|
830
|
+
|
|
831
|
+
def text(self, now: float) -> str:
|
|
832
|
+
"""Plain text of the line (tests, terminals without colour)."""
|
|
833
|
+
return "".join(s for s, _ in self.pieces(now))
|