wingspan-rl 1.0.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.
- wingspan/__init__.py +3 -0
- wingspan/__main__.py +134 -0
- wingspan/agents/__init__.py +20 -0
- wingspan/agents/base.py +34 -0
- wingspan/agents/cli.py +278 -0
- wingspan/agents/display.py +596 -0
- wingspan/agents/interactive.py +435 -0
- wingspan/aid/__init__.py +8 -0
- wingspan/aid/advisor.py +297 -0
- wingspan/aid/app.py +334 -0
- wingspan/aid/console.py +147 -0
- wingspan/aid/entry.py +385 -0
- wingspan/aid/hooks.py +202 -0
- wingspan/aid/models.py +193 -0
- wingspan/aid/oracle.py +287 -0
- wingspan/aid/oracle_state.py +192 -0
- wingspan/aid/placeholders.py +88 -0
- wingspan/aid/preview.py +84 -0
- wingspan/aid/relay.py +284 -0
- wingspan/aid/widgets.py +376 -0
- wingspan/analysis/__init__.py +21 -0
- wingspan/analysis/attention_probe.py +356 -0
- wingspan/analysis/cli.py +424 -0
- wingspan/analysis/head_to_head.py +56 -0
- wingspan/analysis/layer_probe.py +233 -0
- wingspan/analysis/models.py +268 -0
- wingspan/analysis/probe_set.py +144 -0
- wingspan/analysis/representation.py +596 -0
- wingspan/architecture.py +884 -0
- wingspan/cards/__init__.py +82 -0
- wingspan/cards/lookup.py +187 -0
- wingspan/cards/parse/__init__.py +69 -0
- wingspan/cards/parse/catalog.py +67 -0
- wingspan/cards/parse/fields.py +172 -0
- wingspan/cards/parse/loader.py +93 -0
- wingspan/cards/parse/matchers.py +779 -0
- wingspan/cards/parse/pink_matchers.py +117 -0
- wingspan/cards/parse/power.py +59 -0
- wingspan/cards/parse/registry.py +40 -0
- wingspan/cards/parse/tags.py +38 -0
- wingspan/cards/schema.py +498 -0
- wingspan/cli.py +494 -0
- wingspan/cloud/__init__.py +33 -0
- wingspan/cloud/__main__.py +104 -0
- wingspan/cloud/monitor.py +159 -0
- wingspan/cloud/runfile.py +106 -0
- wingspan/cloud/runner.py +295 -0
- wingspan/cloud/s3sync.py +242 -0
- wingspan/cloud/status.py +147 -0
- wingspan/compat/__init__.py +139 -0
- wingspan/compat/v1_0.py +173 -0
- wingspan/compat/v1_3.py +229 -0
- wingspan/compat/v1_4.py +405 -0
- wingspan/data/bonus.json +1030 -0
- wingspan/data/general.json +172 -0
- wingspan/data/goals.json +618 -0
- wingspan/data/master.json +57535 -0
- wingspan/data/parameters.json +5 -0
- wingspan/data/release/last.pt +0 -0
- wingspan/data/release/manifest.json +8 -0
- wingspan/data/release/run_config_20261021-113648.json +222 -0
- wingspan/data/release/setup.pt +0 -0
- wingspan/decisions.py +850 -0
- wingspan/encode/__init__.py +172 -0
- wingspan/encode/choice_encode.py +1715 -0
- wingspan/encode/layout.py +1257 -0
- wingspan/encode/state_encode.py +726 -0
- wingspan/encode/stripes/__init__.py +60 -0
- wingspan/encode/stripes/card_feature.py +271 -0
- wingspan/encode/stripes/choice.py +944 -0
- wingspan/encode/stripes/descriptors.py +236 -0
- wingspan/encode/stripes/embed_rules.py +411 -0
- wingspan/encode/stripes/state.py +1124 -0
- wingspan/engine/__init__.py +20 -0
- wingspan/engine/actions.py +1038 -0
- wingspan/engine/core.py +840 -0
- wingspan/engine/forecast.py +448 -0
- wingspan/engine/helpers.py +219 -0
- wingspan/engine/ledger.py +597 -0
- wingspan/engine/log_format.py +275 -0
- wingspan/engine/playability.py +553 -0
- wingspan/engine/powers/__init__.py +52 -0
- wingspan/engine/powers/dispatch.py +263 -0
- wingspan/engine/powers/drafting.py +268 -0
- wingspan/engine/powers/egg_trade.py +211 -0
- wingspan/engine/powers/grants.py +855 -0
- wingspan/engine/powers/multi_actor.py +316 -0
- wingspan/engine/powers/nest_aggregate.py +145 -0
- wingspan/engine/powers/predator_repeat.py +222 -0
- wingspan/engine/powers/registry.py +51 -0
- wingspan/engine/powers/tray_trade.py +266 -0
- wingspan/engine/reactors.py +346 -0
- wingspan/engine/scoring.py +1171 -0
- wingspan/engine/setup_flow.py +244 -0
- wingspan/gamelog/__init__.py +6 -0
- wingspan/gamelog/models.py +747 -0
- wingspan/gamelog/recorder.py +589 -0
- wingspan/gamelog/render_jsonl.py +247 -0
- wingspan/gamelog/render_text.py +86 -0
- wingspan/gamelog/summarize.py +380 -0
- wingspan/instrumentation/__init__.py +26 -0
- wingspan/instrumentation/config.py +148 -0
- wingspan/instrumentation/dispatcher.py +243 -0
- wingspan/instrumentation/events.py +301 -0
- wingspan/instrumentation/handlers/__init__.py +27 -0
- wingspan/instrumentation/handlers/card_visits.py +58 -0
- wingspan/instrumentation/handlers/decision_logger.py +56 -0
- wingspan/instrumentation/handlers/game_log_html.py +199 -0
- wingspan/instrumentation/handlers/game_stats.py +193 -0
- wingspan/instrumentation/registry.py +57 -0
- wingspan/model/__init__.py +12 -0
- wingspan/model/core.py +1106 -0
- wingspan/model/hand_model.py +130 -0
- wingspan/model/mlp.py +102 -0
- wingspan/players/__init__.py +55 -0
- wingspan/players/decision_probe.py +83 -0
- wingspan/players/factory.py +347 -0
- wingspan/players/loaders.py +239 -0
- wingspan/players/spec.py +114 -0
- wingspan/players/value_sink.py +38 -0
- wingspan/release.py +63 -0
- wingspan/release_prep.py +138 -0
- wingspan/reporting/__init__.py +13 -0
- wingspan/reporting/card_view.py +281 -0
- wingspan/reporting/encode_viewer.py +707 -0
- wingspan/reporting/game_log_capture.py +655 -0
- wingspan/reporting/game_log_csv.py +115 -0
- wingspan/reporting/game_log_html.py +1551 -0
- wingspan/reporting/html.py +946 -0
- wingspan/reporting/humanize.py +319 -0
- wingspan/reporting/inspect_cli.py +448 -0
- wingspan/reporting/summary_cli.py +61 -0
- wingspan/reporting/svg.py +1891 -0
- wingspan/reporting/training_summary.py +1789 -0
- wingspan/research/__init__.py +8 -0
- wingspan/research/app.py +250 -0
- wingspan/research/constants.py +46 -0
- wingspan/research/game_stats.py +235 -0
- wingspan/research/game_stats_agg.py +760 -0
- wingspan/research/models.py +525 -0
- wingspan/research/progress.py +104 -0
- wingspan/research/setup_keep.py +355 -0
- wingspan/sampling.py +24 -0
- wingspan/setup_model/__init__.py +74 -0
- wingspan/setup_model/architecture.py +447 -0
- wingspan/setup_model/candidates.py +135 -0
- wingspan/setup_model/encode.py +416 -0
- wingspan/setup_model/generate.py +204 -0
- wingspan/setup_model/record.py +60 -0
- wingspan/setup_model/stripes.py +596 -0
- wingspan/state.py +896 -0
- wingspan/tournament/__init__.py +34 -0
- wingspan/tournament/app.py +359 -0
- wingspan/tournament/dashboard.py +166 -0
- wingspan/tournament/elo.py +36 -0
- wingspan/tournament/models.py +360 -0
- wingspan/tournament/participants.py +227 -0
- wingspan/tournament/picker.py +172 -0
- wingspan/tournament/results.py +155 -0
- wingspan/tournament/runner.py +394 -0
- wingspan/tournament/schedule.py +76 -0
- wingspan/tournament/state.py +146 -0
- wingspan/training/__init__.py +37 -0
- wingspan/training/__main__.py +8 -0
- wingspan/training/app.py +576 -0
- wingspan/training/artifacts.py +93 -0
- wingspan/training/batched_collect.py +418 -0
- wingspan/training/charts/__init__.py +34 -0
- wingspan/training/charts/braille.py +79 -0
- wingspan/training/charts/convergence_chart.py +680 -0
- wingspan/training/charts/geometry.py +57 -0
- wingspan/training/charts/histogram.py +110 -0
- wingspan/training/charts/insets.py +270 -0
- wingspan/training/charts/text_helpers.py +84 -0
- wingspan/training/collect.py +589 -0
- wingspan/training/config.py +1366 -0
- wingspan/training/config_file.py +142 -0
- wingspan/training/configure/__init__.py +19 -0
- wingspan/training/configure/arch_diagram.py +1449 -0
- wingspan/training/configure/controller.py +838 -0
- wingspan/training/configure/fields.py +1772 -0
- wingspan/training/configure/keys.py +193 -0
- wingspan/training/configure/runs.py +398 -0
- wingspan/training/configure/screen.py +727 -0
- wingspan/training/configure/state.py +129 -0
- wingspan/training/configure/user_defaults.py +187 -0
- wingspan/training/convergence.py +215 -0
- wingspan/training/cpu_threads.py +44 -0
- wingspan/training/dashboard.py +959 -0
- wingspan/training/evaluate.py +306 -0
- wingspan/training/learner.py +1062 -0
- wingspan/training/loop.py +378 -0
- wingspan/training/loop_anneal.py +98 -0
- wingspan/training/loop_checkpoint.py +259 -0
- wingspan/training/loop_collect.py +187 -0
- wingspan/training/loop_eval.py +314 -0
- wingspan/training/loop_metrics.py +187 -0
- wingspan/training/loop_probe.py +91 -0
- wingspan/training/loop_resume.py +367 -0
- wingspan/training/loop_setup.py +246 -0
- wingspan/training/loop_target.py +163 -0
- wingspan/training/metrics.py +361 -0
- wingspan/training/metrics_log.py +79 -0
- wingspan/training/mp_collect.py +841 -0
- wingspan/training/policy.py +246 -0
- wingspan/training/returns.py +133 -0
- wingspan/training/runmeta.py +429 -0
- wingspan/training/runstate.py +687 -0
- wingspan/training/setup_learner.py +226 -0
- wingspan/training/setup_net.py +491 -0
- wingspan/training/setup_runmeta.py +117 -0
- wingspan/training/steps.py +58 -0
- wingspan/training/sysmon.py +60 -0
- wingspan/training/theme.py +202 -0
- wingspan/training/timestamps.py +205 -0
- wingspan/version.py +308 -0
- wingspan_rl-1.0.0.dist-info/METADATA +420 -0
- wingspan_rl-1.0.0.dist-info/RECORD +222 -0
- wingspan_rl-1.0.0.dist-info/WHEEL +5 -0
- wingspan_rl-1.0.0.dist-info/entry_points.txt +2 -0
- wingspan_rl-1.0.0.dist-info/licenses/LICENSE +21 -0
- wingspan_rl-1.0.0.dist-info/top_level.txt +1 -0
wingspan/__init__.py
ADDED
wingspan/__main__.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Unified ``wingspan`` CLI dispatcher.
|
|
2
|
+
|
|
3
|
+
Usage: wingspan <command> [args …]
|
|
4
|
+
wingspan -V | --version (package version, artifact era, bundled model)
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
play Play games between any mix of human, random, and trained-AI seats.
|
|
8
|
+
aid Live assistant for a physical 2-player game (model advice + board tracking).
|
|
9
|
+
dashboard FLIGHT PLAN: configure a run, then train and monitor it live.
|
|
10
|
+
tournament Round-robin tournament between trained AIs.
|
|
11
|
+
cloud Run a training job headless with S3 persistence.
|
|
12
|
+
monitor FLOCK WATCH: watch all cloud runs from their S3 status snapshots.
|
|
13
|
+
inspect Print the model introspection report.
|
|
14
|
+
summary Training-progression HTML report for a run directory.
|
|
15
|
+
research Offline research studies over a trained checkpoint (docs/RESEARCH.md).
|
|
16
|
+
analysis Offline architecture-importance probe over a trained checkpoint
|
|
17
|
+
(docs/TRAINING.md "6.5 Representation diagnostics").
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
# One-line description per verb — printed by the top-level help.
|
|
25
|
+
_VERBS: dict[str, str] = {
|
|
26
|
+
"play": "Play games between any mix of human, random, and trained-AI seats.",
|
|
27
|
+
"aid": "Live assistant for a physical 2-player game (model advice + board tracking).",
|
|
28
|
+
"dashboard": "FLIGHT PLAN: configure a run, then train and monitor it live.",
|
|
29
|
+
"tournament": "Round-robin tournament between trained AIs.",
|
|
30
|
+
"cloud": "Run a training job headless with S3 persistence.",
|
|
31
|
+
"monitor": "FLOCK WATCH: watch all cloud runs from their S3 status snapshots.",
|
|
32
|
+
"inspect": "Print the model introspection report.",
|
|
33
|
+
"summary": "Training-progression HTML report for a run directory.",
|
|
34
|
+
"research": "Offline research studies over a trained checkpoint (docs/RESEARCH.md).",
|
|
35
|
+
"analysis": "Offline architecture-importance probe over a trained checkpoint.",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str] | None = None) -> int:
|
|
40
|
+
"""Entry point for the unified ``wingspan`` command."""
|
|
41
|
+
args = list(argv) if argv is not None else sys.argv[1:]
|
|
42
|
+
if not args or args[0] in ("-h", "--help"):
|
|
43
|
+
_print_help()
|
|
44
|
+
return 0
|
|
45
|
+
if args[0] in ("-V", "--version"):
|
|
46
|
+
_print_version()
|
|
47
|
+
return 0
|
|
48
|
+
verb, rest = args[0], args[1:]
|
|
49
|
+
match verb:
|
|
50
|
+
case "play":
|
|
51
|
+
import wingspan.cli as cli
|
|
52
|
+
|
|
53
|
+
return cli.main_play(rest)
|
|
54
|
+
case "aid":
|
|
55
|
+
import wingspan.aid.app as aid_app
|
|
56
|
+
|
|
57
|
+
return aid_app.main(rest)
|
|
58
|
+
case "dashboard":
|
|
59
|
+
import wingspan.training.app as training_app
|
|
60
|
+
|
|
61
|
+
return training_app.main(rest)
|
|
62
|
+
case "tournament":
|
|
63
|
+
import wingspan.tournament.app as tournament_app
|
|
64
|
+
|
|
65
|
+
return tournament_app.main(rest)
|
|
66
|
+
case "cloud":
|
|
67
|
+
import wingspan.cloud.__main__ as cloud_entry
|
|
68
|
+
|
|
69
|
+
return cloud_entry.main(rest)
|
|
70
|
+
case "monitor":
|
|
71
|
+
import wingspan.cloud.monitor as cloud_monitor
|
|
72
|
+
|
|
73
|
+
return cloud_monitor.main(rest)
|
|
74
|
+
case "inspect":
|
|
75
|
+
from wingspan.reporting import inspect_cli as introspect
|
|
76
|
+
|
|
77
|
+
return introspect.main_inspect(rest)
|
|
78
|
+
case "summary":
|
|
79
|
+
from wingspan.reporting import summary_cli
|
|
80
|
+
|
|
81
|
+
return summary_cli.main_summary(rest)
|
|
82
|
+
case "research":
|
|
83
|
+
import wingspan.research.app as research_app
|
|
84
|
+
|
|
85
|
+
return research_app.main(rest)
|
|
86
|
+
case "analysis":
|
|
87
|
+
import wingspan.analysis.cli as analysis_cli
|
|
88
|
+
|
|
89
|
+
return analysis_cli.main_analysis(rest)
|
|
90
|
+
case _:
|
|
91
|
+
print(
|
|
92
|
+
f"wingspan: unknown command '{verb}' (try `wingspan --help`)",
|
|
93
|
+
file=sys.stderr,
|
|
94
|
+
)
|
|
95
|
+
return 1
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
###### PRIVATE #######
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _print_version() -> None:
|
|
102
|
+
"""Print the package version, the artifact era, and the bundled model."""
|
|
103
|
+
import wingspan
|
|
104
|
+
from wingspan import release, version
|
|
105
|
+
|
|
106
|
+
print(f"wingspan-rl {wingspan.__version__} (artifact era {version.MODEL_VERSION})")
|
|
107
|
+
if release.has_release():
|
|
108
|
+
manifest = release.load_manifest()
|
|
109
|
+
if manifest is not None:
|
|
110
|
+
print(
|
|
111
|
+
f"bundled model: era {manifest.era}, run {manifest.run_name}, "
|
|
112
|
+
f"iteration {manifest.iteration}"
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
print("bundled model: present (no manifest)")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _print_help() -> None:
|
|
119
|
+
"""Print the top-level usage table."""
|
|
120
|
+
width = max(len(verb) for verb in _VERBS)
|
|
121
|
+
lines = [
|
|
122
|
+
"usage: wingspan <command> [args …]",
|
|
123
|
+
"",
|
|
124
|
+
"Commands:",
|
|
125
|
+
*(f" {verb:<{width}} {desc}" for verb, desc in _VERBS.items()),
|
|
126
|
+
"",
|
|
127
|
+
"Run `wingspan <command> --help` for per-command usage.",
|
|
128
|
+
"Run `wingspan --version` for the package version and bundled model.",
|
|
129
|
+
]
|
|
130
|
+
print("\n".join(lines))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Agents that resolve Decisions.
|
|
2
|
+
|
|
3
|
+
An agent is any callable matching ``wingspan.engine.core.Agent`` — given the
|
|
4
|
+
engine and a ``Decision[C]``, it returns the chosen ``C``. The Agent type
|
|
5
|
+
itself lives next to the Engine; this package collects implementations:
|
|
6
|
+
|
|
7
|
+
- ``base`` — the random-policy agent
|
|
8
|
+
- ``cli`` — the interactive human (stdin/stdout) agent
|
|
9
|
+
|
|
10
|
+
The public entry points are re-exported so callers can keep writing
|
|
11
|
+
``from wingspan.agents import cli_agent``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from wingspan.agents.base import random_agent
|
|
15
|
+
from wingspan.agents.cli import cli_agent
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"random_agent",
|
|
19
|
+
"cli_agent",
|
|
20
|
+
]
|
wingspan/agents/base.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""The random-policy agent.
|
|
2
|
+
|
|
3
|
+
This is the minimal surface area every agent implementation depends on: a
|
|
4
|
+
tiny default agent that just picks a uniformly-random offered choice. Other
|
|
5
|
+
modules (e.g. ``cli``) build on this without pulling the interactive bits.
|
|
6
|
+
|
|
7
|
+
The shared callable type is ``wingspan.engine.core.Agent`` — a Protocol with
|
|
8
|
+
a generic ``__call__`` so the returned Choice subtype tracks the Decision's
|
|
9
|
+
parameterization automatically.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import random
|
|
15
|
+
|
|
16
|
+
from wingspan import decisions
|
|
17
|
+
from wingspan.engine import core as engine_core
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def random_agent(rng: random.Random | None = None) -> engine_core.Agent:
|
|
21
|
+
"""Build an agent that picks uniformly at random from the offered choices.
|
|
22
|
+
|
|
23
|
+
A fresh ``random.Random`` is allocated when ``rng`` is omitted so callers
|
|
24
|
+
that want reproducibility can supply a seeded one.
|
|
25
|
+
"""
|
|
26
|
+
chooser = rng or random.Random()
|
|
27
|
+
|
|
28
|
+
def agent[C: decisions.Choice](
|
|
29
|
+
_engine: engine_core.Engine,
|
|
30
|
+
decision: decisions.Decision[C],
|
|
31
|
+
) -> C:
|
|
32
|
+
return chooser.choice(decision.choices)
|
|
33
|
+
|
|
34
|
+
return agent
|
wingspan/agents/cli.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Interactive human (stdin/stdout) agent.
|
|
2
|
+
|
|
3
|
+
The CLI agent walks a human through the offered choices index-by-index.
|
|
4
|
+
The combined ``SetupDecision`` is special-cased: enumerating all 504 legal
|
|
5
|
+
setup combinations on one screen is unusable, so the setup pick is broken
|
|
6
|
+
into three sub-dialogs (cards / foods / bonus) — each driven by an
|
|
7
|
+
arrow-key checkbox/radio widget from ``agents.interactive`` — and the
|
|
8
|
+
human's answer is reassembled back into one of the offered ``SetupChoice``
|
|
9
|
+
instances.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import typing
|
|
15
|
+
|
|
16
|
+
from wingspan import cards, decisions, state
|
|
17
|
+
from wingspan.agents import display, interactive
|
|
18
|
+
from wingspan.engine import core as engine_core
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cli_agent() -> engine_core.Agent:
|
|
22
|
+
"""Interactive human agent. Prints prompt and choices, reads index."""
|
|
23
|
+
interactive.enable_ansi()
|
|
24
|
+
|
|
25
|
+
def agent[C: decisions.Choice](
|
|
26
|
+
engine: engine_core.Engine,
|
|
27
|
+
decision: decisions.Decision[C],
|
|
28
|
+
) -> C:
|
|
29
|
+
if isinstance(decision, decisions.SetupDecision):
|
|
30
|
+
# SetupDecision is Decision[SetupChoice], but the type checker
|
|
31
|
+
# can't propagate that narrowing back onto the bound C — so the
|
|
32
|
+
# SetupChoice return is cast through.
|
|
33
|
+
return typing.cast(
|
|
34
|
+
C,
|
|
35
|
+
resolve_setup_choice_dialog(
|
|
36
|
+
decision,
|
|
37
|
+
[b for b in engine.state.tray if b is not None],
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
# The main-action prompt — and the play-bird menu it can open — are the
|
|
41
|
+
# natural moments to show the full board: the human needs the resource
|
|
42
|
+
# picture both to pick an action type and to pick which bird to play.
|
|
43
|
+
if isinstance(
|
|
44
|
+
decision,
|
|
45
|
+
(decisions.MainActionDecision, decisions.PlayBirdDecision),
|
|
46
|
+
):
|
|
47
|
+
print()
|
|
48
|
+
print(
|
|
49
|
+
display.format_board(
|
|
50
|
+
engine.state, engine.state.players[decision.player_id]
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
print()
|
|
54
|
+
print(decision.prompt)
|
|
55
|
+
player = engine.state.players[decision.player_id]
|
|
56
|
+
for i, choice in enumerate(decision.choices):
|
|
57
|
+
print(format_choice_line(i, choice, player))
|
|
58
|
+
while True:
|
|
59
|
+
raw = input("choice> ").strip()
|
|
60
|
+
try:
|
|
61
|
+
idx = int(raw)
|
|
62
|
+
except ValueError:
|
|
63
|
+
print(" enter a number")
|
|
64
|
+
continue
|
|
65
|
+
if 0 <= idx < len(decision.choices):
|
|
66
|
+
return typing.cast(C, decision.choices[idx])
|
|
67
|
+
print(" out of range")
|
|
68
|
+
|
|
69
|
+
return agent
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def format_choice_line(
|
|
73
|
+
idx: int,
|
|
74
|
+
choice: decisions.Choice,
|
|
75
|
+
player: state.Player,
|
|
76
|
+
*,
|
|
77
|
+
show_index: bool = True,
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Render one offered choice line with type-aware extra context.
|
|
80
|
+
|
|
81
|
+
Bird- and bonus-card-carrying choices are expanded to show food cost,
|
|
82
|
+
power text, and scoring conditions respectively — the engine's stored
|
|
83
|
+
``label`` is too terse for a human at decision time. A bonus card also
|
|
84
|
+
gets a second line with how useful it is to ``player`` right now. Other
|
|
85
|
+
Choice subclasses fall through to ``label`` as-is.
|
|
86
|
+
|
|
87
|
+
``show_index`` controls whether the line is prefixed with a `` [idx] ``
|
|
88
|
+
marker; callers that display a bare label out of index order (e.g. the
|
|
89
|
+
aid advisor's probability-ranked recommendation lines) pass
|
|
90
|
+
``show_index=False`` and get the label text with no leading indent.
|
|
91
|
+
"""
|
|
92
|
+
prefix = f" [{idx}] " if show_index else ""
|
|
93
|
+
if isinstance(choice, decisions.PlayBirdChoice):
|
|
94
|
+
# The board (printed in full above the main-action prompt) already
|
|
95
|
+
# shows every hand card's stats and power, so a play option only needs
|
|
96
|
+
# the bird name and target habitat; the costs are follow-up prompts.
|
|
97
|
+
return f"{prefix}play {choice.bird.name} in {choice.habitat.value}"
|
|
98
|
+
if isinstance(choice, decisions.FoodPaymentChoice):
|
|
99
|
+
# One complete payment for the committed play; the parent decision's
|
|
100
|
+
# prompt already names the bird being paid for.
|
|
101
|
+
return f"{prefix}pay {display.format_food_pool(choice.payment)}"
|
|
102
|
+
if isinstance(choice, decisions.BirdChoice):
|
|
103
|
+
return f"{prefix}{display.format_bird_full(choice.bird)}"
|
|
104
|
+
if isinstance(choice, decisions.BonusCardChoice):
|
|
105
|
+
head = f"{prefix}{display.format_bonus(choice.bonus_card)}"
|
|
106
|
+
return (
|
|
107
|
+
f"{head}\n "
|
|
108
|
+
f"{display.format_bonus_score_now(choice.bonus_card, player)}"
|
|
109
|
+
)
|
|
110
|
+
if isinstance(choice, decisions.PlayedBirdChoice):
|
|
111
|
+
return f"{prefix}{display.format_played_bird(choice.played_bird)}"
|
|
112
|
+
if isinstance(choice, decisions.DrawSourceChoice):
|
|
113
|
+
if choice.bird is not None:
|
|
114
|
+
return f"{prefix}{display.format_bird_full(choice.bird)}"
|
|
115
|
+
return f"{prefix}Draw from the Deck"
|
|
116
|
+
return f"{prefix}{choice.display_label()}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def setup_dialog_axes(decision: decisions.SetupDecision) -> tuple[bool, bool]:
|
|
120
|
+
"""Which of the setup dialog's two optional axes the offered choices carry.
|
|
121
|
+
|
|
122
|
+
Returns ``(ask_bonus, ask_food)``. Under the split-setup regimes the
|
|
123
|
+
engine resolves the bonus pick and/or the food pick as separate later
|
|
124
|
+
decisions, so the offered ``SetupChoice``s come back with that axis
|
|
125
|
+
pinned to its empty value on every option: ``bonus_card=None`` for a
|
|
126
|
+
deferred bonus pick, ``kept_foods=()`` for a deferred food pick. This is
|
|
127
|
+
a sound test for "deferred" because under the non-split regime the
|
|
128
|
+
candidate enumerator (``setup_model/candidates.py``) always assigns a
|
|
129
|
+
dealt bonus card to every choice, and the keep-0-cards choice always
|
|
130
|
+
carries all five foods — so all-``None``/all-empty across *every* choice
|
|
131
|
+
only happens when the axis is genuinely deferred, never as a byproduct of
|
|
132
|
+
the non-split enumeration.
|
|
133
|
+
|
|
134
|
+
The one other way ``bonus_card`` is ``None`` on every choice is when no
|
|
135
|
+
bonus cards were dealt at all. That collapses onto the same correct
|
|
136
|
+
behavior: with an empty ``dealt_bonus`` the dialog already omits the
|
|
137
|
+
bonus section (``_cli_pick_hand_and_bonus`` returns ``None`` for it), so
|
|
138
|
+
treating that case as "don't ask" changes nothing observable.
|
|
139
|
+
"""
|
|
140
|
+
ask_bonus = any(choice.bonus_card is not None for choice in decision.choices)
|
|
141
|
+
ask_food = any(choice.kept_foods for choice in decision.choices)
|
|
142
|
+
return ask_bonus, ask_food
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def resolve_setup_choice_dialog(
|
|
146
|
+
decision: decisions.SetupDecision,
|
|
147
|
+
tray: list[cards.Bird],
|
|
148
|
+
) -> decisions.SetupChoice:
|
|
149
|
+
"""Two-step sub-dialog for the combined setup pick, split-aware.
|
|
150
|
+
|
|
151
|
+
Step 1 is one screen: keep any subset of the dealt birds, plus — when the
|
|
152
|
+
offered choices carry a bonus pick (see :func:`setup_dialog_axes`) —
|
|
153
|
+
choose exactly one bonus card. Step 2 keeps the matching number of foods,
|
|
154
|
+
but only when the offered choices carry a food pick; otherwise the axis
|
|
155
|
+
is left at its deferred empty value. This mirrors whichever combination
|
|
156
|
+
of axes ``decision.choices`` actually varies over — under the split-setup
|
|
157
|
+
regimes the bonus and/or food pick are resolved by later decisions, so
|
|
158
|
+
asking for them here would never match any offered choice. The assembled
|
|
159
|
+
answer is located among ``decision.choices`` and returned. ``tray`` is
|
|
160
|
+
the face-up bird display, used to gauge bonus-card value.
|
|
161
|
+
"""
|
|
162
|
+
dealt_cards = decision.dealt_cards
|
|
163
|
+
ask_bonus, ask_food = setup_dialog_axes(decision)
|
|
164
|
+
|
|
165
|
+
print()
|
|
166
|
+
print(decision.prompt)
|
|
167
|
+
|
|
168
|
+
kept_cards, bonus_card = _cli_pick_hand_and_bonus(
|
|
169
|
+
dealt_cards, decision.dealt_bonus if ask_bonus else [], tray
|
|
170
|
+
)
|
|
171
|
+
kept_foods = _cli_pick_kept_foods(kept_cards) if ask_food else ()
|
|
172
|
+
|
|
173
|
+
for choice in decision.choices:
|
|
174
|
+
if (
|
|
175
|
+
choice.kept_cards == kept_cards
|
|
176
|
+
and choice.kept_foods == kept_foods
|
|
177
|
+
and choice.bonus_card == bonus_card
|
|
178
|
+
):
|
|
179
|
+
return choice
|
|
180
|
+
raise AssertionError(
|
|
181
|
+
"assembled setup answer did not match any offered SetupChoice: "
|
|
182
|
+
f"keep={[bird.name for bird in kept_cards]} foods={kept_foods} bonus={bonus_card}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
###### PRIVATE #######
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _cli_pick_hand_and_bonus(
|
|
190
|
+
dealt_cards: list[cards.Bird],
|
|
191
|
+
dealt_bonus: list[cards.BonusCard],
|
|
192
|
+
tray: list[cards.Bird],
|
|
193
|
+
) -> tuple[tuple[cards.Bird, ...], cards.BonusCard | None]:
|
|
194
|
+
"""Step 1: keep any subset of birds (each costs 1 food) plus one bonus card.
|
|
195
|
+
|
|
196
|
+
Birds and bonus cards share one screen as a two-section form; the bird
|
|
197
|
+
section is unconstrained (0+) while the bonus section is a radio requiring
|
|
198
|
+
exactly one pick. Each bonus card shows how many qualifying birds sit in
|
|
199
|
+
hand vs. the ``tray`` display. When no bonus cards were dealt the bonus
|
|
200
|
+
section is omitted and ``None`` is returned for it.
|
|
201
|
+
"""
|
|
202
|
+
name_width = max((len(bird.name) for bird in dealt_cards), default=0)
|
|
203
|
+
bird_options = [
|
|
204
|
+
display.format_bird_full(bird, indent="", name_width=name_width)
|
|
205
|
+
for bird in dealt_cards
|
|
206
|
+
]
|
|
207
|
+
sections = [
|
|
208
|
+
interactive.Section(
|
|
209
|
+
title="Keep which birds? (each kept bird costs 1 food)",
|
|
210
|
+
options=bird_options,
|
|
211
|
+
)
|
|
212
|
+
]
|
|
213
|
+
live: typing.Callable[[list[set[int]]], list[list[str]]] | None = None
|
|
214
|
+
if dealt_bonus:
|
|
215
|
+
bonus_width = max(len(bc.name) for bc in dealt_bonus)
|
|
216
|
+
|
|
217
|
+
def render_bonus(selected: list[cards.Bird]) -> list[str]:
|
|
218
|
+
return [
|
|
219
|
+
display.format_bonus_with_setup_help(
|
|
220
|
+
bc, dealt_cards, tray, selected, bonus_width
|
|
221
|
+
)
|
|
222
|
+
for bc in dealt_bonus
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
sections.append(
|
|
226
|
+
interactive.Section(
|
|
227
|
+
title="Keep which bonus card? (choose exactly one)",
|
|
228
|
+
options=render_bonus([]),
|
|
229
|
+
mode=interactive.Mode.SINGLE,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# The bonus help line counts selected matching birds, so re-render it
|
|
234
|
+
# each frame against the live bird-section (index 0) selection.
|
|
235
|
+
def _live(selections: list[set[int]]) -> list[list[str]]:
|
|
236
|
+
selected = [dealt_cards[i] for i in selections[0]]
|
|
237
|
+
return [bird_options, render_bonus(selected)]
|
|
238
|
+
|
|
239
|
+
live = _live
|
|
240
|
+
|
|
241
|
+
picks = interactive.select_form(
|
|
242
|
+
sections, header="Step 1 — choose your opening hand:", live_options=live
|
|
243
|
+
)
|
|
244
|
+
kept_cards = tuple(dealt_cards[i] for i in picks[0])
|
|
245
|
+
bonus_card = dealt_bonus[picks[1][0]] if dealt_bonus else None
|
|
246
|
+
return kept_cards, bonus_card
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _cli_pick_kept_foods(
|
|
250
|
+
kept_cards: tuple[cards.Bird, ...],
|
|
251
|
+
) -> tuple[cards.Food, ...]:
|
|
252
|
+
"""Step 2: check which ``keep_count`` distinct foods to keep.
|
|
253
|
+
|
|
254
|
+
``keep_count`` is ``len(ALL_FOODS) - len(kept_cards)``, since each kept card
|
|
255
|
+
costs one food off the player's one-of-each starting stash. A live footer
|
|
256
|
+
shows which of the ``kept_cards`` the current food selection could play
|
|
257
|
+
immediately. The food order in the returned tuple follows
|
|
258
|
+
:data:`cards.ALL_FOODS` to match the enumeration the engine offers.
|
|
259
|
+
"""
|
|
260
|
+
keep_count = len(cards.ALL_FOODS) - len(kept_cards)
|
|
261
|
+
section = interactive.Section(
|
|
262
|
+
options=[food.value for food in cards.ALL_FOODS],
|
|
263
|
+
required_count=keep_count,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _can_play_footer(selections: list[set[int]]) -> list[str]:
|
|
267
|
+
chosen_foods = [cards.ALL_FOODS[i] for i in selections[0]]
|
|
268
|
+
return [display.format_can_play(kept_cards, chosen_foods)]
|
|
269
|
+
|
|
270
|
+
picks = interactive.select_form(
|
|
271
|
+
[section],
|
|
272
|
+
header=(
|
|
273
|
+
f"Step 2 — keep which {keep_count} food(s)? "
|
|
274
|
+
f"(you start with one of each)"
|
|
275
|
+
),
|
|
276
|
+
live_footer=_can_play_footer,
|
|
277
|
+
)
|
|
278
|
+
return tuple(cards.ALL_FOODS[i] for i in picks[0])
|