atris 3.71.2 → 3.72.0
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.
- package/atris/skills/campaigns/SKILL.md +1 -1
- package/bin/atris.js +8 -2
- package/commands/autoland.js +102 -2
- package/commands/campaign.js +263 -11
- package/commands/clean.js +60 -4
- package/commands/gm.js +1273 -7
- package/commands/improve.js +3 -2
- package/commands/member.js +28 -3
- package/commands/mission.js +82 -27
- package/commands/pulse.js +89 -5
- package/commands/sync.js +2 -2
- package/commands/task.js +49 -13
- package/commands/worktree.js +24 -2
- package/commands/zen.js +183 -0
- package/lib/auto-accept-certified.js +9 -4
- package/lib/campaign-cadence.js +53 -14
- package/lib/campaign-core.js +98 -17
- package/lib/campaign-keep.js +91 -0
- package/lib/campaign-lanes.js +11 -6
- package/lib/campaign-loop.js +173 -32
- package/lib/campaign-reading.js +14 -1
- package/lib/campaign-rsi.js +697 -0
- package/lib/campaign-run-lock.js +9 -1
- package/lib/campaign-schedule.js +124 -0
- package/lib/campaign-schema.js +3 -1
- package/lib/campaign-store.js +34 -2
- package/lib/campaign-trace.js +77 -29
- package/lib/engine-resource.js +5 -1
- package/lib/gm-bet.js +2219 -0
- package/lib/gm-phone-page.js +1102 -0
- package/lib/gm-phone.js +682 -0
- package/lib/gm-season.js +1162 -0
- package/lib/gm-target.js +292 -0
- package/lib/job-code-verify.js +169 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-autoland.js +105 -14
- package/lib/member-progress.js +2 -2
- package/lib/member-report.js +11 -1
- package/lib/member-self-next-job.js +6 -1
- package/lib/member-up.js +17 -8
- package/lib/merged-review-close.js +206 -0
- package/lib/mission-code-provenance.js +118 -0
- package/lib/mission-protected-lane.js +52 -4
- package/lib/pulse.js +2 -1
- package/lib/qr.js +338 -0
- package/lib/rsi-record.js +88 -0
- package/lib/task-receipt.js +24 -6
- package/lib/yes-lock.js +530 -0
- package/lib/zen-apply.js +511 -0
- package/lib/zen-workspace.js +121 -0
- package/lib/zen.js +395 -0
- package/package.json +1 -1
package/commands/gm.js
CHANGED
|
@@ -24,22 +24,121 @@ const ROLE_PLAYERS_TO_IGNORE = new Set([
|
|
|
24
24
|
|
|
25
25
|
function showHelp() {
|
|
26
26
|
console.log('');
|
|
27
|
-
console.log('Usage: atris gm
|
|
27
|
+
console.log('Usage: atris gm play the front office (in a terminal)');
|
|
28
|
+
console.log(' atris gm --board print the front office board once');
|
|
29
|
+
console.log(' atris gm --league print the league, every project as a club');
|
|
30
|
+
console.log(' atris gm replay [--since 12h|2d|last-seen] [--speed N] [--plain] [--league]');
|
|
31
|
+
console.log(' atris gm card <member> [--json] one member\'s profile page');
|
|
32
|
+
console.log(' atris gm target [--landed N] [--max-abandoned N] [--max-stuck N] [--clear]');
|
|
33
|
+
console.log(' atris gm phone [--port 7777] [--send] decision cards on your phone');
|
|
34
|
+
console.log(' atris gm --json machine-readable state');
|
|
28
35
|
console.log(' atris gm --watch [--interval 10]');
|
|
29
36
|
console.log('');
|
|
30
37
|
console.log('Description:');
|
|
31
|
-
console.log('
|
|
32
|
-
console.log('
|
|
33
|
-
console.log('
|
|
34
|
-
console.log('
|
|
38
|
+
console.log(' The front office for your team. The board shows each member\'s rating,');
|
|
39
|
+
console.log(' their last five finished jobs, who is on a job, the last plays, and');
|
|
40
|
+
console.log(' what is up next. Every job a member takes in the last 30 days ends');
|
|
41
|
+
console.log(' landed (accepted or merged), dropped (claim let go), sent back, or');
|
|
42
|
+
console.log(' abandoned (archived without landing). Jobs in review are pending.');
|
|
43
|
+
console.log(' ovr = round(60 + 39 * landed / finished), rookie under 3 finished.');
|
|
44
|
+
console.log(' Drops and abandons count, so this is stricter than the website rating.');
|
|
45
|
+
console.log(' "on a job" means the claimed task moved in the last 6 hours; a claim');
|
|
46
|
+
console.log(' that sat longer shows as stuck, with its age and task id.');
|
|
47
|
+
console.log(' The line above the roster recaps what happened since your last look');
|
|
48
|
+
console.log(' (saved in ~/.atris/state/gm-last-seen.json by the playable board only).');
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log(' In a terminal, gm is playable and refreshes every 10 seconds:');
|
|
51
|
+
console.log(' p send the team out for a session (asks minutes, who, then y)');
|
|
52
|
+
console.log(' one name: atris member wake <name> --minutes N');
|
|
53
|
+
console.log(' one wake plus one work step with an N minute budget');
|
|
54
|
+
console.log(' a count: atris member wake <count>, names: atris member wake a b');
|
|
55
|
+
console.log(' keeps members picking up jobs; gm stops it after N');
|
|
56
|
+
console.log(' minutes and runs already started finish');
|
|
57
|
+
console.log(' r run a member, with an optional job (asks y first)');
|
|
58
|
+
console.log(' w wake a member (asks y first)');
|
|
59
|
+
console.log(' s put a member to sleep');
|
|
60
|
+
console.log(' u unstick: let go of a stuck claim (asks y first)');
|
|
61
|
+
console.log(' atris task release <id> --as <member holding it>');
|
|
62
|
+
console.log(' l landing check (atris land --dry-run)');
|
|
63
|
+
console.log(' g switch between this club and the league (read only)');
|
|
64
|
+
console.log(' c switch to another club from the league (read only, like --workspace)');
|
|
65
|
+
console.log(' v replay what happened since your last look, then back to the board');
|
|
66
|
+
console.log(' i a member\'s card: rating by week, record, engine, scouting (read only)');
|
|
67
|
+
console.log(' o set this week\'s owner target: landed, max abandoned, max stuck over a day');
|
|
68
|
+
console.log(' enter or space refresh, q quit');
|
|
69
|
+
console.log(' Every key that runs a command shows it first.');
|
|
70
|
+
console.log('');
|
|
71
|
+
console.log(' atris gm replay plays a window back as a time-lapse in about 75 seconds:');
|
|
72
|
+
console.log(' a clock, each member\'s running tally, and the last six plays. Keys:');
|
|
73
|
+
console.log(' space pause, f faster, q quit. Off a terminal, or with --plain, it prints');
|
|
74
|
+
console.log(' each play as a line. --since takes 30m, 12h, 2d, or last-seen (the');
|
|
75
|
+
console.log(' default, else 12h); --speed 2 plays twice as fast; --league plays every');
|
|
76
|
+
console.log(' club at once.');
|
|
77
|
+
console.log('');
|
|
78
|
+
console.log(' The owner target is the win condition: landed this week, at most N');
|
|
79
|
+
console.log(' abandoned, at most N claims stuck over a day. It carries forward to new');
|
|
80
|
+
console.log(' weeks until changed. During the week a landed shortfall is behind and a');
|
|
81
|
+
console.log(' broken limit is missed; a week is won only once it is over and all');
|
|
82
|
+
console.log(' three held. The header keeps the season record of won and lost weeks.');
|
|
83
|
+
console.log(' Saved in ~/.atris/state/gm-targets/, one file per workspace.');
|
|
84
|
+
console.log('');
|
|
85
|
+
console.log(' atris gm phone opens a page your phone can reach on the same wifi. It');
|
|
86
|
+
console.log(' prints a link with a random key and a QR code; anything without the key');
|
|
87
|
+
console.log(' gets nothing. The page is one dark screen with the orb in the middle.');
|
|
88
|
+
console.log(' A quiet link at the bottom opens the decisions only you can make, one');
|
|
89
|
+
console.log(' card at a time, at most five a session:');
|
|
90
|
+
console.log(' jobs waiting on review: have the reviewer check them');
|
|
91
|
+
console.log(' atris task auto-accept-certified --json --certify-first');
|
|
92
|
+
console.log(' (offered only while autoland is on), or approve / send back the');
|
|
93
|
+
console.log(' oldest 3 one at a time: atris task accept <id>,');
|
|
94
|
+
console.log(' atris task revise <id> --note "..."');
|
|
95
|
+
console.log(' stuck claims: atris task release <id> --as <holder> for each, or');
|
|
96
|
+
console.log(' atris member wake <holder> for each holder');
|
|
97
|
+
console.log(' no target: set this week\'s target (5 seconds to undo)');
|
|
98
|
+
console.log(' Tap the orb and a smart model (claude -p, your subscription, never an');
|
|
99
|
+
console.log(' api key) reads now.md, this week\'s target, the changes merged on master');
|
|
100
|
+
console.log(' this week, npm installs and the best installs week of the last 2 months,');
|
|
101
|
+
console.log(' the published version and how much on master is not in it, the newest');
|
|
102
|
+
console.log(' research briefs, and past bets with won or lost, then proposes one new');
|
|
103
|
+
console.log(' move with a target number, a check date (it picks the number of days,');
|
|
104
|
+
console.log(' 7 to 14), a check that proves the deliverable, and steps, each team step');
|
|
105
|
+
console.log(' naming a member of atris/team. Bets are business moves only (customers,');
|
|
106
|
+
console.log(' revenue, reach, product people use); plumbing is the team\'s job. An');
|
|
107
|
+
console.log(' installs goal must beat the best recent week. A second model then checks');
|
|
108
|
+
console.log(' that the move makes sense. When a job finishes, the reviewer in the engine');
|
|
109
|
+
console.log(' roster reads the work before it counts as done. Bets the team is doing are');
|
|
110
|
+
console.log(' scored won or lost from the same numbers once the check date is over.');
|
|
111
|
+
console.log(' The Mac keeps 3 bets ready so a tap rarely waits; tap the orb again for');
|
|
112
|
+
console.log(' the next one. Every answer is saved to ~/.atris/state/gm-bet-attempts.jsonl.');
|
|
113
|
+
console.log(' do it starts a mission: atris mission start "<bet, steps, handoffs>"');
|
|
114
|
+
console.log(' --owner <first step member> --verify "<the bet check>" --runner claude,');
|
|
115
|
+
console.log(' then atris mission run <id> --detach. later steps go to their member with');
|
|
116
|
+
console.log(' atris member handoff <from> <to> "...".');
|
|
117
|
+
console.log(' steps marked your yes become decision cards; yes sends');
|
|
118
|
+
console.log(' atris mission ping <id> "..." --from gm-phone and nothing else.');
|
|
119
|
+
console.log(' Bets are kept in ~/.atris/state/gm-bets.jsonl.');
|
|
120
|
+
console.log(' Anything that starts agents, frees claims, or lands work needs a second');
|
|
121
|
+
console.log(' tap. Every tap is logged to ~/.atris/state/gm-phone-actions.jsonl.');
|
|
122
|
+
console.log(' --send texts the link to the number saved with atris autoland on --to.');
|
|
123
|
+
console.log('');
|
|
124
|
+
console.log(' The league treats every real project folder with task events in the');
|
|
125
|
+
console.log(' last 30 days as a club: team rating, landed this week and in 30 days,');
|
|
126
|
+
console.log(' drops, abandons, stuck claims, last task event, and best player.');
|
|
127
|
+
console.log(' Scratch folders under the temp dirs and agent worktree checkouts are');
|
|
128
|
+
console.log(' left out, and clubs under 3 finished jobs are counted but hidden.');
|
|
35
129
|
console.log('');
|
|
36
130
|
console.log('Options:');
|
|
131
|
+
console.log(' --board Print the board once and exit, no keys. Read only, even');
|
|
132
|
+
console.log(' with --json (schema atris.gm_board.v1, never seeds).');
|
|
133
|
+
console.log(' --league Print the league once and exit; with --json, adds league.');
|
|
37
134
|
console.log(' --manager <id> Manager id. Defaults to game-manager when present.');
|
|
38
135
|
console.log(' --as <id> Alias for --player.');
|
|
39
136
|
console.log(' --player <id> Preferred player when seeding a first local mission.');
|
|
40
|
-
console.log(' --workspace <p> Read
|
|
137
|
+
console.log(' --workspace <p> Read another Atris workspace.');
|
|
41
138
|
console.log(' --watch Append new GM events until Ctrl+C.');
|
|
42
139
|
console.log(' --interval <sec> Watch polling interval. Defaults to 10.');
|
|
140
|
+
console.log(' --port <n> gm phone port. Defaults to 7777.');
|
|
141
|
+
console.log(' --send gm phone: text the link to your saved number.');
|
|
43
142
|
console.log(' --no-seed Do not create a starter player mission.');
|
|
44
143
|
console.log(' --json Print machine-readable mode state.');
|
|
45
144
|
console.log(' --help, -h Show this help.');
|
|
@@ -632,6 +731,1090 @@ function render(state) {
|
|
|
632
731
|
for (const command of state.next_commands) console.log(`- ${command}`);
|
|
633
732
|
}
|
|
634
733
|
|
|
734
|
+
// ---- front office board -------------------------------------------------
|
|
735
|
+
|
|
736
|
+
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
737
|
+
const PLAY_KEYS = [
|
|
738
|
+
'keys: p play r run w wake s sleep u unstick l landing check',
|
|
739
|
+
' g league c switch club v replay i card o target enter refresh q quit',
|
|
740
|
+
];
|
|
741
|
+
const LEAGUE_KEYS = 'keys: g back to the club c switch club v replay enter refresh q quit';
|
|
742
|
+
const LEAGUE_MAX_ROWS = 20;
|
|
743
|
+
const MEMBER_NAME_RE = /^[a-zA-Z0-9._-]+$/;
|
|
744
|
+
const REFRESH_MS = 10 * 1000;
|
|
745
|
+
const PLAY_DEFAULT_MINUTES = 30;
|
|
746
|
+
const PLAY_DEFAULT_WHO = '3';
|
|
747
|
+
const PLAY_MAX_MINUTES = 480;
|
|
748
|
+
const PLAY_MAX_COUNT = 12;
|
|
749
|
+
|
|
750
|
+
function pad(value, width) {
|
|
751
|
+
const text = String(value == null ? '' : value);
|
|
752
|
+
return text.length >= width ? `${text.slice(0, width - 1)} ` : text + ' '.repeat(width - text.length);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const DEFAULT_WIDTH = 100;
|
|
756
|
+
const NAME_CAP = 24;
|
|
757
|
+
|
|
758
|
+
// The terminal's width when there is one, else 100 columns.
|
|
759
|
+
function termWidth() {
|
|
760
|
+
const cols = process.stdout.isTTY ? Number(process.stdout.columns) : 0;
|
|
761
|
+
return cols >= 40 ? cols : DEFAULT_WIDTH;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// A column cell that never cuts mid-word silently: text that does not fit
|
|
765
|
+
// ends in "..." so it reads as shortened. Always one space after.
|
|
766
|
+
function cell(value, width) {
|
|
767
|
+
const text = String(value == null ? '' : value);
|
|
768
|
+
const room = Math.max(1, width - 1);
|
|
769
|
+
if (text.length <= room) return text + ' '.repeat(width - text.length);
|
|
770
|
+
const short = `${text.slice(0, Math.max(1, width - 5))}...`;
|
|
771
|
+
return short + ' '.repeat(Math.max(1, width - short.length));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// A name column wide enough for the longest name shown, capped at 24.
|
|
775
|
+
function nameWidth(names, min = 12, cap = NAME_CAP) {
|
|
776
|
+
const longest = Math.max(0, ...names.filter(Boolean).map(n => String(n).length));
|
|
777
|
+
return Math.max(min, Math.min(cap, longest)) + 2;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// The free text at the end of a line (a title) takes whatever room is left.
|
|
781
|
+
function tail(text, used, width) {
|
|
782
|
+
return clip(text, Math.max(10, width - used));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Last guard on width: a long line breaks after a comma onto an indented
|
|
786
|
+
// line; anything still too long ends in "...".
|
|
787
|
+
function fitLines(lines, width) {
|
|
788
|
+
const out = [];
|
|
789
|
+
for (const line of lines) {
|
|
790
|
+
let rest = line;
|
|
791
|
+
while (rest.length > width) {
|
|
792
|
+
const cut = rest.lastIndexOf(', ', width - 1);
|
|
793
|
+
if (cut <= 0) break;
|
|
794
|
+
out.push(rest.slice(0, cut + 1));
|
|
795
|
+
rest = ` ${rest.slice(cut + 2)}`;
|
|
796
|
+
}
|
|
797
|
+
out.push(rest.length > width ? `${rest.slice(0, width - 3)}...` : rest);
|
|
798
|
+
}
|
|
799
|
+
return out;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function weekLabel(ms) {
|
|
803
|
+
const d = new Date(ms);
|
|
804
|
+
return `week of ${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function hoursLabel(hours) {
|
|
808
|
+
if (hours == null) return '';
|
|
809
|
+
return hours >= 10 ? `${Math.round(hours)}h` : `${hours}h`;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function boardWorkspaceRoot(args = []) {
|
|
813
|
+
const taskDb = require('../lib/task-db');
|
|
814
|
+
const workspaceArg = flag(args, '--workspace') || flag(args, '--root') || process.cwd();
|
|
815
|
+
return taskDb.workspaceRoot(path.resolve(workspaceArg));
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function loadBoard(workspaceRoot, options = {}) {
|
|
819
|
+
const season = require('../lib/gm-season');
|
|
820
|
+
return season.seasonBoard(season.loadSeason(workspaceRoot, { lastSeen: options.lastSeen }), options);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// One screen, about 40 rows at most. Pure: board in, lines out.
|
|
824
|
+
function renderBoardLines(board, { interactive = false, width = termWidth() } = {}) {
|
|
825
|
+
const { clockLabel } = require('../lib/gm-season');
|
|
826
|
+
const lines = [];
|
|
827
|
+
const nameW = nameWidth([
|
|
828
|
+
...board.roster.map(r => r.name),
|
|
829
|
+
...(board.waiting_on_review || []).map(r => r.member),
|
|
830
|
+
...(board.stuck || []).map(r => r.member),
|
|
831
|
+
...(board.last_plays || []).map(p => p.member),
|
|
832
|
+
], 16);
|
|
833
|
+
const gmTarget = require('../lib/gm-target');
|
|
834
|
+
const live = interactive ? ` | refreshes every ${REFRESH_MS / 1000}s` : '';
|
|
835
|
+
const record = board.record ? ` | ${gmTarget.recordLine(board.record)}` : '';
|
|
836
|
+
lines.push(`front office | ${board.workspace_name} | ${weekLabel(board.week_of)}${record}${live}`);
|
|
837
|
+
lines.push(board.mood.line);
|
|
838
|
+
if (board.target) lines.push(gmTarget.targetLine(board.target));
|
|
839
|
+
if (board.since_last_look) lines.push(board.since_last_look.line);
|
|
840
|
+
if (board.error) lines.push(`could not read the task list: ${clip(board.error, 80)}`);
|
|
841
|
+
lines.push('');
|
|
842
|
+
|
|
843
|
+
lines.push('roster, last 30 days');
|
|
844
|
+
if (board.roster.length) {
|
|
845
|
+
lines.push(` ${pad('name', nameW)}${pad('ovr', 8)}${pad('form', 7)}${pad('record', 18)}status`);
|
|
846
|
+
for (const row of board.roster) {
|
|
847
|
+
const record = `landed ${row.landed} of ${row.jobs}`;
|
|
848
|
+
lines.push(` ${cell(row.name, nameW)}${pad(row.ovr == null ? 'rookie' : row.ovr, 8)}${pad(row.form || '-', 7)}${pad(record, 18)}${row.status}`);
|
|
849
|
+
}
|
|
850
|
+
} else {
|
|
851
|
+
lines.push(' no jobs taken in the last 30 days');
|
|
852
|
+
}
|
|
853
|
+
lines.push('');
|
|
854
|
+
|
|
855
|
+
// review waits first: handed in, waiting on a reviewer, the usual jam
|
|
856
|
+
if (board.waiting_on_review && board.waiting_on_review.length) {
|
|
857
|
+
const total = board.waiting_on_review_total || board.waiting_on_review.length;
|
|
858
|
+
const more = total > board.waiting_on_review.length ? `, ${total} in all, oldest first` : '';
|
|
859
|
+
lines.push(`waiting on review, age since the proof${more}`);
|
|
860
|
+
for (const row of board.waiting_on_review) {
|
|
861
|
+
const head = ` ${cell(row.member, nameW)}${pad(row.age, 6)}${cell(row.ref, 11)}`;
|
|
862
|
+
lines.push(`${head}${tail(row.title, head.length, width)}`);
|
|
863
|
+
}
|
|
864
|
+
lines.push('');
|
|
865
|
+
}
|
|
866
|
+
if (board.stuck && board.stuck.length) {
|
|
867
|
+
lines.push(`stuck with the player, claimed with no move for 6h or more${interactive ? ' (u to unstick)' : ''}`);
|
|
868
|
+
for (const row of board.stuck) {
|
|
869
|
+
const head = ` ${cell(row.member, nameW)}${pad(row.age, 6)}${cell(row.ref, 11)}`;
|
|
870
|
+
lines.push(`${head}${tail(row.title, head.length, width)}`);
|
|
871
|
+
}
|
|
872
|
+
lines.push('');
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// the outcome word is its own column and never cut; the title gives way
|
|
876
|
+
lines.push('last plays');
|
|
877
|
+
if (board.last_plays && board.last_plays.length) {
|
|
878
|
+
for (const play of board.last_plays) {
|
|
879
|
+
const tag = play.fresh ? ' new' : '';
|
|
880
|
+
const head = ` ${pad(clockLabel(play.at, board.now), 17)}${cell(play.member, nameW)}${pad(play.play, 10)}${pad(hoursLabel(play.hours), 6)}`;
|
|
881
|
+
lines.push(`${head}${tail(play.title, head.length + tag.length, width)}${tag}`);
|
|
882
|
+
}
|
|
883
|
+
} else {
|
|
884
|
+
lines.push(' no plays yet');
|
|
885
|
+
}
|
|
886
|
+
lines.push('');
|
|
887
|
+
|
|
888
|
+
lines.push('up next');
|
|
889
|
+
if (board.up_next.length) {
|
|
890
|
+
for (const item of board.up_next) {
|
|
891
|
+
const label = item.kind === 'task' ? (item.ref || 'task') : 'mission';
|
|
892
|
+
const owner = item.owner ? ` (${clip(item.owner, NAME_CAP)})` : '';
|
|
893
|
+
const head = ` ${cell(label, 12)}`;
|
|
894
|
+
lines.push(`${head}${tail(item.title, head.length + owner.length, width)}${owner}`);
|
|
895
|
+
}
|
|
896
|
+
} else {
|
|
897
|
+
lines.push(' no open tasks or active missions');
|
|
898
|
+
}
|
|
899
|
+
lines.push('');
|
|
900
|
+
|
|
901
|
+
lines.push('key: ovr 60 to 99 = share of finished jobs landed, rookie under 3 finished,');
|
|
902
|
+
lines.push(' stricter than the website rating on purpose (drops and abandons count)');
|
|
903
|
+
lines.push(' form: last 5, newest right: k landed, d dropped, x sent back, a abandoned');
|
|
904
|
+
lines.push(' hours: claim to result. on a job: moved in the last 6h. review: pending');
|
|
905
|
+
if (interactive) for (const line of PLAY_KEYS) lines.push(line);
|
|
906
|
+
else lines.push(' run atris gm in a terminal to play');
|
|
907
|
+
return fitLines(lines, width);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function loadLeagueBoard(options = {}) {
|
|
911
|
+
const season = require('../lib/gm-season');
|
|
912
|
+
return season.leagueBoard(season.loadLeague(options));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// The league: one row per club, best this week first. Pure: board in, lines out.
|
|
916
|
+
function renderLeagueLines(board, { interactive = false, width = termWidth() } = {}) {
|
|
917
|
+
const lines = [];
|
|
918
|
+
const clubW = nameWidth(board.rows.slice(0, LEAGUE_MAX_ROWS).map(r => r.club), 16);
|
|
919
|
+
const live = interactive ? ` | refreshes every ${REFRESH_MS / 1000}s` : '';
|
|
920
|
+
lines.push(`league | ${board.days} days | ${board.rows.length} clubs${live}`);
|
|
921
|
+
if (board.error) lines.push(`could not read the task list: ${clip(board.error, 80)}`);
|
|
922
|
+
lines.push('');
|
|
923
|
+
if (!board.rows.length) {
|
|
924
|
+
lines.push(' no club finished 3 or more jobs in the last 30 days');
|
|
925
|
+
} else {
|
|
926
|
+
// the target column shows only when some club has a target
|
|
927
|
+
const withTarget = board.rows.some(row => row.target);
|
|
928
|
+
const targetHead = withTarget ? pad('target', 10) : '';
|
|
929
|
+
lines.push(` ${pad('club', clubW)}${pad('ovr', 7)}${pad('week', 6)}${pad('30d', 6)}${pad('drop', 6)}${pad('aband', 7)}${pad('stuck', 7)}${pad('last', 6)}${targetHead}best player`);
|
|
930
|
+
for (const row of board.rows.slice(0, LEAGUE_MAX_ROWS)) {
|
|
931
|
+
const targetCell = withTarget ? pad(row.target || '-', 10) : '';
|
|
932
|
+
const head = ` ${cell(row.club, clubW)}${pad(row.ovr == null ? 'rookie' : row.ovr, 7)}${pad(row.landed_week, 6)}${pad(row.landed_30d, 6)}${pad(row.dropped_30d, 6)}${pad(row.abandoned_30d, 7)}${pad(row.stuck, 7)}${pad(row.last_play, 6)}${targetCell}`;
|
|
933
|
+
// the rating always shows; a long best player name gives way first
|
|
934
|
+
const best = row.best_player
|
|
935
|
+
? `${clip(row.best_player.name, Math.max(6, Math.min(NAME_CAP, width - head.length - 4)))} ${row.best_player.ovr}`
|
|
936
|
+
: '-';
|
|
937
|
+
lines.push(`${head}${best}`);
|
|
938
|
+
}
|
|
939
|
+
const more = board.rows.length - LEAGUE_MAX_ROWS;
|
|
940
|
+
if (more > 0) lines.push(` and ${more} more clubs (atris gm --league --json lists all)`);
|
|
941
|
+
}
|
|
942
|
+
if (board.hidden) lines.push(` ${board.hidden} quiet clubs hidden (under 3 finished jobs in 30 days)`);
|
|
943
|
+
lines.push('');
|
|
944
|
+
lines.push('key: week and 30d count landed jobs; drop and aband count dropped and');
|
|
945
|
+
lines.push(' abandoned jobs in 30 days. stuck: claims with no move for 6h or more');
|
|
946
|
+
lines.push(' last: newest task event. ovr: the club board rating over every job');
|
|
947
|
+
lines.push(interactive ? LEAGUE_KEYS : ' run atris gm in a terminal, then g, to play');
|
|
948
|
+
return fitLines(lines, width);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// The club a typed name means: its unique league label, or a folder name
|
|
952
|
+
// that only one club has. Returns { root }, { ambiguous: [labels] }, or null.
|
|
953
|
+
function pickClub(answer, rows = []) {
|
|
954
|
+
const wanted = String(answer || '').trim().toLowerCase();
|
|
955
|
+
if (!wanted) return null;
|
|
956
|
+
const exact = rows.filter(row => String(row.club).toLowerCase() === wanted);
|
|
957
|
+
if (exact.length === 1) return { root: exact[0].root };
|
|
958
|
+
const byFolder = exact.length ? exact : rows.filter(row => path.basename(String(row.root)).toLowerCase() === wanted);
|
|
959
|
+
if (byFolder.length === 1) return { root: byFolder[0].root };
|
|
960
|
+
if (byFolder.length > 1) return { ambiguous: byFolder.map(row => row.club) };
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// An unreadable task db is a failure, not an empty board: say so, exit 1.
|
|
965
|
+
function failRead(error) {
|
|
966
|
+
console.error(`could not read the task list: ${clip(error, 120)}`);
|
|
967
|
+
process.exitCode = 1;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function printLeague() {
|
|
971
|
+
const board = loadLeagueBoard();
|
|
972
|
+
if (board.error) return failRead(board.error);
|
|
973
|
+
console.log(renderLeagueLines(board).join('\n'));
|
|
974
|
+
return undefined;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function printBoard(args = []) {
|
|
978
|
+
const board = loadBoard(boardWorkspaceRoot(args));
|
|
979
|
+
if (board.error) return failRead(board.error);
|
|
980
|
+
console.log(renderBoardLines(board).join('\n'));
|
|
981
|
+
return undefined;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function quoteArg(value) {
|
|
985
|
+
const text = String(value);
|
|
986
|
+
return /^[a-zA-Z0-9._/:=-]+$/.test(text) ? text : `"${text.replace(/(["\\$`])/g, '\\$1')}"`;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function displayCommand(args) {
|
|
990
|
+
return ['atris', ...args].map(quoteArg).join(' ');
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function playMinutes(value) {
|
|
994
|
+
const text = String(value == null ? '' : value).trim();
|
|
995
|
+
if (!text) return PLAY_DEFAULT_MINUTES;
|
|
996
|
+
if (!/^\d+$/.test(text)) return null;
|
|
997
|
+
const minutes = Number(text);
|
|
998
|
+
return minutes >= 1 && minutes <= PLAY_MAX_MINUTES ? minutes : null;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// p sends the team out for a session:
|
|
1002
|
+
// one name -> atris member wake <name> --minutes N
|
|
1003
|
+
// one wake, then one work step with an N minute budget; ends itself
|
|
1004
|
+
// a count -> atris member wake <count> (same as atris member up --max <count>)
|
|
1005
|
+
// names -> atris member wake a b (same as atris member up --only a,b)
|
|
1006
|
+
// both keep members picking up jobs with no end of their own, so
|
|
1007
|
+
// gm stops it after N minutes; runs already started finish
|
|
1008
|
+
function playSession({ who = '', minutes = '' } = {}) {
|
|
1009
|
+
const span = playMinutes(minutes);
|
|
1010
|
+
if (span == null) return null;
|
|
1011
|
+
const words = String(who || PLAY_DEFAULT_WHO).split(/[\s,]+/).filter(Boolean);
|
|
1012
|
+
if (!words.length) return null;
|
|
1013
|
+
if (words.length === 1 && /^\d+$/.test(words[0])) {
|
|
1014
|
+
const count = Number(words[0]);
|
|
1015
|
+
if (count < 1 || count > PLAY_MAX_COUNT) return null;
|
|
1016
|
+
return { args: ['member', 'wake', String(count)], confirm: true, stopAfterMs: span * 60 * 1000, minutes: span };
|
|
1017
|
+
}
|
|
1018
|
+
if (!words.every(word => MEMBER_NAME_RE.test(word))) return null;
|
|
1019
|
+
if (words.length === 1) {
|
|
1020
|
+
return { args: ['member', 'wake', words[0], '--minutes', String(span)], confirm: true, stopAfterMs: null, minutes: span };
|
|
1021
|
+
}
|
|
1022
|
+
return { args: ['member', 'wake', ...words], confirm: true, stopAfterMs: span * 60 * 1000, minutes: span };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// u lets go of a stuck claim with the real release verb. `atris task release`
|
|
1026
|
+
// only works as the member holding the claim, so it runs --as that member,
|
|
1027
|
+
// and the drop lands on the member that held the job.
|
|
1028
|
+
function unstickAction(answer, stuck = []) {
|
|
1029
|
+
const wanted = String(answer || '').trim().toLowerCase();
|
|
1030
|
+
if (!wanted) return null;
|
|
1031
|
+
const row = stuck.find(s => [s.ref, s.task_id].filter(Boolean).some(id => String(id).toLowerCase() === wanted));
|
|
1032
|
+
if (!row || !MEMBER_NAME_RE.test(row.member)) return null;
|
|
1033
|
+
return {
|
|
1034
|
+
args: ['task', 'release', row.ref || row.task_id, '--as', row.member],
|
|
1035
|
+
confirm: true,
|
|
1036
|
+
confirmText: 'this releases the claim and counts a drop for that member',
|
|
1037
|
+
recheck: { task_id: row.task_id, member: row.member },
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// What each key runs. p, r, w and u ask for y first.
|
|
1042
|
+
function playAction(key, { name = '', job = '', who = '', minutes = '', stuck = [] } = {}) {
|
|
1043
|
+
if (key === 'l') return { args: ['land', '--dry-run'], confirm: false };
|
|
1044
|
+
if (key === 'p') return playSession({ who, minutes });
|
|
1045
|
+
if (key === 'u') return unstickAction(name, stuck);
|
|
1046
|
+
if (!MEMBER_NAME_RE.test(name)) return null;
|
|
1047
|
+
if (key === 'r') return { args: ['member', 'run', name, ...(job ? [job] : [])], confirm: true };
|
|
1048
|
+
if (key === 'w') return { args: ['member', 'wake', name], confirm: true };
|
|
1049
|
+
if (key === 's') return { args: ['member', 'sleep', name], confirm: false };
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function actionLabel(action) {
|
|
1054
|
+
const shown = displayCommand(action.args);
|
|
1055
|
+
if (!action.stopAfterMs) return shown;
|
|
1056
|
+
return `${shown} (gm stops it after ${action.minutes} min; runs already started finish)`;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
async function gmPlay(args = []) {
|
|
1060
|
+
const readline = require('readline');
|
|
1061
|
+
const { spawn } = require('child_process');
|
|
1062
|
+
const input = process.stdin;
|
|
1063
|
+
const output = process.stdout;
|
|
1064
|
+
// The club in view. c switches it, the same as --workspace; every command a
|
|
1065
|
+
// key runs then runs in that club's folder.
|
|
1066
|
+
let workspaceRoot = boardWorkspaceRoot(args);
|
|
1067
|
+
const cliPath = path.join(__dirname, '..', 'bin', 'atris.js');
|
|
1068
|
+
const write = text => output.write(`${text}\n`);
|
|
1069
|
+
const setRaw = (on) => {
|
|
1070
|
+
if (input.isTTY && typeof input.setRawMode === 'function') input.setRawMode(on);
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
readline.emitKeypressEvents(input);
|
|
1074
|
+
|
|
1075
|
+
// The recap compares against the look before this session, pinned per club
|
|
1076
|
+
// for the session. What gets saved as the new look is the time of the last
|
|
1077
|
+
// club snapshot actually drawn, so a landing after it still shows next time.
|
|
1078
|
+
const season = require('../lib/gm-season');
|
|
1079
|
+
let lastSeen = season.readLastSeen(workspaceRoot);
|
|
1080
|
+
|
|
1081
|
+
let view = 'club';
|
|
1082
|
+
let seen = null;
|
|
1083
|
+
let note = '';
|
|
1084
|
+
let stuck = [];
|
|
1085
|
+
let leagueRowsSeen = [];
|
|
1086
|
+
let lastBoard = null;
|
|
1087
|
+
const draw = () => {
|
|
1088
|
+
output.write('\x1b[2J\x1b[H');
|
|
1089
|
+
try {
|
|
1090
|
+
if (view === 'league') {
|
|
1091
|
+
const board = loadLeagueBoard();
|
|
1092
|
+
leagueRowsSeen = [...board.rows, ...(board.hidden_clubs || [])];
|
|
1093
|
+
write(renderLeagueLines(board, { interactive: true }).join('\n'));
|
|
1094
|
+
} else {
|
|
1095
|
+
const board = loadBoard(workspaceRoot, { seen, lastSeen });
|
|
1096
|
+
lastBoard = board;
|
|
1097
|
+
seen = new Set(board.last_plays.map(play => play.id));
|
|
1098
|
+
stuck = board.stuck || [];
|
|
1099
|
+
write(renderBoardLines(board, { interactive: true }).join('\n'));
|
|
1100
|
+
season.writeLastSeen(workspaceRoot, board.now);
|
|
1101
|
+
}
|
|
1102
|
+
} catch (err) {
|
|
1103
|
+
write(`could not load the board: ${err.message}`);
|
|
1104
|
+
}
|
|
1105
|
+
if (note) write(`\n${note}`);
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
const switchClub = (root) => {
|
|
1109
|
+
workspaceRoot = root;
|
|
1110
|
+
lastSeen = season.readLastSeen(workspaceRoot);
|
|
1111
|
+
seen = null;
|
|
1112
|
+
stuck = [];
|
|
1113
|
+
view = 'club';
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
// Keys arrive in bursts (type-ahead while a command runs lands as one chunk),
|
|
1117
|
+
// so queue them instead of keeping only the first.
|
|
1118
|
+
const queue = [];
|
|
1119
|
+
let waiter = null;
|
|
1120
|
+
let capturing = true;
|
|
1121
|
+
const onKeypress = (str, key) => {
|
|
1122
|
+
if (!capturing) return;
|
|
1123
|
+
if (waiter) {
|
|
1124
|
+
const resolve = waiter;
|
|
1125
|
+
waiter = null;
|
|
1126
|
+
resolve({ str, key });
|
|
1127
|
+
} else {
|
|
1128
|
+
queue.push({ str, key });
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
input.on('keypress', onKeypress);
|
|
1132
|
+
|
|
1133
|
+
const nextKey = () => {
|
|
1134
|
+
setRaw(true);
|
|
1135
|
+
input.resume();
|
|
1136
|
+
if (queue.length) return Promise.resolve(queue.shift());
|
|
1137
|
+
return new Promise((resolve) => { waiter = resolve; });
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
// Resolves the typed answer, or null when the prompt is cancelled with
|
|
1141
|
+
// Ctrl-C or closed, so a cancel never falls through to a default.
|
|
1142
|
+
const ask = question => new Promise((resolve) => {
|
|
1143
|
+
capturing = false;
|
|
1144
|
+
queue.length = 0;
|
|
1145
|
+
setRaw(false);
|
|
1146
|
+
const rl = readline.createInterface({ input, output, terminal: true });
|
|
1147
|
+
let settled = false;
|
|
1148
|
+
const settle = (answer) => {
|
|
1149
|
+
if (settled) return;
|
|
1150
|
+
settled = true;
|
|
1151
|
+
capturing = true;
|
|
1152
|
+
rl.close();
|
|
1153
|
+
resolve(answer);
|
|
1154
|
+
};
|
|
1155
|
+
rl.on('SIGINT', () => {
|
|
1156
|
+
output.write('\n');
|
|
1157
|
+
settle(null);
|
|
1158
|
+
});
|
|
1159
|
+
rl.on('close', () => settle(null));
|
|
1160
|
+
rl.question(question, answer => settle(String(answer || '').trim()));
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
// Ctrl-C while a command runs reaches the command, not gm, so gm comes back:
|
|
1164
|
+
// the signal trap below ignores SIGINT while a child runs.
|
|
1165
|
+
let childRunning = false;
|
|
1166
|
+
const runAtris = (cmdArgs, { stopAfterMs = null } = {}) => new Promise((resolve) => {
|
|
1167
|
+
setRaw(false);
|
|
1168
|
+
input.pause();
|
|
1169
|
+
childRunning = true;
|
|
1170
|
+
const child = spawn(process.execPath, [cliPath, ...cmdArgs], {
|
|
1171
|
+
cwd: workspaceRoot,
|
|
1172
|
+
stdio: 'inherit',
|
|
1173
|
+
env: process.env,
|
|
1174
|
+
});
|
|
1175
|
+
const timer = stopAfterMs ? setTimeout(() => child.kill('SIGTERM'), stopAfterMs) : null;
|
|
1176
|
+
const finish = (result) => {
|
|
1177
|
+
if (timer) clearTimeout(timer);
|
|
1178
|
+
childRunning = false;
|
|
1179
|
+
resolve(result);
|
|
1180
|
+
};
|
|
1181
|
+
child.on('error', err => finish({ code: 1, error: err.message }));
|
|
1182
|
+
child.on('close', code => finish({ code }));
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
const CANCELLED = 'cancelled, nothing ran';
|
|
1186
|
+
const doAction = async (key) => {
|
|
1187
|
+
let action;
|
|
1188
|
+
write('');
|
|
1189
|
+
if (key === 'l') {
|
|
1190
|
+
action = playAction('l');
|
|
1191
|
+
} else if (key === 'p') {
|
|
1192
|
+
const minutes = await ask(`session length in minutes (enter for ${PLAY_DEFAULT_MINUTES}): `);
|
|
1193
|
+
if (minutes === null) return CANCELLED;
|
|
1194
|
+
const who = await ask(`who: a member name, names, or how many (enter for ${PLAY_DEFAULT_WHO}): `);
|
|
1195
|
+
if (who === null) return CANCELLED;
|
|
1196
|
+
action = playAction('p', { who, minutes });
|
|
1197
|
+
if (!action) return `cancelled, need 1 to ${PLAY_MAX_MINUTES} minutes and member names or a count up to ${PLAY_MAX_COUNT}`;
|
|
1198
|
+
} else if (key === 'u') {
|
|
1199
|
+
if (!stuck.length) return 'nothing is stuck';
|
|
1200
|
+
const id = await ask(`unstick which task? (${stuck.map(s => s.ref).join(', ')}): `);
|
|
1201
|
+
if (id === null) return CANCELLED;
|
|
1202
|
+
action = playAction('u', { name: id, stuck });
|
|
1203
|
+
if (!action) return id ? `cancelled, "${clip(id, 40)}" is not in the stuck list` : 'cancelled, no task named';
|
|
1204
|
+
} else {
|
|
1205
|
+
const verb = { r: 'run', w: 'wake', s: 'sleep' }[key];
|
|
1206
|
+
const name = await ask(`${verb} which member? `);
|
|
1207
|
+
if (name === null) return CANCELLED;
|
|
1208
|
+
if (!name) return 'cancelled, no member named';
|
|
1209
|
+
const job = key === 'r' ? await ask('job (optional, enter to skip): ') : '';
|
|
1210
|
+
if (job === null) return CANCELLED;
|
|
1211
|
+
action = playAction(key, { name, job });
|
|
1212
|
+
if (!action) return `cancelled, "${clip(name, 40)}" is not a member name`;
|
|
1213
|
+
}
|
|
1214
|
+
const shown = displayCommand(action.args);
|
|
1215
|
+
write('');
|
|
1216
|
+
write(`this will run: ${actionLabel(action)}`);
|
|
1217
|
+
if (action.confirm) {
|
|
1218
|
+
write(`${action.confirmText || 'this starts agents'}. press y to go, any other key to cancel`);
|
|
1219
|
+
queue.length = 0; // only a fresh key press can confirm
|
|
1220
|
+
const { str } = await nextKey();
|
|
1221
|
+
if (String(str || '').toLowerCase() !== 'y') return `cancelled: ${shown}`;
|
|
1222
|
+
}
|
|
1223
|
+
// The stuck list can be minutes old: if the holder moved while the y
|
|
1224
|
+
// prompt was open, releasing now would take an active job away.
|
|
1225
|
+
if (action.recheck && !season.stillStuck(action.recheck.task_id, action.recheck.member)) {
|
|
1226
|
+
return 'not stuck anymore, nothing ran';
|
|
1227
|
+
}
|
|
1228
|
+
write('');
|
|
1229
|
+
const result = await runAtris(action.args, { stopAfterMs: action.stopAfterMs });
|
|
1230
|
+
write('');
|
|
1231
|
+
const exit = result.code == null ? 'stopped' : result.code;
|
|
1232
|
+
write(`${shown} finished with exit ${exit}${result.error ? ` (${result.error})` : ''}`);
|
|
1233
|
+
write('press any key to go back to the board');
|
|
1234
|
+
await nextKey();
|
|
1235
|
+
return `last: ${shown}`;
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
// v: replay what happened since the look this session started from (or the
|
|
1239
|
+
// last 12 hours), for this club or the league, then come back.
|
|
1240
|
+
const replayHere = async () => {
|
|
1241
|
+
const now = Date.now();
|
|
1242
|
+
const window = season.parseSince('', { now, lastSeen });
|
|
1243
|
+
const byClub = view === 'league';
|
|
1244
|
+
const model = season.loadReplay({ workspaceRoot, since: window.since, now, league: byClub });
|
|
1245
|
+
if (!model.plays.length) return `nothing happened since ${season.clockLabel(window.since, now)}`;
|
|
1246
|
+
queue.length = 0;
|
|
1247
|
+
setRaw(true);
|
|
1248
|
+
input.resume();
|
|
1249
|
+
const outcome = await runReplay({ plays: model.plays, since: window.since, now, byClub }, {
|
|
1250
|
+
write: text => output.write(text),
|
|
1251
|
+
poll: () => queue.splice(0).map(({ str, key }) => replayKeyName(str, key)),
|
|
1252
|
+
});
|
|
1253
|
+
const summary = season.replaySummary(model.plays, { stuck: model.stuck, since: window.since, now });
|
|
1254
|
+
write('');
|
|
1255
|
+
write(outcome === 'quit' ? `stopped early. ${summary}` : summary);
|
|
1256
|
+
write('press any key to go back to the board');
|
|
1257
|
+
await nextKey();
|
|
1258
|
+
return summary;
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
let atBoard = false;
|
|
1262
|
+
const refresh = setInterval(() => {
|
|
1263
|
+
if (atBoard) draw();
|
|
1264
|
+
}, REFRESH_MS);
|
|
1265
|
+
// A signal from outside (kill, a closed window) still hands the terminal
|
|
1266
|
+
// back the way it was found.
|
|
1267
|
+
const untrap = trapSignals(() => {
|
|
1268
|
+
clearInterval(refresh);
|
|
1269
|
+
input.off('keypress', onKeypress);
|
|
1270
|
+
setRaw(false);
|
|
1271
|
+
}, { ignoreInt: () => childRunning });
|
|
1272
|
+
|
|
1273
|
+
try {
|
|
1274
|
+
draw();
|
|
1275
|
+
for (;;) {
|
|
1276
|
+
atBoard = true;
|
|
1277
|
+
const { str, key } = await nextKey();
|
|
1278
|
+
atBoard = false;
|
|
1279
|
+
const name = (key && key.name) || str;
|
|
1280
|
+
if ((key && key.ctrl && key.name === 'c') || name === 'q' || name === 'escape') break;
|
|
1281
|
+
if (name === 'space' || name === 'return' || name === 'enter') {
|
|
1282
|
+
draw();
|
|
1283
|
+
} else if (name === 'g') {
|
|
1284
|
+
view = view === 'league' ? 'club' : 'league';
|
|
1285
|
+
note = '';
|
|
1286
|
+
draw();
|
|
1287
|
+
} else if (name === 'v') {
|
|
1288
|
+
note = await replayHere();
|
|
1289
|
+
draw();
|
|
1290
|
+
} else if (name === 'o' && view === 'club') {
|
|
1291
|
+
// owner target: three numbers, enter keeps the default shown
|
|
1292
|
+
const gmTarget = require('../lib/gm-target');
|
|
1293
|
+
const base = (lastBoard && lastBoard.target_default) || { landed: 1, max_abandoned: 2, max_stuck: 0 };
|
|
1294
|
+
write('');
|
|
1295
|
+
write(`owner target for the ${weekLabel(lastBoard ? lastBoard.week_of : Date.now())}`);
|
|
1296
|
+
const landed = await ask(`landed this week (enter for ${base.landed}): `);
|
|
1297
|
+
const maxAbandoned = landed === null ? null : await ask(`max abandoned (enter for ${base.max_abandoned}): `);
|
|
1298
|
+
const maxStuck = maxAbandoned === null ? null : await ask(`max stuck over a day (enter for ${base.max_stuck}): `);
|
|
1299
|
+
if (landed === null || maxAbandoned === null || maxStuck === null) {
|
|
1300
|
+
note = 'cancelled, target unchanged';
|
|
1301
|
+
} else {
|
|
1302
|
+
const built = targetFromInput(base, { landed, maxAbandoned, maxStuck });
|
|
1303
|
+
if (built.error) note = `target unchanged: ${built.error}`;
|
|
1304
|
+
else if (!gmTarget.writeTarget(workspaceRoot, built.target)) note = 'could not save the target';
|
|
1305
|
+
else note = `target set: ${targetSummary(built.target)}`;
|
|
1306
|
+
}
|
|
1307
|
+
draw();
|
|
1308
|
+
} else if (name === 'i') {
|
|
1309
|
+
write('');
|
|
1310
|
+
const who = await ask('card for which member? ');
|
|
1311
|
+
if (who === null || !who) {
|
|
1312
|
+
note = 'cancelled, nothing shown';
|
|
1313
|
+
} else {
|
|
1314
|
+
const result = season.loadCard(workspaceRoot, who);
|
|
1315
|
+
if (result.card) {
|
|
1316
|
+
output.write('\x1b[2J\x1b[H');
|
|
1317
|
+
write(renderCardLines(result.card).join('\n'));
|
|
1318
|
+
write('');
|
|
1319
|
+
write('press any key to go back to the board');
|
|
1320
|
+
await nextKey();
|
|
1321
|
+
note = '';
|
|
1322
|
+
} else {
|
|
1323
|
+
note = `${result.error}${result.close && result.close.length ? `. close names: ${result.close.join(', ')}` : ''}`;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
draw();
|
|
1327
|
+
} else if (name === 'c') {
|
|
1328
|
+
if (!leagueRowsSeen.length) {
|
|
1329
|
+
const fresh = loadLeagueBoard();
|
|
1330
|
+
leagueRowsSeen = [...fresh.rows, ...(fresh.hidden_clubs || [])];
|
|
1331
|
+
}
|
|
1332
|
+
write('');
|
|
1333
|
+
const names = leagueRowsSeen.slice(0, 6).map(row => row.club).join(', ');
|
|
1334
|
+
const answer = await ask(`switch to which club? (${names}${leagueRowsSeen.length > 6 ? ', ...' : ''}): `);
|
|
1335
|
+
const picked = answer ? pickClub(answer, leagueRowsSeen) : null;
|
|
1336
|
+
if (picked && picked.root) {
|
|
1337
|
+
switchClub(picked.root);
|
|
1338
|
+
note = `now looking at ${path.basename(picked.root)}`;
|
|
1339
|
+
} else if (picked && picked.ambiguous) {
|
|
1340
|
+
note = `more than one club is called that: ${picked.ambiguous.join(', ')}. type the full name`;
|
|
1341
|
+
} else {
|
|
1342
|
+
note = answer ? `no club called "${clip(answer, 40)}" in the league` : 'stayed on this club';
|
|
1343
|
+
}
|
|
1344
|
+
draw();
|
|
1345
|
+
} else if (view === 'club' && ['p', 'r', 'w', 's', 'u', 'l'].includes(name)) {
|
|
1346
|
+
note = await doAction(name);
|
|
1347
|
+
draw();
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
} finally {
|
|
1351
|
+
untrap();
|
|
1352
|
+
clearInterval(refresh);
|
|
1353
|
+
input.off('keypress', onKeypress);
|
|
1354
|
+
setRaw(false);
|
|
1355
|
+
input.pause();
|
|
1356
|
+
write('');
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
|
|
1361
|
+
|
|
1362
|
+
// Restores the terminal and exits with the usual code when a signal arrives:
|
|
1363
|
+
// raw mode off, cursor shown, then 130 / 143 / 129. Returns the function that
|
|
1364
|
+
// removes the handlers on a normal exit. `ignoreInt` lets SIGINT pass to a
|
|
1365
|
+
// running child instead.
|
|
1366
|
+
function trapSignals(cleanup, { ignoreInt = () => false } = {}) {
|
|
1367
|
+
const handlers = {};
|
|
1368
|
+
for (const sig of Object.keys(SIGNAL_EXIT_CODES)) {
|
|
1369
|
+
handlers[sig] = () => {
|
|
1370
|
+
if (sig === 'SIGINT' && ignoreInt()) return;
|
|
1371
|
+
try {
|
|
1372
|
+
cleanup();
|
|
1373
|
+
} catch {
|
|
1374
|
+
// restoring is best effort; the exit below still happens
|
|
1375
|
+
}
|
|
1376
|
+
try {
|
|
1377
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') process.stdin.setRawMode(false);
|
|
1378
|
+
} catch {
|
|
1379
|
+
// stdin may already be gone
|
|
1380
|
+
}
|
|
1381
|
+
try {
|
|
1382
|
+
process.stdout.write('\x1b[?25h\n');
|
|
1383
|
+
} catch {
|
|
1384
|
+
// stdout may already be gone
|
|
1385
|
+
}
|
|
1386
|
+
process.exit(SIGNAL_EXIT_CODES[sig]);
|
|
1387
|
+
};
|
|
1388
|
+
process.on(sig, handlers[sig]);
|
|
1389
|
+
}
|
|
1390
|
+
return () => {
|
|
1391
|
+
for (const sig of Object.keys(handlers)) process.off(sig, handlers[sig]);
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// ---- owner target -----------------------------------------------------------
|
|
1396
|
+
|
|
1397
|
+
// A whole number typed at a prompt or flag: empty means the default, below
|
|
1398
|
+
// `min` or not a number means no.
|
|
1399
|
+
function parseWhole(text, fallback, min = 0) {
|
|
1400
|
+
const raw = String(text == null ? '' : text).trim();
|
|
1401
|
+
if (!raw) return fallback;
|
|
1402
|
+
if (!/^\d+$/.test(raw)) return null;
|
|
1403
|
+
const n = Number(raw);
|
|
1404
|
+
return n >= min ? n : null;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// The target to save from what was typed, over a base target. Pure.
|
|
1408
|
+
// Returns { target } or { error }.
|
|
1409
|
+
function targetFromInput(base, { landed, maxAbandoned, maxStuck } = {}) {
|
|
1410
|
+
const next = {
|
|
1411
|
+
landed: parseWhole(landed, base.landed, 1),
|
|
1412
|
+
max_abandoned: parseWhole(maxAbandoned, base.max_abandoned, 0),
|
|
1413
|
+
max_stuck: parseWhole(maxStuck, base.max_stuck, 0),
|
|
1414
|
+
};
|
|
1415
|
+
if (next.landed == null) return { error: 'landed takes a whole number, 1 or more' };
|
|
1416
|
+
if (next.max_abandoned == null) return { error: 'max abandoned takes a whole number, 0 or more' };
|
|
1417
|
+
if (next.max_stuck == null) return { error: 'max stuck takes a whole number, 0 or more' };
|
|
1418
|
+
return { target: next };
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function targetSummary(target) {
|
|
1422
|
+
return `${target.landed} landed, at most ${target.max_abandoned} abandoned, at most ${target.max_stuck} stuck over a day`;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// gm target: show this week's target, or set or clear it. Only the target
|
|
1426
|
+
// file under ~/.atris/state/gm-targets is written.
|
|
1427
|
+
function gmTargetCommand(args = []) {
|
|
1428
|
+
const gmTarget = require('../lib/gm-target');
|
|
1429
|
+
const workspaceRoot = boardWorkspaceRoot(args);
|
|
1430
|
+
const board = loadBoard(workspaceRoot);
|
|
1431
|
+
if (board.error) return failRead(board.error);
|
|
1432
|
+
if (hasExactFlag(args, '--clear')) {
|
|
1433
|
+
if (!gmTarget.writeTarget(workspaceRoot, { cleared: true })) {
|
|
1434
|
+
console.error('could not save the target');
|
|
1435
|
+
process.exitCode = 1;
|
|
1436
|
+
return undefined;
|
|
1437
|
+
}
|
|
1438
|
+
console.log(`target cleared from the ${weekLabel(board.week_of)} on`);
|
|
1439
|
+
return undefined;
|
|
1440
|
+
}
|
|
1441
|
+
const given = {
|
|
1442
|
+
landed: flag(args, '--landed'),
|
|
1443
|
+
maxAbandoned: flag(args, '--max-abandoned'),
|
|
1444
|
+
maxStuck: flag(args, '--max-stuck'),
|
|
1445
|
+
};
|
|
1446
|
+
if (given.landed == null && given.maxAbandoned == null && given.maxStuck == null) {
|
|
1447
|
+
if (board.target) console.log(gmTarget.targetLine(board.target));
|
|
1448
|
+
else console.log(`no target for the ${weekLabel(board.week_of)}. suggested: ${targetSummary(board.target_default)}`);
|
|
1449
|
+
if (board.record) console.log(gmTarget.recordLine(board.record));
|
|
1450
|
+
if (board.no_history) console.log('no task history yet');
|
|
1451
|
+
return undefined;
|
|
1452
|
+
}
|
|
1453
|
+
const base = board.target ? board.target.target : board.target_default;
|
|
1454
|
+
const built = targetFromInput(base, given);
|
|
1455
|
+
if (built.error) {
|
|
1456
|
+
console.error(`target: ${built.error}`);
|
|
1457
|
+
process.exitCode = 1;
|
|
1458
|
+
return undefined;
|
|
1459
|
+
}
|
|
1460
|
+
if (!gmTarget.writeTarget(workspaceRoot, built.target)) {
|
|
1461
|
+
console.error('could not save the target');
|
|
1462
|
+
process.exitCode = 1;
|
|
1463
|
+
return undefined;
|
|
1464
|
+
}
|
|
1465
|
+
console.log(`target for the ${weekLabel(board.week_of)}: ${targetSummary(built.target)}`);
|
|
1466
|
+
const after = loadBoard(workspaceRoot);
|
|
1467
|
+
if (after.target) console.log(gmTarget.targetLine(after.target));
|
|
1468
|
+
if (after.no_history) console.log('no task history yet');
|
|
1469
|
+
return undefined;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// ---- phone ------------------------------------------------------------------
|
|
1473
|
+
|
|
1474
|
+
// gm phone: serve the decision cards to a phone on the same wifi. The link
|
|
1475
|
+
// carries a random token; every request without it gets 403. Ctrl-C stops it.
|
|
1476
|
+
// Commands started from the phone keep running on their own after that.
|
|
1477
|
+
async function gmPhone(args = []) {
|
|
1478
|
+
const http = require('http');
|
|
1479
|
+
const crypto = require('crypto');
|
|
1480
|
+
const phone = require('../lib/gm-phone');
|
|
1481
|
+
const { phonePage } = require('../lib/gm-phone-page');
|
|
1482
|
+
const gmTarget = require('../lib/gm-target');
|
|
1483
|
+
const { atrisHome } = require('../lib/state-home');
|
|
1484
|
+
const workspaceRoot = boardWorkspaceRoot(args);
|
|
1485
|
+
const portRaw = flag(args, '--port');
|
|
1486
|
+
const firstPort = portRaw == null ? 7777 : Number(portRaw);
|
|
1487
|
+
if (!Number.isInteger(firstPort) || firstPort < 1 || firstPort > 65535) {
|
|
1488
|
+
console.error('--port takes a whole number from 1 to 65535');
|
|
1489
|
+
process.exitCode = 2;
|
|
1490
|
+
return undefined;
|
|
1491
|
+
}
|
|
1492
|
+
const lan = phone.lanAddress();
|
|
1493
|
+
const addresses = [...new Set([lan, '127.0.0.1'].filter(Boolean))];
|
|
1494
|
+
// filled in once a port is open; the handler reads it on every request
|
|
1495
|
+
const hosts = [];
|
|
1496
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
1497
|
+
const stateDir = path.join(atrisHome(), 'state');
|
|
1498
|
+
const handler = phone.createPhoneHandler({
|
|
1499
|
+
workspaceRoot,
|
|
1500
|
+
token,
|
|
1501
|
+
hosts,
|
|
1502
|
+
loadView: () => phone.loadPhoneView(workspaceRoot),
|
|
1503
|
+
runStep: phone.makeStepRunner({ cliPath: path.join(__dirname, '..', 'bin', 'atris.js'), cwd: workspaceRoot, logDir: path.join(stateDir, 'gm-phone-runs') }),
|
|
1504
|
+
writeTarget: target => gmTarget.writeTarget(workspaceRoot, target),
|
|
1505
|
+
snapshotTarget: () => phone.targetSnapshot(workspaceRoot),
|
|
1506
|
+
logPath: path.join(stateDir, 'gm-phone-actions.jsonl'),
|
|
1507
|
+
page: phonePage,
|
|
1508
|
+
makeBetDesk: (log) => {
|
|
1509
|
+
const gmBet = require('../lib/gm-bet');
|
|
1510
|
+
return gmBet.createBetDesk({
|
|
1511
|
+
workspaceRoot,
|
|
1512
|
+
ledgerFile: gmBet.ledgerPath(stateDir),
|
|
1513
|
+
attemptsFile: gmBet.attemptsPath(stateDir),
|
|
1514
|
+
runCli: gmBet.makeCliRunner({ cliPath: path.join(__dirname, '..', 'bin', 'atris.js'), cwd: workspaceRoot }),
|
|
1515
|
+
log,
|
|
1516
|
+
// read the started bets' missions every 25 seconds, read-only
|
|
1517
|
+
pollMs: gmBet.JOB_POLL_MS,
|
|
1518
|
+
// the roster's reviewer gets up to 16 minutes to read the finished work
|
|
1519
|
+
reviewCli: gmBet.makeCliRunner({ cliPath: path.join(__dirname, '..', 'bin', 'atris.js'), cwd: workspaceRoot, timeoutMs: 16 * 60 * 1000 }),
|
|
1520
|
+
});
|
|
1521
|
+
},
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
const listenAll = async (port) => {
|
|
1525
|
+
const opened = [];
|
|
1526
|
+
try {
|
|
1527
|
+
for (const address of addresses) {
|
|
1528
|
+
const server = http.createServer((req, res) => { handler(req, res); });
|
|
1529
|
+
await new Promise((resolve, reject) => {
|
|
1530
|
+
server.once('error', reject);
|
|
1531
|
+
server.listen(port, address, resolve);
|
|
1532
|
+
});
|
|
1533
|
+
opened.push(server);
|
|
1534
|
+
}
|
|
1535
|
+
return opened;
|
|
1536
|
+
} catch (err) {
|
|
1537
|
+
for (const server of opened) server.close();
|
|
1538
|
+
throw err;
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
// With no --port, a taken 7777 moves on to the next free port (up to 10).
|
|
1542
|
+
let servers = null;
|
|
1543
|
+
let port = firstPort;
|
|
1544
|
+
for (let tries = portRaw == null ? 10 : 1; tries > 0 && !servers; tries -= 1) {
|
|
1545
|
+
try {
|
|
1546
|
+
servers = await listenAll(port);
|
|
1547
|
+
} catch (err) {
|
|
1548
|
+
if (!(err && err.code === 'EADDRINUSE' && tries > 1)) {
|
|
1549
|
+
console.error(err && err.code === 'EADDRINUSE' ? `port ${port} is taken. try --port ${port + 1}` : `could not start: ${err.message}`);
|
|
1550
|
+
process.exitCode = 1;
|
|
1551
|
+
return undefined;
|
|
1552
|
+
}
|
|
1553
|
+
port += 1;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
hosts.push(...addresses.map(a => `${a}:${port}`), `localhost:${port}`);
|
|
1557
|
+
|
|
1558
|
+
const link = `http://${lan || '127.0.0.1'}:${port}/?t=${token}`;
|
|
1559
|
+
console.log('');
|
|
1560
|
+
console.log(`your front office for ${path.basename(workspaceRoot)} is open.`);
|
|
1561
|
+
if (lan) {
|
|
1562
|
+
console.log('scan this with your phone camera, or open the link on the same wifi:');
|
|
1563
|
+
console.log('');
|
|
1564
|
+
try {
|
|
1565
|
+
const { qrLines } = require('../lib/qr');
|
|
1566
|
+
console.log(qrLines(link, { color: Boolean(process.stdout.isTTY) }).map(line => ` ${line}`).join('\n'));
|
|
1567
|
+
console.log('');
|
|
1568
|
+
} catch {
|
|
1569
|
+
// the link below still works without the picture
|
|
1570
|
+
}
|
|
1571
|
+
} else {
|
|
1572
|
+
console.log('no wifi address found, so only this mac can open it:');
|
|
1573
|
+
}
|
|
1574
|
+
console.log(` ${link}`);
|
|
1575
|
+
console.log('');
|
|
1576
|
+
console.log('anyone with this link can act for you until you stop it. ctrl-c stops it.');
|
|
1577
|
+
|
|
1578
|
+
if (hasExactFlag(args, '--send')) {
|
|
1579
|
+
const autoland = require('../lib/autoland');
|
|
1580
|
+
const to = String((autoland.readPolicy(workspaceRoot) || {}).imessage_to || '').trim();
|
|
1581
|
+
if (!to) {
|
|
1582
|
+
console.log('not texted: no number saved for texts. save one with: atris autoland on --to <your number>');
|
|
1583
|
+
} else {
|
|
1584
|
+
const sent = autoland.sendImessage(workspaceRoot, to, `your front office: ${link}`);
|
|
1585
|
+
console.log(sent.ok ? `texted the link to ${to}` : `could not text the link: ${sent.output}`);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
await new Promise((resolve) => {
|
|
1590
|
+
const stop = () => {
|
|
1591
|
+
process.off('SIGINT', stop);
|
|
1592
|
+
process.off('SIGTERM', stop);
|
|
1593
|
+
for (const server of servers) {
|
|
1594
|
+
server.close();
|
|
1595
|
+
if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
|
|
1596
|
+
}
|
|
1597
|
+
const running = handler.actions.filter(a => a.status === 'running').length;
|
|
1598
|
+
console.log('');
|
|
1599
|
+
console.log(running ? `phone page stopped. ${running} started from the phone still running on their own.` : 'phone page stopped.');
|
|
1600
|
+
resolve();
|
|
1601
|
+
};
|
|
1602
|
+
process.on('SIGINT', stop);
|
|
1603
|
+
process.on('SIGTERM', stop);
|
|
1604
|
+
});
|
|
1605
|
+
return undefined;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// ---- player card ------------------------------------------------------------
|
|
1609
|
+
|
|
1610
|
+
// One member's profile page as lines. Pure: card in, lines out.
|
|
1611
|
+
function renderCardLines(card, { width = termWidth() } = {}) {
|
|
1612
|
+
const { clockLabel } = require('../lib/gm-season');
|
|
1613
|
+
const lines = [];
|
|
1614
|
+
lines.push(`card | ${card.name} | ${card.workspace_name}`);
|
|
1615
|
+
const roleHead = `role: ${card.role || 'not recorded'} | ${card.awake ? 'awake' : 'asleep'} | `;
|
|
1616
|
+
const status = card.status.title ? `${card.status.state}: ${tail(card.status.title, roleHead.length + card.status.state.length + 2, width)}` : card.status.state;
|
|
1617
|
+
lines.push(`${roleHead}${status}`);
|
|
1618
|
+
lines.push('');
|
|
1619
|
+
const weekly = card.weekly.map(v => (v == null ? '-' : String(v))).join(' ');
|
|
1620
|
+
lines.push(`ovr ${card.ovr == null ? 'rookie' : card.ovr} | last 6 weeks, oldest first: ${weekly}`);
|
|
1621
|
+
const r = card.record_30d;
|
|
1622
|
+
const median = card.median_landing_hours == null ? 'no landings to time' : `median ${card.median_landing_hours}h from claim to landing`;
|
|
1623
|
+
lines.push(`record 30d: ${r.landed} landed, ${r.dropped} dropped, ${r.sent_back} sent back, ${r.abandoned} abandoned | ${median}`);
|
|
1624
|
+
const engine = card.engine
|
|
1625
|
+
? `engine: ${card.engine.engine}${card.engine.model ? ` (${card.engine.model})` : ''}, from ${card.engine.source}`
|
|
1626
|
+
: 'engine: not recorded';
|
|
1627
|
+
lines.push(engine);
|
|
1628
|
+
lines.push(`scouting: ${card.scouting}`);
|
|
1629
|
+
lines.push('');
|
|
1630
|
+
lines.push('last 8 plays');
|
|
1631
|
+
if (card.last_plays.length) {
|
|
1632
|
+
for (const play of card.last_plays) {
|
|
1633
|
+
const head = ` ${pad(clockLabel(play.at, card.now), 17)}${pad(play.outcome, 11)}`;
|
|
1634
|
+
lines.push(`${head}${tail(play.title, head.length, width)}`);
|
|
1635
|
+
}
|
|
1636
|
+
} else {
|
|
1637
|
+
lines.push(' no plays in the last 6 weeks');
|
|
1638
|
+
}
|
|
1639
|
+
return fitLines(lines, width);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// gm card <member>: read only. Unknown names exit 1 with the closest names.
|
|
1643
|
+
function gmCard(args = []) {
|
|
1644
|
+
const season = require('../lib/gm-season');
|
|
1645
|
+
const workspaceRoot = boardWorkspaceRoot(args);
|
|
1646
|
+
const name = positional(args)[0];
|
|
1647
|
+
const result = season.loadCard(workspaceRoot, name);
|
|
1648
|
+
if (!result.card) {
|
|
1649
|
+
console.error(result.error);
|
|
1650
|
+
if (result.close && result.close.length) console.error(`close names: ${result.close.join(', ')}`);
|
|
1651
|
+
process.exitCode = 1;
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
if (hasFlagOrValue(args, '--json')) {
|
|
1655
|
+
console.log(JSON.stringify({ schema: 'atris.gm_card.v1', ...result.card }, null, 2));
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
console.log(renderCardLines(result.card).join('\n'));
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// ---- replay: the night as a two-minute time-lapse -------------------------
|
|
1662
|
+
|
|
1663
|
+
const REPLAY_KEYS = 'keys: space pause f faster q quit';
|
|
1664
|
+
|
|
1665
|
+
function replayWho(play, byClub) {
|
|
1666
|
+
return byClub ? `${play.club}: ${play.member}` : play.member;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// One frame of the replay as lines. Pure: frame in, lines out.
|
|
1670
|
+
function renderReplayFrameLines(frame, { since, now, byClub = false, speed = 1, paused = false, interactive = false, width = termWidth() } = {}) {
|
|
1671
|
+
const { clockLabel } = require('../lib/gm-season');
|
|
1672
|
+
const lines = [];
|
|
1673
|
+
const at = frame.at ? clockLabel(frame.at, now) : 'the start';
|
|
1674
|
+
const state = paused ? ', paused' : (speed !== 1 ? `, ${speed}x` : '');
|
|
1675
|
+
lines.push(`replaying ${clockLabel(since, now)} to now, at ${at} (play ${frame.index + 1} of ${frame.total}${state})`);
|
|
1676
|
+
lines.push('');
|
|
1677
|
+
const keyW = nameWidth(frame.rows.map(r => r.key), 18);
|
|
1678
|
+
lines.push(` ${pad(byClub ? 'club' : 'name', keyW)}${pad('landed', 8)}${pad('dropped', 9)}${pad('sent back', 11)}abandoned`);
|
|
1679
|
+
for (const row of frame.rows) {
|
|
1680
|
+
lines.push(` ${cell(row.key, keyW)}${pad(row.landed, 8)}${pad(row.dropped, 9)}${pad(row.sent_back, 11)}${pad(row.abandoned, 11)}${row.bump ? '+1' : ''}`.replace(/\s+$/, ''));
|
|
1681
|
+
}
|
|
1682
|
+
lines.push('');
|
|
1683
|
+
lines.push('plays, newest first');
|
|
1684
|
+
const whoW = nameWidth(frame.ticker.map(p => replayWho(p, byClub)), 16, byClub ? NAME_CAP * 2 : NAME_CAP);
|
|
1685
|
+
for (const play of frame.ticker) {
|
|
1686
|
+
const mark = play.newest ? '> ' : ' ';
|
|
1687
|
+
const head = `${mark}${pad(clockLabel(play.at, now), 17)}${cell(replayWho(play, byClub), whoW)}${pad(play.play, 10)}`;
|
|
1688
|
+
lines.push(`${head}${tail(play.title, head.length, width)}`);
|
|
1689
|
+
}
|
|
1690
|
+
if (interactive) {
|
|
1691
|
+
lines.push('');
|
|
1692
|
+
lines.push(REPLAY_KEYS);
|
|
1693
|
+
}
|
|
1694
|
+
return fitLines(lines, width);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// Plain mode: every play in order, one line each, no redraw. Pure.
|
|
1698
|
+
function replayPlainLines(plays, { now, byClub = false, width = termWidth() } = {}) {
|
|
1699
|
+
const { clockLabel } = require('../lib/gm-season');
|
|
1700
|
+
const whoW = nameWidth(plays.map(p => replayWho(p, byClub)), 16, byClub ? NAME_CAP * 2 : NAME_CAP);
|
|
1701
|
+
return plays.map((play) => {
|
|
1702
|
+
const head = `${pad(clockLabel(play.at, now), 17)}${cell(replayWho(play, byClub), whoW)}${pad(play.play || play.kind.replace('_', ' '), 10)}`;
|
|
1703
|
+
return `${head}${tail(play.title, head.length, width)}`;
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function replaySleep(ms) {
|
|
1708
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// Plays the frames in place. `poll` returns key names pressed since the last
|
|
1712
|
+
// call (space, f, q). Resolves 'done' or 'quit'.
|
|
1713
|
+
async function runReplay(model, { write, poll, interactive = true }) {
|
|
1714
|
+
const season = require('../lib/gm-season');
|
|
1715
|
+
const { plays, since, now, byClub } = model;
|
|
1716
|
+
const rows = season.replayRows(plays, { byClub });
|
|
1717
|
+
let speed = Number(model.speed) > 0 ? Number(model.speed) : 1;
|
|
1718
|
+
let paused = false;
|
|
1719
|
+
let index = 0;
|
|
1720
|
+
const draw = () => {
|
|
1721
|
+
const frame = season.replayFrame(plays, index, { byClub, rows });
|
|
1722
|
+
const lines = renderReplayFrameLines(frame, { since, now, byClub, speed, paused, interactive });
|
|
1723
|
+
write(`\x1b[H${lines.map(line => `${line}\x1b[K`).join('\n')}\n\x1b[J`);
|
|
1724
|
+
};
|
|
1725
|
+
write('\x1b[2J');
|
|
1726
|
+
while (index < plays.length) {
|
|
1727
|
+
draw();
|
|
1728
|
+
let waited = 0;
|
|
1729
|
+
while (paused || waited < season.replayDelayMs(plays.length, speed)) {
|
|
1730
|
+
for (const key of poll()) {
|
|
1731
|
+
if (key === 'q') return 'quit';
|
|
1732
|
+
if (key === 'space') paused = !paused;
|
|
1733
|
+
if (key === 'f') speed *= 2;
|
|
1734
|
+
if (key === 'space' || key === 'f') draw();
|
|
1735
|
+
}
|
|
1736
|
+
await replaySleep(50);
|
|
1737
|
+
if (!paused) waited += 50;
|
|
1738
|
+
}
|
|
1739
|
+
index += 1;
|
|
1740
|
+
}
|
|
1741
|
+
index = plays.length - 1;
|
|
1742
|
+
draw();
|
|
1743
|
+
return 'done';
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function replayKeyName(str, key) {
|
|
1747
|
+
if (key && key.ctrl && key.name === 'c') return 'q';
|
|
1748
|
+
return (key && key.name) || str;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
async function gmReplay(args = []) {
|
|
1752
|
+
const season = require('../lib/gm-season');
|
|
1753
|
+
const workspaceRoot = boardWorkspaceRoot(args);
|
|
1754
|
+
const byClub = hasExactFlag(args, '--league');
|
|
1755
|
+
const now = Date.now();
|
|
1756
|
+
const window = season.parseSince(flag(args, '--since'), { now, lastSeen: season.readLastSeen(workspaceRoot) });
|
|
1757
|
+
if (!window) {
|
|
1758
|
+
console.error('replay: --since takes a span like 30m, 12h, 2d, or last-seen');
|
|
1759
|
+
process.exitCode = 1;
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
const speed = Number(flag(args, '--speed')) > 0 ? Number(flag(args, '--speed')) : 1;
|
|
1763
|
+
const model = season.loadReplay({ workspaceRoot, since: window.since, now, league: byClub });
|
|
1764
|
+
const where = byClub ? 'the league' : path.basename(workspaceRoot);
|
|
1765
|
+
if (model.error) {
|
|
1766
|
+
failRead(model.error);
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
if (!model.plays.length) {
|
|
1770
|
+
console.log(`nothing happened since ${season.clockLabel(window.since, now)}`);
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const summary = season.replaySummary(model.plays, { stuck: model.stuck, since: window.since, now });
|
|
1774
|
+
const plain = hasExactFlag(args, '--plain') || !(process.stdout.isTTY && process.stdin.isTTY);
|
|
1775
|
+
if (plain) {
|
|
1776
|
+
// A reader like head may close the pipe early; that is a normal end.
|
|
1777
|
+
process.stdout.on('error', (err) => {
|
|
1778
|
+
if (err && err.code === 'EPIPE') process.exit(0);
|
|
1779
|
+
});
|
|
1780
|
+
const lines = [
|
|
1781
|
+
`replay of ${where} since ${season.clockLabel(window.since, now)} (${window.label}), ${model.plays.length} plays`,
|
|
1782
|
+
...replayPlainLines(model.plays, { now, byClub }),
|
|
1783
|
+
summary,
|
|
1784
|
+
];
|
|
1785
|
+
// wait for the write to drain: the caller exits right after, and a pipe
|
|
1786
|
+
// write can still be in flight
|
|
1787
|
+
await new Promise(resolve => process.stdout.write(`${lines.join('\n')}\n`, resolve));
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
const readline = require('readline');
|
|
1791
|
+
const input = process.stdin;
|
|
1792
|
+
const pressed = [];
|
|
1793
|
+
const onKey = (str, key) => pressed.push(replayKeyName(str, key));
|
|
1794
|
+
readline.emitKeypressEvents(input);
|
|
1795
|
+
if (typeof input.setRawMode === 'function') input.setRawMode(true);
|
|
1796
|
+
input.resume();
|
|
1797
|
+
input.on('keypress', onKey);
|
|
1798
|
+
const untrap = trapSignals(() => input.off('keypress', onKey));
|
|
1799
|
+
try {
|
|
1800
|
+
await runReplay({ plays: model.plays, since: window.since, now, byClub, speed }, {
|
|
1801
|
+
write: text => process.stdout.write(text),
|
|
1802
|
+
poll: () => pressed.splice(0),
|
|
1803
|
+
});
|
|
1804
|
+
process.stdout.write(`\n${summary}\n`);
|
|
1805
|
+
} finally {
|
|
1806
|
+
untrap();
|
|
1807
|
+
input.off('keypress', onKey);
|
|
1808
|
+
if (typeof input.setRawMode === 'function') input.setRawMode(false);
|
|
1809
|
+
input.pause();
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function wantsPlay(args = []) {
|
|
1814
|
+
if (hasFlagOrValue(args, '--json') || hasFlagOrValue(args, '--watch')) return false;
|
|
1815
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
635
1818
|
// `gm <name>` is how people greet a team member in chat ("gm maze"). When the
|
|
636
1819
|
// positional names a real member (and not the manager persona), the same phrase
|
|
637
1820
|
// works in the terminal: wake that member instead of entering manager mode.
|
|
@@ -649,7 +1832,30 @@ async function gmCommand(...args) {
|
|
|
649
1832
|
return;
|
|
650
1833
|
}
|
|
651
1834
|
|
|
652
|
-
|
|
1835
|
+
if (positional(args)[0] === 'target') {
|
|
1836
|
+
const at = args.indexOf('target');
|
|
1837
|
+
return gmTargetCommand([...args.slice(0, at), ...args.slice(at + 1)]);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
if (positional(args)[0] === 'card') {
|
|
1841
|
+
const at = args.indexOf('card');
|
|
1842
|
+
return gmCard([...args.slice(0, at), ...args.slice(at + 1)]);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
if (positional(args)[0] === 'phone') {
|
|
1846
|
+
const at = args.indexOf('phone');
|
|
1847
|
+
return gmPhone([...args.slice(0, at), ...args.slice(at + 1)]);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
if (positional(args)[0] === 'replay') {
|
|
1851
|
+
const at = args.indexOf('replay');
|
|
1852
|
+
return gmReplay([...args.slice(0, at), ...args.slice(at + 1)]);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// "gm <member>" wakes that member, but never when the ask is a read: any
|
|
1856
|
+
// --board, --league, or --json request only looks.
|
|
1857
|
+
const readOnly = hasExactFlag(args, '--board') || hasExactFlag(args, '--league') || hasFlagOrValue(args, '--json');
|
|
1858
|
+
const member = readOnly ? null : wakeableMember(args);
|
|
653
1859
|
if (member) {
|
|
654
1860
|
const { isMemberAwake } = require('../lib/member-switches');
|
|
655
1861
|
if (!isMemberAwake(member)) {
|
|
@@ -661,6 +1867,44 @@ async function gmCommand(...args) {
|
|
|
661
1867
|
return memberCommand('wake', member, ...passthrough);
|
|
662
1868
|
}
|
|
663
1869
|
|
|
1870
|
+
// Any --board is read-only: it never seeds a starter task or touches the
|
|
1871
|
+
// task db for writing, even with --json or --league.
|
|
1872
|
+
if (hasExactFlag(args, '--board') && hasFlagOrValue(args, '--json')) {
|
|
1873
|
+
const workspaceRoot = boardWorkspaceRoot(args);
|
|
1874
|
+
const body = {
|
|
1875
|
+
schema: 'atris.gm_board.v1',
|
|
1876
|
+
generated_at: new Date().toISOString(),
|
|
1877
|
+
workspace_root: workspaceRoot,
|
|
1878
|
+
workspace_name: path.basename(workspaceRoot),
|
|
1879
|
+
season: loadBoard(workspaceRoot),
|
|
1880
|
+
};
|
|
1881
|
+
if (hasExactFlag(args, '--league')) body.league = loadLeagueBoard();
|
|
1882
|
+
const readError = body.season.error || (body.league && body.league.error);
|
|
1883
|
+
if (readError) {
|
|
1884
|
+
failRead(readError);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
console.log(JSON.stringify(body, null, 2));
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// --league prints the league once (with or without --board); in --json it
|
|
1892
|
+
// adds a `league` field instead, so plain --json stays a one-folder read.
|
|
1893
|
+
if (hasExactFlag(args, '--league') && !hasFlagOrValue(args, '--json')) {
|
|
1894
|
+
printLeague();
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
if (hasExactFlag(args, '--board') && !hasFlagOrValue(args, '--json')) {
|
|
1899
|
+
printBoard(args);
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
if (wantsPlay(args)) {
|
|
1904
|
+
await gmPlay(args);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
664
1908
|
if (!hasFlagOrValue(args, '--json') && !hasFlagOrValue(args, '--watch')) {
|
|
665
1909
|
try {
|
|
666
1910
|
const { gameCommand } = require('./game');
|
|
@@ -673,6 +1917,18 @@ async function gmCommand(...args) {
|
|
|
673
1917
|
|
|
674
1918
|
const state = gmState(args);
|
|
675
1919
|
if (hasFlagOrValue(args, '--json')) {
|
|
1920
|
+
try {
|
|
1921
|
+
state.season = loadBoard(state.workspace_root);
|
|
1922
|
+
} catch {
|
|
1923
|
+
state.season = null;
|
|
1924
|
+
}
|
|
1925
|
+
if (hasExactFlag(args, '--league')) {
|
|
1926
|
+
try {
|
|
1927
|
+
state.league = loadLeagueBoard();
|
|
1928
|
+
} catch {
|
|
1929
|
+
state.league = null;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
676
1932
|
console.log(JSON.stringify(state, null, 2));
|
|
677
1933
|
return;
|
|
678
1934
|
}
|
|
@@ -683,5 +1939,15 @@ async function gmCommand(...args) {
|
|
|
683
1939
|
}
|
|
684
1940
|
|
|
685
1941
|
module.exports = {
|
|
1942
|
+
displayCommand,
|
|
686
1943
|
gmCommand,
|
|
1944
|
+
pickClub,
|
|
1945
|
+
playAction,
|
|
1946
|
+
renderBoardLines,
|
|
1947
|
+
renderCardLines,
|
|
1948
|
+
targetFromInput,
|
|
1949
|
+
renderLeagueLines,
|
|
1950
|
+
renderReplayFrameLines,
|
|
1951
|
+
replayPlainLines,
|
|
1952
|
+
runReplay,
|
|
687
1953
|
};
|