Repository navigation
Expand file tree
/
Copy pathtdp.py
More file actions
401 lines (365 loc) · 16.7 KB
/
Copy pathtdp.py
File metadata and controls
401 lines (365 loc) · 16.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
"""Spend Time Development Points on stats, one confirmed point at a time: ;tdp
;tdp INFO: the eight stats and the TDPs on hand (spends nothing)
;tdp <stat> what the next point costs (a prefix works: agi)
;tdp <stat> <N> ... and what reaching N costs (TDP PROJECT)
;tdp train <stat> walk to its trainer, TRAIN one point, walk back
;tdp train <stat> <N> <stat> +2 ... [stay]
several goals in order: to N, then two points;
`stay` stays in the last training room
;tdp plan [N] up to N points (3) where the training plan's `tdp` list says
;stop tdp quit at once
What it does
- Asks the stat's own command for its value, the next point's cost and the
TDPs before every point, and buys only what the TDPs cover.
- TRAINs twice in the stat's training room (the map tags it), then asks again:
a value that did not rise stops the run, so a surprise costs one point at most.
- Pays the coin fee from the purse; when it falls short, WITHDRAWs the rest at
the nearest teller rather than let it go on the provincial debt.
- Flags a stat below its race's starting value: train those first.
- `plan` spends past `tdp_reserve` on the plan's stat targets, or the guild's
tiers on `auto`; a ;train task runs it once a cycle.
- A run that bought a point has ;sheet record INFO at once.
When it stops, and walks back unless told to stay
- every goal reached, or the next point costs more than the TDPs on hand
- a value that did not rise, a teller that refuses, a trainer out of reach
- death, or ;stop tdp
client/game/tdp.py is the model; docs/training.md lists the plan's `tdp` keys.
"""
import re
from client.game import bank, money, probe, travel
from client.game.act import ask
from client.game.money import phrase
from client.game.tdp import (
STATS,
TRAIN_OUTCOMES,
below_start,
fee_of,
next_stat,
parse_goals,
parse_info,
parse_project,
parse_stat_answer,
plan_goals,
point_cost,
stat_name,
)
from client.game.mapdb import MapDB
from client.game.walker import locate, walk
# The design notes the manual above leaves out: what each rule came
# from, with its issue — read by people, never served as ;help.
_NOTES = """Spend Time Development Points on stats, one confirmed point at a time: ;tdp
;tdp INFO: the eight stats and the TDPs on hand (spends nothing)
;tdp agility what the next point costs, from the stat's own command
;tdp agility 12 ... and what reaching 12 costs (TDP PROJECT)
;tdp train agility walk to the Agility trainer, TRAIN one point, walk back
;tdp train agility 12 strength +2 [stay]
several goals in order: to 12, then two points of Strength;
`stay` stays in the last training room
;tdp plan [N] up to N points (3) where the training plan's `tdp` list says — the stat targets, or the guild's tiers on `auto` — leaving `tdp_reserve` unspent; the ;train task
A stat rises one point per TRAIN typed twice in that stat's training
room (the map tags it: `agility`, `strength`, ...), at the cost the
game quotes first (Elanthipedia: Attributes, Time Development Points,
Train command). Each point also costs a fee of 2 Kronars per TDP,
quoted by the first TRAIN ("There is also a fee of 70 Kronars to
complete this training."); a character carrying no coins has it added
to the provincial debt (captured 2026-09-12: 28 TDPs and 56 Kronars
for Agility 8 → 9; 674 copper owed by the end of 2026-09-20, a day
whose plan banked the purse right before the tdps task, #247). So the
script reads the quoted fee, checks the purse (WEALTH, money.purse),
and when it falls short WITHDRAWs the difference at the nearest
teller and walks back before the confirming TRAIN — a teller that
refuses stops the run, said. A stat below its race's starting value
is flagged: DR3 recalculates TDPs assuming every character started at
the racial values, so such a point comes back twice over at the next
recalculation (a DR1 character's rolled stats, #165) — train those
first. `plan` is the ;train task's form (the operator, 2026-09-20: a task
in the plan's order, not only the rest's spending, "then I could
structure the train loop with it"): the character's training plan
names where the TDPs go (`tdp`: stat targets, or `auto` for the
guild's tiers — client/game/tdp.py's GUILD_TIERS) and what to keep
(`tdp_reserve`), and each point is INFO, the next stat, its cost
from the stat's own command (before any walk: the race's modifier
is in it) against the points past the reserve, then one confirmed TRAIN pair
at its trainer; a `{"script": "tdp", "args": ["plan"]}` task runs
it once a cycle wherever the plan puts it. It never trusts the TRAIN wording alone:
it asks the stat's own command (AGILITY, STRENGTH, ...) for the value,
the next point's cost and the TDPs before every point, buys only what
the TDPs cover, and asks again after the pair — a value that did not
rise stops the run with the answers echoed, so an unexpected wording
costs at most one point. It stops on death or a refusal, and walks
back to where it started unless told to stay. A run that bought a
point ends by asking ;sheet to record INFO, so history.db and beholder
have the new stat and TDPs at once, not at the next three-hourly
snapshot (#303).
Stop with: ;stop tdp
"""
# The lines of a TRAIN pair worth showing when it worked: the fee
# (2 Kronars per TDP) and where it went — a coinless character's goes
# on the provincial debt (captured 2026-09-12).
_NOTE = re.compile(r"fee of|debt", re.IGNORECASE)
def echo_lines(s, text):
for line in text.splitlines():
if line.strip():
s.echo(f"tdp: {line.strip()}")
def show_info(s):
"""INFO's stats and TDPs, one line each; the parsed dict."""
info = parse_info(ask(s, "info"))
if not info["stats"]:
s.echo("tdp: INFO gave no stats — try again in a moment")
return info
width = max(len(name) for name in STATS)
for name in STATS:
value = info["stats"].get(name)
s.echo(f"tdp: {name:<{width}} {value if value is not None else '?':>4}")
s.echo(f"tdp: TDPs {info['tdps'] if info['tdps'] is not None else '?'}")
under = below_start(info.get("race"), info["stats"])
if under:
listed = ", ".join(
f"{stat} {info['stats'][stat]} (start {start})"
for stat, start in under.items()
)
s.echo(
f"tdp: below the {info['race']} starting stats: {listed} — DR3 hands "
"those points back twice over; train them first"
)
return info
def quote(s, stat, goal=None):
"""What the next point costs and, with a goal, the whole climb —
the game's own figures. Returns the stat answer's dict."""
answer = parse_stat_answer(ask(s, stat.lower()))
if answer["next_cost"] is None:
s.echo(f"tdp: {stat.upper()} gave no cost — is that a stat command here?")
return answer
s.echo(
f"tdp: {stat} {answer['value']} → {answer['value'] + 1} costs "
f"{answer['next_cost']} TDPs; you have {answer['tdps']}"
)
if goal is not None and goal > (answer["value"] or 0) + 1:
project = parse_project(ask(s, f"tdp project {stat.lower()} {goal}"))
if project is None:
s.echo("tdp: TDP PROJECT gave no figure")
else:
s.echo(f"tdp: {stat} to {goal} costs {project['cost']} TDPs in all")
return answer
def train_one(s, stat, before, fetch_fee=None):
"""TRAIN twice, then the stat's command: (rose, answer). The first
TRAIN quotes the coin fee beside the moon cycles; `fetch_fee(copper,
currency)` — None when the purse covers it, True when it fetched
the coins (the quote is asked again from the trainer's room), False
when it could not — keeps the fee off the province's debt (#247).
The two TRAIN answers are echoed when the value did not rise."""
first = ask(s, "train")
outcome = probe.classify(first, TRAIN_OUTCOMES)
fee = fee_of(first)
if outcome != "refused" and fee and fetch_fee is not None:
fetched = fetch_fee(*fee)
if fetched is False:
return False, parse_stat_answer(ask(s, stat.lower()))
if fetched:
first = ask(s, "train")
second = ""
if outcome != "refused":
second = ask(s, "train")
after = parse_stat_answer(ask(s, stat.lower()))
rose = after["value"] is not None and after["value"] > before
if rose:
echo_lines(
s, "\n".join(line for line in second.splitlines() if _NOTE.search(line))
)
else:
echo_lines(s, first + "\n" + second)
return rose, after
def train_stat(s, stat, goal, mapdb, walk_fn, bought=None):
"""Walk to the stat's trainer and buy points up to the goal or the
TDPs. True when every point up to the goal was bought; each point
bought is appended to `bought` as (stat, value)."""
rooms = set(mapdb.rooms_tagged(stat.lower()))
if not travel.go(s, rooms, f"the {stat} trainer", db=mapdb, walk=walk_fn):
s.echo(f"tdp: could not reach the {stat} trainer — stopping")
return False
def fetch_fee(copper, currency):
"""The fee in the purse before the confirming TRAIN: WEALTH's
carried coins (money.purse) against it, the difference WITHDRAWn
at the nearest teller and the walk back to the trainer (#247).
None when the purse covers it or WEALTH gave no purse to read,
True when fetched, False when the teller refused or the walk
failed."""
carried = money.purse(s, ask)
if not carried:
return None
have = carried.get(currency, 0)
if have >= copper:
return None
s.echo(
f"tdp: the fee is {phrase(copper, currency)} and you carry "
f"{phrase(have, currency)} — fetching the rest from the teller, "
"not the province's debt"
)
if not bank.withdraw(
s,
mapdb,
walk_fn,
ask,
"tdp",
copper - have,
currency,
retry="run ;tdp again",
):
return False
if not travel.go(s, rooms, f"the {stat} trainer", db=mapdb, walk=walk_fn):
s.echo(f"tdp: could not walk back to the {stat} trainer — stopping")
return False
return True
answer = quote(s, stat)
while answer["value"] is not None and answer["value"] < goal:
if s.dead:
s.echo("tdp: you are dead — stopping")
return False
cost, have = answer["next_cost"], answer["tdps"]
if cost is None or have is None:
s.echo(f"tdp: no cost or TDP figure for {stat} — stopping")
return False
if cost > have:
s.echo(f"tdp: the next point costs {cost} and you have {have} — stopping")
return False
rose, answer = train_one(s, stat, answer["value"], fetch_fee)
if not rose:
s.echo(f"tdp: {stat} did not rise — stopping before another point")
return False
s.echo(f"tdp: {stat} is now {answer['value']}, TDPs {answer['tdps']}")
if bought is not None:
bought.append((stat, answer["value"]))
return answer["value"] is not None and answer["value"] >= goal
PLAN_POINTS = 3 # points one `;tdp plan` buys unless told otherwise
def plan_points(s, mapdb, walk_fn, points=PLAN_POINTS, bought_points=None):
"""Up to `points` points where the character's training plan says:
INFO for the stats, the points and the guild, the plan's goals or
the guild's tiers for the stat, the stat command's quoted cost
against the points past `tdp_reserve` — asked before any walk —
then one confirmed point at its trainer; the
walk back at the end. The points bought."""
from client.game.training import load_plan
plan = load_plan(getattr(s.state, "name", None) or "")
entries = plan.get("tdp") or []
if not entries:
s.echo("tdp: the training plan's tdp list is empty — nothing to spend on")
return 0
reserve = int(plan.get("tdp_reserve") or 0)
start = locate(mapdb, s.state)
bought = 0
for _ in range(max(points, 0)):
info = parse_info(ask(s, "info"))
if info["tdps"] is None or not info["stats"]:
s.echo("tdp: INFO gave no TDPs — stopping")
break
try:
goals = plan_goals(entries, info["stats"])
except ValueError as error:
s.echo(f"tdp: the plan's tdp list — {error}")
break
choice = next_stat(info["stats"], goals, info.get("guild"))
if choice is None:
s.echo("tdp: every goal of the plan reached")
break
stat, value = choice
# The stat's own command quotes the cost anywhere, race modifier
# included; the wiki's bare formula said 48 for Reflex 16, the
# game 56, and the walk to the trainer went for nothing
# (2026-09-27).
cost = parse_stat_answer(ask(s, stat.lower()))["next_cost"]
if cost is None:
cost = point_cost(value)
if info["tdps"] - reserve < cost:
s.echo(
f"tdp: {stat} {value} → {value + 1} costs {cost} and "
f"{info['tdps']} on hand keeps {reserve} — stopping"
)
break
if not train_stat(s, stat, value + 1, mapdb, walk_fn, bought_points):
break
bought += 1
if start is not None and locate(mapdb, s.state) != start:
if not travel.go(s, start, "where you started", db=mapdb, walk=walk_fn):
s.echo("tdp: could not walk back — you are at the trainer")
s.echo(f"tdp: {bought} point(s) bought by the plan")
return bought
def refresh_sheet(s):
"""A bought point on the record at once (the operator, 2026-09-24,
#303): the running ;sheet — an autostart — records INFO, all TRAIN
moved (the stat and the TDPs), or ;sheet info starts when it is not
running. Otherwise history.db held the old stat until the next
three-hourly snapshot."""
running = getattr(s, "is_running", None)
if not callable(running):
return
if running("sheet"):
s.tell("sheet", "info")
else:
s.run("sheet", ["info"])
s.echo("tdp: the sheet records INFO's new stats")
def run(s, words, mapdb=None, walk_fn=walk):
"""The verb: show, quote, train, or plan; a run that bought a point
ends with a ;sheet snapshot."""
bought = []
try:
_run(s, words, mapdb, walk_fn, bought)
finally:
if bought:
refresh_sheet(s)
def _run(s, words, mapdb, walk_fn, bought):
if not words:
show_info(s)
return
if words[0].lower() == "plan":
if mapdb is None:
s.echo("tdp: training needs the map — none loaded")
return
points = int(words[1]) if len(words) > 1 and str(words[1]).isdigit() else None
plan_points(
s, mapdb, walk_fn, PLAN_POINTS if points is None else points, bought
)
return
training = words[0].lower() == "train"
stay = training and words[-1].lower() == "stay"
goal_words = words[1 : -1 if stay else None] if training else words
if training and not goal_words:
s.echo("usage: ;tdp train <stat> [goal | +n] [<stat> ...] [stay]")
return
if not training:
stat = stat_name(goal_words[0])
if stat is None:
s.echo(f"tdp: {goal_words[0]!r} names no stat — one of {', '.join(STATS)}")
return
goal = (
int(goal_words[1])
if len(goal_words) > 1 and goal_words[1].isdigit()
else None
)
quote(s, stat, goal)
return
if mapdb is None:
s.echo("tdp: training needs the map — none loaded")
return
info = show_info(s)
try:
goals = parse_goals(goal_words, info["stats"])
except ValueError as error:
s.echo(f"tdp: {error}")
return
start = locate(mapdb, s.state)
for stat, goal in goals:
if not train_stat(s, stat, goal, mapdb, walk_fn, bought):
break
else:
s.echo("tdp: every goal reached")
if not stay and start is not None and locate(mapdb, s.state) != start:
if not travel.go(s, start, "where you started", db=mapdb, walk=walk_fn):
s.echo("tdp: could not walk back — you are at the trainer")
show_info(s)
def main(s):
words = list(s.args)
if words and words[0].lower() in ("train", "plan"):
mapdb = MapDB.load()
run(s, words, mapdb=mapdb)
else:
run(s, words)