azeroth-shell 0.1.2 → 0.2.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.
Files changed (63) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +9 -1
  4. package/src/autopilot/plan.ts +8 -11
  5. package/src/autopilot/view.ts +12 -11
  6. package/src/cli/combatLogFormat.ts +22 -0
  7. package/src/cli/gzLoader.mjs +47 -6
  8. package/src/cli/hdArt.mjs +81 -0
  9. package/src/cli/iconArt.mjs +135 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +295 -0
  12. package/src/cli/paperdoll.mjs +76 -0
  13. package/src/cli/perfCheck.mjs +56 -0
  14. package/src/cli/repl.mjs +32 -7
  15. package/src/cli/rotationText.mjs +6 -6
  16. package/src/cli/sixel.mjs +307 -0
  17. package/src/cli/term.mjs +106 -12
  18. package/src/cli/text.mjs +5 -3
  19. package/src/cli/tui.mjs +231 -95
  20. package/src/cli/tuiAutopilot.mjs +347 -0
  21. package/src/cli/tuiBg.mjs +370 -41
  22. package/src/cli/tuiCheck.mjs +362 -27
  23. package/src/cli/tuiCity.mjs +118 -13
  24. package/src/cli/tuiCompanions.mjs +25 -15
  25. package/src/cli/tuiCreate.mjs +223 -0
  26. package/src/cli/tuiDungeon.mjs +1 -1
  27. package/src/cli/tuiFight.mjs +423 -86
  28. package/src/cli/tuiFightPanes.mjs +27 -8
  29. package/src/cli/tuiFloor.mjs +1 -1
  30. package/src/cli/tuiGear.mjs +10 -6
  31. package/src/cli/tuiLoot.mjs +1 -0
  32. package/src/cli/tuiPaperdoll.mjs +107 -0
  33. package/src/cli/tuiPartyStage.mjs +177 -0
  34. package/src/cli/tuiProfessions.mjs +4 -2
  35. package/src/cli/tuiRotationCond.mjs +390 -0
  36. package/src/cli/tuiSaves.mjs +10 -1
  37. package/src/cli/tuiSearch.mjs +75 -22
  38. package/src/cli/tuiStage.mjs +304 -0
  39. package/src/cli/tuiTalentTree.mjs +124 -0
  40. package/src/cli/tuiViews.mjs +284 -110
  41. package/src/cli/tuiWorld.mjs +2 -2
  42. package/src/data/gear-art.bin.br +0 -0
  43. package/src/data/hd-art.json +114 -0
  44. package/src/data/icon-art.bin.gz +0 -0
  45. package/src/data/model-art.bin.gz +0 -0
  46. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  47. package/src/domain/bg/match.ts +85 -24
  48. package/src/domain/bg/strategies.ts +144 -29
  49. package/src/domain/bg/types.ts +2 -0
  50. package/src/domain/bg/wsg.ts +35 -20
  51. package/src/domain/quests/doQuest.ts +24 -6
  52. package/src/domain/rotation/build.ts +3031 -3031
  53. package/src/domain/rotation/classes/paladin.ts +16 -11
  54. package/src/domain/rotation/evaluate.ts +3 -0
  55. package/src/engine/combat/applySpell.ts +20 -9
  56. package/src/engine/combat/applySpellLate.ts +16 -12
  57. package/src/engine/combat/areaAura.ts +284 -283
  58. package/src/engine/combat/fighterFactory.ts +1 -1
  59. package/src/engine/combat/pickAction.ts +39 -1
  60. package/src/engine/combat/procsDamage.ts +31 -1
  61. package/src/engine/combat/sessionChase.ts +7 -1
  62. package/src/engine/combat/sessionMotionKit.ts +40 -2
  63. package/src/quests/quests.ts +60 -0
package/src/cli/tuiBg.mjs CHANGED
@@ -1,14 +1,17 @@
1
1
  // Warsong Gulch and Arathi Basin as a text match, from the Honor window. The map, rules, roster and strategy layer
2
2
  // are phonecraft's own (domain/bg: BgWorld, installWsg / installAb, bgRoster, BgMatch); what is not ported is
3
3
  // bgScreen.ts's whole-match combat session. Here the world resolves its own engagements (BgMatch's attrition
4
- // resolver, flat damage and healing per body), so a match is watched rather than fought: you are one seat on your
5
- // team with an assignment you pick (flag carrier, escort, defence; assault or hold a base), and the scoreboard,
6
- // the flags or bases and the feed run in front of you. Honor and Marks of Honor settle as bgScreen.ts does.
4
+ // resolver, flat damage and healing per body), so a fight is watched, but the field is yours to play: a strip map
5
+ // of the gulch above the scoreboard, orders for your own seat by place (our flag room, mid, their graveyard, a
6
+ // buff, the flag itself) or an autopilot job the strategy layer plays, and team calls to your side through
7
+ // BgMatch's playerCommanded hook. The clock drops back to 1x when a flag changes hands, so you get a moment to
8
+ // react. Enemies are drawn from your team's BgView, the same fair-vision rule the bots are held to. Honor and
9
+ // Marks of Honor settle as bgScreen.ts does.
7
10
  import { bold, dim, yellow, green, red, cyan, fit, bar } from './text.mjs';
8
11
  import { BATTLEGROUNDS } from '../domain/run/battlegroundRun.ts';
9
12
  import { BgWorld } from '../domain/bg/bgWorld.ts';
10
13
  import { BgMatch, attritionResolver } from '../domain/bg/match.ts';
11
- import { installWsg, wsgGraveyard, WSG_GEOMETRY } from '../domain/bg/wsg.ts';
14
+ import { installWsg, wsgGraveyard, WSG_GEOMETRY, flagObjectiveId } from '../domain/bg/wsg.ts';
12
15
  import { installAb, abGraveyard, AB_GEOMETRY, AB_NODES } from '../domain/bg/ab.ts';
13
16
  import { buildFriendlySeats, buildEnemySeats, seatLevel } from '../domain/bg/bgRoster.ts';
14
17
  import { bgTier } from '../domain/bg/bgTier.ts';
@@ -24,7 +27,9 @@ const H = (text) => ({ text, header: true });
24
27
  const TEAM = { alliance: 'Alliance', horde: 'Horde' };
25
28
  const paintTeam = (t, s) => (t === 'horde' ? red(s) : cyan(s));
26
29
  const COUNTDOWN_MS = 10_000;
27
- const MATCH_MAX_MS = 60 * 60_000;
30
+ // Vanilla 1.12 had no clock and a double turtle could run for hours; patch 2.3 put 25 minutes on the
31
+ // gulch, which is what an unattended match here needs to reach a result. A tied clock goes to kills.
32
+ const MATCH_MAX_MS = 25 * 60_000;
28
33
  // The job boards phonecraft deals seats from (bgScreen.ts:140-151).
29
34
  const WSG_ROLES = ['flag-carrier', 'fc-escort', 'offense', 'defense', 'defense', 'mid', 'offense', 'fc-escort', 'mid', 'flex'];
30
35
  const AB_ROLES = ['node-assault', 'node-assault', 'node-defense', 'node-assault', 'node-defense'];
@@ -34,6 +39,17 @@ const ROLE_TEXT = {
34
39
  'node-assault': 'assault their bases', 'node-defense': 'hold our bases',
35
40
  };
36
41
  const STEPS_PER_TICK = 5; // world ticks (100 ms each) per screen tick at 1x
42
+ /** How long a team call holds your side before the strategy layer takes its jobs back. */
43
+ const CALL_MS = 45_000;
44
+ const ASCII = !!process.env.AZSH_ASCII;
45
+ const GLYPH = ASCII ? { flag: 'F', fight: 'x', dash: '-', mid: ':' } : { flag: '⚑', fight: '⚔', dash: '─', mid: '·' };
46
+ const MAP_ROWS = 7;
47
+ // The gulch is a long, narrow lane: Warsong (Horde) holds the west end and Silverwing (Alliance) the east, so the
48
+ // strip draws Horde on the left and Alliance on the right, at about 10 yards a column and 34 a row.
49
+ const WSG_FIELD = { x0: 895, x1: 1565, y0: 1335, y1: 1575 };
50
+ const POWERUP_CHAR = { speed: 's', regen: 'r', zerk: 'z' };
51
+ const xy = (p) => ({ x: p.x, y: p.y });
52
+ const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
37
53
 
38
54
  /** The Honor window's Warsong / Arathi rows. */
39
55
  export function objectiveBgRows(app) {
@@ -66,33 +82,53 @@ export function startBattleground(app, bgId) {
66
82
  const start = isAb ? AB_GEOMETRY.start : WSG_GEOMETRY.start;
67
83
  const roles = isAb ? AB_ROLES : WSG_ROLES;
68
84
  const addTeam = (side, seats) => {
69
- seats.forEach((seat, i) => {
85
+ const bodies = seats.map((seat, i) => {
70
86
  const isPlayer = seat.kind === 'player';
71
87
  const lvl = seatLevel(seat, state.char.level);
72
- const hp = maxHealth(isPlayer ? state.char : companionCharacter(seat.comp, lvl, ds), ds);
88
+ return { seat, i, isPlayer, lvl, hp: maxHealth(isPlayer ? state.char : companionCharacter(seat.comp, lvl, ds), ds), heals: isPlayer ? state.squadRole === 'healer' : seat.comp.role === 'healer' };
89
+ });
90
+ // The flag runs on the sturdiest body that does not heal, as a real team's does; dealt by seat, the
91
+ // run went to seat 0, a 560-hp healer who died 100 yd out of the enemy flag room every time.
92
+ const runner = bodies.filter((b) => !b.heals).sort((a, b) => b.hp - a.hp)[0] ?? bodies[0];
93
+ const rest = roles.filter((r) => r !== 'flag-carrier');
94
+ let k = 0;
95
+ for (const b of bodies) {
96
+ const { seat, i, isPlayer, lvl, hp } = b;
97
+ const role = isAb ? roles[i % roles.length] : b === runner ? 'flag-carrier' : rest[(k++) % rest.length];
73
98
  world.addUnit({
74
99
  id: `${side[0]}${i}`, team: side, name: isPlayer ? state.char.name : seat.comp.name, cls: isPlayer ? state.char.cls : seat.comp.cls, level: lvl,
75
100
  pos: { x: start[side].x + (i % 5) - 2, y: start[side].y + Math.floor(i / 5) * 3 - 1 }, z: start[side].z ?? 0,
76
- hp, maxHp: hp, role: roles[i % roles.length], order: { kind: 'hold' }, speedYd: BASE_RUN_SPEED,
77
- // Not driven by hand here: the strategy layer plays your seat to the assignment you pick.
101
+ hp, maxHp: hp, role, order: { kind: 'hold' }, speedYd: BASE_RUN_SPEED,
102
+ // The resolver heals with the seats the roster made healers (three a side), not with every
103
+ // paladin, priest, shaman and druid: by class, six of ten bodies a side healed and nobody died.
104
+ heals: b.heals,
105
+ // Autopilot to begin with: the strategy layer plays your seat to the job you pick. Your first order by
106
+ // place flips this to true and the layer lets go of you (match.ts runStrategy).
78
107
  human: false,
79
108
  });
80
- });
109
+ }
81
110
  world.setVision(side, FAIR_VISION);
82
111
  };
83
112
  addTeam(team, friendly);
84
113
  addTeam(foe, enemies);
85
- const match = new BgMatch({
86
- world, seed: run.seed, strategyMs: 1000, strategyMsByTeam: { [foe]: tier.enemyStrategyMs },
87
- resolveShard: attritionResolver(), graveyard: isAb ? (t) => abGraveyard(world, t) : wsgGraveyard, respawnMs: 30_000,
88
- });
89
114
  const bg = {
90
- def, team, foe, isAb, world, match, wsgEvents, abEvents, selfId: run.selfId ?? `${team[0]}0`,
115
+ def, team, foe, isAb, world, match: null, wsgEvents, abEvents, selfId: run.selfId ?? `${team[0]}0`,
91
116
  wsgAt: 0, abAt: 0, honor: 0, kills: 0, caps: 0, marks: 0, speed: 1, paused: false, timer: null, settled: false,
117
+ // Both sides' kills, from the field's deaths: the kill line under the map counts only yours.
118
+ teamKills: { alliance: 0, horde: 0 }, dead: new Set(),
92
119
  bracket: bgBracketId(state.char.level, 10, 60),
120
+ // Your standing order ({ label, make }) while you drive your own seat, and the label of what it resolved to.
121
+ my: null, myLabel: null,
122
+ // Team calls: unit id → match clock the call expires on. The strategy layer skips these until then.
123
+ commanded: new Map(), slowLatch: null, zones: null,
93
124
  };
125
+ bg.match = new BgMatch({
126
+ world, seed: run.seed, strategyMs: 1000, strategyMsByTeam: { [foe]: tier.enemyStrategyMs },
127
+ resolveShard: attritionResolver(), graveyard: isAb ? (t) => abGraveyard(world, t) : wsgGraveyard, respawnMs: 30_000,
128
+ playerCommanded: (id) => (bg.commanded.get(id) ?? -Infinity) > world.t,
129
+ });
94
130
  app.bg = bg;
95
- app.say(yellow(`You enter ${def.name} for the ${TEAM[team]}. ${run.teamSize}v${run.teamSize}.`) + dim(' The gates open in 10 seconds.'));
131
+ app.say(yellow(`You enter ${def.name} for the ${TEAM[team]}. ${run.teamSize}v${run.teamSize}.`) + dim(' The gates open in 10 seconds. o: your orders, t: a team call.'));
96
132
  const view = bgView(app, bg);
97
133
  app.push(view);
98
134
  bg.timer = app.term.setInterval(() => { if (!bg.paused) { stepBg(app, bg, STEPS_PER_TICK * bg.speed); app.renderSoon(); } }, 100);
@@ -115,13 +151,34 @@ function bankRosterBonus(app, bg, side, cp) {
115
151
  for (const u of bg.world.units.values()) if (u.team === side && u.id !== bg.selfId) app.g.bankWeeklyHonor(u.name, cp, 0);
116
152
  }
117
153
 
154
+ /** Drop the clock back to 1x at a decision point, once. */
155
+ function slowDown(app, bg, why) {
156
+ if (bg.speed <= 1) return;
157
+ bg.speed = 1;
158
+ app.say(dim(`Back to 1x: ${why}.`));
159
+ }
160
+
161
+ /** The enemy carrying OUR flag, if any. */
162
+ function theirCarrier(bg) {
163
+ const f = bg.world.objectives.get(flagObjectiveId(bg.team));
164
+ const c = f?.state === 'carried' ? bg.world.units.get(f.carrier) : null;
165
+ return c && bg.world.alive(c) ? c : null;
166
+ }
167
+
118
168
  /** Run `n` world ticks and turn what happened into log lines and honor. */
119
169
  export function stepBg(app, bg, n) {
120
170
  const { world, match } = bg;
121
171
  for (let i = 0; i < n && !world.ended; i++) match.tick();
172
+ // Deaths this step, by the side that scored them. Credits skip an honorless victim, so count bodies.
173
+ for (const u of world.units.values()) {
174
+ if (u.deadUntilMs != null) { if (!bg.dead.has(u.id)) { bg.dead.add(u.id); bg.teamKills[OTHER_TEAM[u.team]]++; } }
175
+ else bg.dead.delete(u.id);
176
+ }
122
177
  for (const c of world.takeKillCredit()) {
123
178
  const killer = world.units.get(c.killer), victim = world.units.get(c.victim);
124
- if (!killer || !victim || killer.team === victim.team || world.isHonorless(victim)) continue;
179
+ if (!killer || !victim || killer.team === victim.team) continue;
180
+ if (killer.id !== bg.selfId) app.say(dim(`${paintTeam(killer.team, killer.name)} kills ${paintTeam(victim.team, victim.name)}.`));
181
+ if (world.isHonorless(victim)) continue;
125
182
  if (killer.id === bg.selfId) {
126
183
  // Player.cpp:6645 RewardHonor: the rank-and-level formula with the per-victim falloff.
127
184
  const res = recordHonorableKill(app.g.state.char.honor ?? newHonorState(), { level: app.ch.level }, { id: victim.name, level: victim.level, rankPoints: 0 }, new Date().toISOString().slice(0, 10));
@@ -133,11 +190,12 @@ export function stepBg(app, bg, n) {
133
190
  }
134
191
  for (; bg.wsgAt < bg.wsgEvents.length; bg.wsgAt++) {
135
192
  const e = bg.wsgEvents[bg.wsgAt];
136
- if (e.kind === 'pickup') app.say(`${unitName(bg, e.unit)} picks up the ${TEAM[OTHER_TEAM[e.team]]} flag!`);
137
- else if (e.kind === 'drop') app.say(dim(`${unitName(bg, e.unit)} drops the flag.`));
193
+ if (e.kind === 'pickup') { app.say(`${unitName(bg, e.unit)} picks up the ${TEAM[OTHER_TEAM[e.team]]} flag!`); slowDown(app, bg, 'a flag is up'); }
194
+ else if (e.kind === 'drop') { app.say(dim(`${unitName(bg, e.unit)} drops the flag.`)); slowDown(app, bg, 'a flag is on the ground'); }
138
195
  else if (e.kind === 'return') app.say(dim(`The ${TEAM[e.team]} flag returns to its base.`));
139
196
  else if (e.kind === 'capture') {
140
197
  app.say(bold(paintTeam(e.team, `${unitName(bg, e.unit)} captures the flag for the ${TEAM[e.team]}!`)));
198
+ slowDown(app, bg, 'a capture');
141
199
  const cp = WSG_FLAG_CAPTURED_HONOR[bg.bracket] ?? 0;
142
200
  if (e.team === bg.team) { bg.caps++; awardCp(app, bg, cp); }
143
201
  bankRosterBonus(app, bg, e.team, cp);
@@ -145,21 +203,57 @@ export function stepBg(app, bg, n) {
145
203
  }
146
204
  for (; bg.abAt < bg.abEvents.length; bg.abAt++) {
147
205
  const e = bg.abEvents[bg.abAt];
148
- if (e.kind === 'assault') { app.say(`${unitName(bg, e.unit)} assaults the ${nodeName(e.node)}.`); if (e.team === bg.team && e.unit === bg.selfId) bg.caps++; }
206
+ if (e.kind === 'assault') { app.say(`${unitName(bg, e.unit)} assaults the ${nodeName(e.node)}.`); if (e.team === bg.team && e.unit === bg.selfId) bg.caps++; slowDown(app, bg, `the ${nodeName(e.node)} is under assault`); }
149
207
  else if (e.kind === 'capture') app.say(bold(paintTeam(e.team, `The ${TEAM[e.team]} claim the ${nodeName(e.node)}!`)));
150
208
  else if (e.kind === 'defend') { app.say(`${unitName(bg, e.unit)} defends the ${nodeName(e.node)}.`); if (e.team === bg.team && e.unit === bg.selfId) bg.caps++; }
151
- else if (e.kind === 'near-victory') app.say(bold(`The ${TEAM[e.team]} are close to victory!`));
209
+ else if (e.kind === 'near-victory') { app.say(bold(`The ${TEAM[e.team]} are close to victory!`)); slowDown(app, bg, 'the end is near'); }
152
210
  else if (e.kind === 'honor') { const cp = hkHonorAtLevel(app.ch.level, e.amount ?? 1); if (e.team === bg.team) awardCp(app, bg, cp); bankRosterBonus(app, bg, e.team, cp); }
153
211
  }
212
+ if (!bg.isAb) {
213
+ // Their carrier closing on a score while their flag sits at home: the moment to react.
214
+ const tc = theirCarrier(bg);
215
+ if (tc && world.objectives.get(flagObjectiveId(bg.foe))?.state === 'base' && dist(tc.pos, WSG_GEOMETRY.flagBase[bg.foe]) < 80) {
216
+ if (bg.slowLatch !== tc.id) { bg.slowLatch = tc.id; slowDown(app, bg, `${tc.name} is about to score`); }
217
+ } else bg.slowLatch = null;
218
+ }
219
+ expireCalls(bg);
220
+ driveSelf(app, bg);
154
221
  if (world.ended && !bg.settled) settle(app, bg);
155
222
  }
156
223
 
224
+ // A call lapses when its clock runs out, when the body died (kill() reset it to hold and the strategy layer must
225
+ // have it back on respawn), when it picked up the flag (runFlagHome is the layer's job), or when the unit it
226
+ // was following is gone.
227
+ function expireCalls(bg) {
228
+ const { world } = bg;
229
+ for (const [id, until] of bg.commanded) {
230
+ const u = world.units.get(id);
231
+ const tgt = u?.order.kind === 'follow' ? world.units.get(u.order.target) : null;
232
+ if (!u || world.t >= until || !world.alive(u) || u.carryingFlag || u.order.kind === 'hold' || (u.order.kind === 'follow' && !(tgt && world.alive(tgt)))) bg.commanded.delete(id);
233
+ }
234
+ }
235
+
236
+ // Your standing order, re-resolved every screen tick: a respawn walks you back to your place, "take their flag"
237
+ // follows the flag wherever it went, and an order with nothing left to do lets go of you.
238
+ function driveSelf(app, bg) {
239
+ const me = bg.world.units.get(bg.selfId);
240
+ if (!me || !me.human || !bg.my || !bg.world.alive(me)) return;
241
+ const r = bg.my.make();
242
+ if (!r) { app.say(dim(`${bg.myLabel ?? bg.my.label}: nothing left to do. You hold here.`)); bg.my = null; bg.myLabel = 'holding here'; return; }
243
+ bg.world.issue(me.id, r.order, bg.team);
244
+ bg.myLabel = r.label ?? bg.my.label;
245
+ }
246
+
157
247
  /** Marks, the win honor, the weekly pass, then out of the battleground (bgScreen.ts:1277-1330). */
158
248
  function settle(app, bg) {
159
249
  bg.settled = true;
160
250
  if (bg.timer) app.term.clearInterval(bg.timer);
161
251
  bg.timer = null;
162
252
  const { world } = bg;
253
+ if (world.winner == null && bg.teamKills.alliance !== bg.teamKills.horde) {
254
+ world.winner = bg.teamKills.alliance > bg.teamKills.horde ? 'alliance' : 'horde';
255
+ app.say(yellow(`Time. The ${TEAM[world.winner]} take it on kills, ${bg.teamKills[world.winner]} to ${bg.teamKills[OTHER_TEAM[world.winner]]}.`));
256
+ }
163
257
  const won = world.winner === bg.team, drawn = world.winner == null;
164
258
  const marks = bg.isAb ? BG_MARK_SPELL.ab : BG_MARK_SPELL.wsg;
165
259
  const spell = marks ? app.ds.spell(won ? marks.winner : marks.loser) : null;
@@ -191,12 +285,246 @@ function leave(app, bg) {
191
285
  app.bg = null;
192
286
  }
193
287
 
288
+ // ---- places ------------------------------------------------------------------------------------------------
289
+
290
+ /** The places you can be sent, named from your side of the field. */
291
+ function zonesOf(bg) {
292
+ if (bg.zones) return bg.zones;
293
+ const t = bg.team, f = bg.foe;
294
+ const z = [];
295
+ if (bg.isAb) {
296
+ z.push({ id: 'our-base', label: 'our base', pos: xy(AB_GEOMETRY.baseGraveyard[t]) });
297
+ for (const n of AB_NODES) z.push({ id: n.id, label: `the ${n.name}`, pos: { x: n.x, y: n.y } });
298
+ z.push({ id: 'their-base', label: 'their base', pos: xy(AB_GEOMETRY.baseGraveyard[f]) });
299
+ } else {
300
+ const g = WSG_GEOMETRY;
301
+ const buff = (kind, side) => xy(g.powerups.find((p) => p.id === `${kind}-${side[0]}`));
302
+ z.push(
303
+ { id: 'our-base', label: 'our flag room', pos: xy(g.flagBase[t]) },
304
+ { id: 'our-gy', label: 'our graveyard', pos: xy(g.graveyard[t]) },
305
+ { id: 'our-speed', label: 'our speed buff', pos: buff('speed', t) },
306
+ { id: 'our-regen', label: 'our restoration buff', pos: buff('regen', t) },
307
+ { id: 'our-zerk', label: 'our berserking buff', pos: buff('zerk', t) },
308
+ { id: 'mid', label: 'mid', pos: xy(g.mid) },
309
+ { id: 'their-zerk', label: 'their berserking buff', pos: buff('zerk', f) },
310
+ { id: 'their-regen', label: 'their restoration buff', pos: buff('regen', f) },
311
+ { id: 'their-speed', label: 'their speed buff', pos: buff('speed', f) },
312
+ { id: 'their-gy', label: 'their graveyard', pos: xy(g.graveyard[f]) },
313
+ { id: 'their-base', label: 'their flag room', pos: xy(g.flagBase[f]) },
314
+ );
315
+ }
316
+ bg.zones = z;
317
+ return z;
318
+ }
319
+
320
+ /** Where on the field a point is, in words: the nearest named place, else which half. */
321
+ function placeName(bg, pos) {
322
+ if (!pos) return 'the field';
323
+ let best = null, bd = 60;
324
+ for (const z of zonesOf(bg)) { const d = dist(z.pos, pos); if (d < bd) { bd = d; best = z; } }
325
+ if (best) return best.label;
326
+ if (bg.isAb) return 'the field';
327
+ return (pos.x < WSG_GEOMETRY.mid.x ? 'horde' : 'alliance') === bg.team ? 'our half' : 'their half';
328
+ }
329
+
330
+ // ---- the map strip -----------------------------------------------------------------------------------------
331
+
332
+ function mapWidth(app) { return Math.max(40, Math.min(72, app.term.cols - 6)); }
333
+
334
+ /** The gulch as a strip: flags, graveyards (G), buffs (s r z), each side's bodies, fights, and you as @. */
335
+ function wsgMapRows(app, bg) {
336
+ const { world } = bg;
337
+ const W = mapWidth(app), F = WSG_FIELD;
338
+ const colYd = (F.x1 - F.x0) / W, rowYd = (F.y1 - F.y0) / MAP_ROWS;
339
+ const colOf = (pos) => Math.min(W - 1, Math.max(0, Math.floor((pos.x - F.x0) / colYd)));
340
+ const rowOf = (pos) => Math.min(MAP_ROWS - 1, Math.max(0, Math.floor((F.y1 - pos.y) / rowYd)));
341
+ const cells = Array.from({ length: MAP_ROWS }, () => Array.from({ length: W }, () => ({ ch: ' ', paint: null, prio: -1 })));
342
+ const put = (pos, ch, paint, prio) => {
343
+ const cell = cells[rowOf(pos)][colOf(pos)];
344
+ if (prio > cell.prio) { cell.ch = ch; cell.paint = paint; cell.prio = prio; }
345
+ };
346
+ const midCol = colOf(WSG_GEOMETRY.mid);
347
+ for (let r = 0; r < MAP_ROWS; r++) put({ x: F.x0 + (midCol + 0.5) * colYd, y: F.y1 - (r + 0.5) * rowYd }, GLYPH.mid, dim, 0);
348
+ for (const side of ['alliance', 'horde']) put(WSG_GEOMETRY.graveyard[side], 'G', dim, 1);
349
+ for (const o of world.objectives.values()) {
350
+ if (o.kind === 'powerup') put(o.pos, POWERUP_CHAR[o.id.split('-')[0]] ?? '?', o.state === 'up' ? green : dim, 1);
351
+ else if (o.kind === 'flag' && o.owner) {
352
+ put(WSG_GEOMETRY.flagBase[o.owner], GLYPH.flag, o.state === 'base' ? (o.owner === 'horde' ? red : cyan) : dim, 2);
353
+ if (o.state === 'ground') put(o.pos, GLYPH.flag, yellow, 3);
354
+ }
355
+ }
356
+ // Bodies, tallied per cell. Ours in full; theirs only as far as our side can see them (BgView).
357
+ const tally = new Map();
358
+ const add = (u, ours) => {
359
+ const key = rowOf(u.pos) * W + colOf(u.pos);
360
+ const t = tally.get(key) ?? { pos: u.pos, ours: 0, theirs: 0, carrier: null, self: false };
361
+ if (ours) t.ours++; else t.theirs++;
362
+ if (u.carryingFlag) t.carrier = ours ? bg.team : bg.foe;
363
+ if (u.id === bg.selfId) t.self = true;
364
+ tally.set(key, t);
365
+ };
366
+ for (const u of world.livingOf(bg.team)) add(u, true);
367
+ for (const e of world.viewFor(bg.team).enemies) if (e.alive) add(e, false);
368
+ for (const t of tally.values()) {
369
+ if (t.self) put(t.pos, '@', bold, 6);
370
+ else if (t.ours && t.theirs) put(t.pos, GLYPH.fight, (s) => bold(red(s)), 5);
371
+ else if (t.carrier) put(t.pos, t.carrier[0].toUpperCase(), (s) => bold(yellow(s)), 5);
372
+ else {
373
+ const side = t.ours ? bg.team : bg.foe, n = t.ours || t.theirs;
374
+ put(t.pos, n > 1 ? (n > 9 ? '+' : String(n)) : side[0], (s) => paintTeam(side, s), 4);
375
+ }
376
+ }
377
+ const left = 'Horde ', right = ' Alliance', midLabel = ' mid ';
378
+ const midStart = Math.max(left.length + 1, Math.min(W - right.length - midLabel.length - 1, midCol - 2));
379
+ const header = red(left) + dim(GLYPH.dash.repeat(midStart - left.length) + midLabel + GLYPH.dash.repeat(Math.max(0, W - right.length - midStart - midLabel.length))) + cyan(right);
380
+ return [H(` ${header}`), ...cells.map((row) => H(` ${row.map((c) => (c.paint ? c.paint(c.ch) : c.ch)).join('')}`))];
381
+ }
382
+
383
+ /** Lines under the map: one per flag carrier, then one per fight, largest first. */
384
+ const FIELD_LINES = 4;
385
+
386
+ // Always FIELD_LINES rows, blank when quiet, so the cursor does not jump as fights start and end under it.
387
+ function fieldLines(bg) {
388
+ const { world } = bg;
389
+ const lines = [];
390
+ if (!bg.isAb) {
391
+ for (const o of world.objectives.values()) {
392
+ if (o.kind !== 'flag' || o.state !== 'carried') continue;
393
+ const c = world.units.get(o.carrier);
394
+ if (!c || !world.alive(c)) continue;
395
+ const ours = c.team === bg.team;
396
+ const near = world.unitsNear(c.pos.x, c.pos.y, 40).filter((u) => u.team !== c.team && world.alive(u)).length;
397
+ lines.push(`${paintTeam(c.team, c.name)} ${ours ? 'carries their flag' : 'has our flag'} near ${placeName(bg, c.pos)}, ${Math.round((c.hp / c.maxHp) * 100)}%${near ? `, ${near} ${ours ? 'chasing' : 'of ours on it'}` : ''}`);
398
+ }
399
+ }
400
+ const shards = [];
401
+ for (const s of world.currentShards()) {
402
+ let ours = 0, theirs = 0, self = false, cx = 0, cy = 0, n = 0;
403
+ for (const id of s.units) {
404
+ const u = world.units.get(id);
405
+ if (!u || !world.alive(u)) continue;
406
+ if (u.team === bg.team) ours++; else theirs++;
407
+ if (id === bg.selfId) self = true;
408
+ cx += u.pos.x; cy += u.pos.y; n++;
409
+ }
410
+ if (ours && theirs) shards.push({ ours, theirs, self, pos: { x: cx / n, y: cy / n } });
411
+ }
412
+ shards.sort((a, b) => (b.ours + b.theirs) - (a.ours + a.theirs));
413
+ for (const s of shards) lines.push(`${paintTeam(bg.team, String(s.ours))}v${paintTeam(bg.foe, String(s.theirs))} at ${placeName(bg, s.pos)}${s.self ? bold(' you are in it') : ''}`);
414
+ while (lines.length < FIELD_LINES) lines.push('');
415
+ return lines.slice(0, FIELD_LINES);
416
+ }
417
+
418
+ // ---- orders ------------------------------------------------------------------------------------------------
419
+
420
+ const goTo = (z) => ({ label: `going to ${z.label}`, make: () => ({ order: { kind: 'attack-move', to: z.pos } }) });
421
+
422
+ /** Their flag, wherever it is: at its base or on the ground, walk to it; carried by one of ours, escort them. */
423
+ const takeTheirFlag = (bg) => ({
424
+ label: 'taking their flag',
425
+ make: () => {
426
+ const { world } = bg;
427
+ const me = world.units.get(bg.selfId);
428
+ if (me?.carryingFlag) return { label: 'running the flag home', order: { kind: 'move', to: xy(WSG_GEOMETRY.flagBase[bg.team]) } };
429
+ const f = world.objectives.get(flagObjectiveId(bg.foe));
430
+ if (f?.state === 'carried') {
431
+ const c = world.units.get(f.carrier);
432
+ return c ? { label: `escorting ${c.name}`, order: { kind: 'follow', target: c.id } } : null;
433
+ }
434
+ return { order: { kind: 'attack-move', to: f?.state === 'ground' ? xy(f.pos) : xy(WSG_GEOMETRY.flagBase[bg.foe]) } };
435
+ },
436
+ });
437
+
438
+ const followUnit = (bg, id, verb) => ({
439
+ label: `${verb} ${unitName(bg, id)}`,
440
+ make: () => { const u = bg.world.units.get(id); return u && bg.world.alive(u) ? { order: { kind: 'follow', target: id } } : null; },
441
+ });
442
+
443
+ function setMyOrder(app, bg, standing) {
444
+ const me = bg.world.units.get(bg.selfId);
445
+ if (!me) return;
446
+ me.human = true;
447
+ bg.my = standing;
448
+ bg.myLabel = standing.label;
449
+ app.say(dim(`You are ${standing.label}.`));
450
+ driveSelf(app, bg);
451
+ }
452
+
453
+ function pickOrder(app, bg) {
454
+ const { world } = bg;
455
+ const me = world.units.get(bg.selfId);
456
+ const opts = [];
457
+ if (me?.carryingFlag) opts.push({ label: 'Run the flag home', value: { label: 'running the flag home', make: () => ({ order: { kind: 'move', to: xy(WSG_GEOMETRY.flagBase[bg.team]) } }) } });
458
+ if (!bg.isAb) opts.push({ label: 'Take their flag', value: takeTheirFlag(bg) });
459
+ for (const z of zonesOf(bg)) opts.push({ label: `Go to ${z.label}`, value: goTo(z) });
460
+ opts.push({ label: 'Follow an ally…', value: 'follow' }, { label: 'Attack an enemy you can see…', value: 'attack' }, { label: `Autopilot: ${me?.human ? 'hand your seat back to a job' : ROLE_TEXT[me?.role] ?? 'pick a job'}`, value: 'auto' });
461
+ app.pick('Your orders', opts, (v) => {
462
+ if (v === 'auto') return pickRole(app, bg);
463
+ if (v === 'follow') {
464
+ const allies = world.livingOf(bg.team).filter((u) => u.id !== bg.selfId);
465
+ if (!allies.length) return app.say(dim('Nobody of yours is up.'));
466
+ return app.pick('Follow', allies.map((u) => ({ label: `${u.name} ${dim(ROLE_TEXT[u.role] ?? u.role)}${u.carryingFlag ? yellow(' carrying the flag') : ''}`, value: u.id })), (id) => setMyOrder(app, bg, followUnit(bg, id, 'following')));
467
+ }
468
+ if (v === 'attack') {
469
+ const seen = world.viewFor(bg.team).enemies.filter((e) => e.alive);
470
+ if (!seen.length) return app.say(dim('You see no enemy right now.'));
471
+ return app.pick('Attack', seen.map((e) => ({ label: `${e.name} ${dim(`${e.hpPct}%, near ${placeName(bg, e.pos)}`)}${e.carryingFlag ? yellow(' has our flag') : ''}`, value: e.id })), (id) => setMyOrder(app, bg, followUnit(bg, id, 'attacking')));
472
+ }
473
+ setMyOrder(app, bg, v);
474
+ });
475
+ }
476
+
477
+ /** A job for the strategy layer to play your seat to; your own orders are dropped. */
478
+ function pickRole(app, bg) {
479
+ const roles = bg.isAb ? ['node-assault', 'node-defense'] : ['flag-carrier', 'fc-escort', 'offense', 'defense', 'mid'];
480
+ app.pick('Your job', roles.map((r) => ({ label: ROLE_TEXT[r], value: r })), (r) => {
481
+ const me = bg.world.units.get(bg.selfId);
482
+ if (!me) return;
483
+ me.role = r; me.human = false; bg.my = null; bg.myLabel = null;
484
+ app.say(dim(`You will ${ROLE_TEXT[r]}.`));
485
+ });
486
+ }
487
+
488
+ /** One order to everyone of yours who is up, held for CALL_MS, then the strategy layer has them back. */
489
+ function teamCall(app, bg, say, orderFor) {
490
+ const { world } = bg;
491
+ let n = 0;
492
+ for (const u of world.livingOf(bg.team)) {
493
+ if (u.id === bg.selfId) continue;
494
+ const o = orderFor(u);
495
+ if (o && world.issue(u.id, o, bg.team)) { bg.commanded.set(u.id, world.t + CALL_MS); n++; }
496
+ }
497
+ app.say(n ? yellow(say) + dim(` ${n} answer, for ${CALL_MS / 1000} s.`) : dim('Nobody of yours is up to answer.'));
498
+ }
499
+
500
+ function pickTeamCall(app, bg) {
501
+ const opts = [];
502
+ const call = (label, say, orderFor) => opts.push({ label, value: () => teamCall(app, bg, say, orderFor) });
503
+ if (!bg.isAb) {
504
+ const tc = theirCarrier(bg);
505
+ if (tc) call(`All on their carrier, ${tc.name}`, `You call: all on ${tc.name}!`, () => ({ kind: 'follow', target: tc.id }));
506
+ call('Everyone to mid', 'You call: everyone to mid!', () => ({ kind: 'attack-move', to: xy(WSG_GEOMETRY.mid) }));
507
+ call('Turtle: everyone back to our flag room', 'You call: turtle!', () => ({ kind: 'attack-move', to: xy(WSG_GEOMETRY.flagBase[bg.team]) }));
508
+ call('Push their flag room', 'You call: push their base!', () => ({ kind: 'attack-move', to: xy(WSG_GEOMETRY.flagBase[bg.foe]) }));
509
+ } else {
510
+ for (const n of AB_NODES) call(`Everyone to the ${n.name}`, `You call: everyone to the ${n.name}!`, () => ({ kind: 'attack-move', to: { x: n.x, y: n.y } }));
511
+ }
512
+ call('Escort me', 'You call: on me!', () => ({ kind: 'follow', target: bg.selfId }));
513
+ opts.push({ label: 'Back to your jobs', value: () => { bg.commanded.clear(); app.say(dim('Your side goes back to its jobs.')); } });
514
+ app.pick('Team call', opts, (fn) => fn());
515
+ }
516
+
517
+ // ---- the view ----------------------------------------------------------------------------------------------
518
+
194
519
  function bgView(app, bg) {
195
520
  const self = () => bg.world.units.get(bg.selfId);
196
521
  return {
197
- id: 'bg', title: bg.def.name, hint: 'Enter: your job', noDetail: true,
522
+ id: 'bg', title: bg.def.name, hint: 'Enter: act', noDetail: true,
198
523
  onClose: (a) => leave(a, bg),
199
524
  keys: {
525
+ o: { hint: 'your orders', fn: (a) => pickOrder(a, bg) },
526
+ t: { hint: 'team call', fn: (a) => pickTeamCall(a, bg) },
527
+ j: { hint: 'autopilot job', fn: (a) => pickRole(a, bg) },
200
528
  space: { hint: 'pause', fn: () => { bg.paused = !bg.paused; } },
201
529
  '+': { hint: 'faster', fn: () => { bg.speed = Math.min(16, bg.speed * 2); } },
202
530
  '-': { fn: () => { bg.speed = Math.max(1, bg.speed / 2); } },
@@ -206,38 +534,39 @@ function bgView(app, bg) {
206
534
  const me = self();
207
535
  const t = Math.max(0, world.t - COUNTDOWN_MS);
208
536
  const clock = `${Math.floor(t / 60000)}:${String(Math.floor((t % 60000) / 1000)).padStart(2, '0')}`;
209
- const rows = [H(`${bold(bg.def.name)} ${scoreText(bg)} ${dim(world.gatesOpen ? clock : 'gates closed')}${bg.paused ? red(' PAUSED') : ''}${bg.speed !== 1 ? dim(` ${bg.speed}x`) : ''}`), H('')];
537
+ const up = `${paintTeam(bg.team, `${world.livingOf(bg.team).length}/${world.unitsOf(bg.team).length} up`)} ${dim('vs')} ${paintTeam(bg.foe, `${world.livingOf(bg.foe).length}/${world.unitsOf(bg.foe).length}`)}`;
538
+ const kills = `${dim('kills')} ${paintTeam(bg.team, String(bg.teamKills[bg.team]))}${dim('-')}${paintTeam(bg.foe, String(bg.teamKills[bg.foe]))}`;
539
+ const rows = [H(`${bold(bg.def.name)} ${scoreText(bg)} ${dim(world.gatesOpen ? clock : 'gates closed')} ${up} ${kills}${bg.paused ? red(' PAUSED') : ''}${bg.speed !== 1 ? dim(` ${bg.speed}x`) : ''}`)];
210
540
  if (bg.isAb) {
541
+ rows.push(H(''));
211
542
  for (const n of AB_NODES) {
212
543
  const o = world.objectives.get(n.id);
213
544
  const who = o?.owner ? paintTeam(o.owner, TEAM[o.owner]) : dim('nobody');
214
545
  rows.push(H(` ${fit(n.name, 14)} ${who}${o?.state === 'assaulted' ? yellow(' under assault') : ''}`));
215
546
  }
216
547
  } else {
217
- for (const side of ['alliance', 'horde']) {
218
- const flag = [...world.objectives.values()].find((o) => o.kind === 'flag' && o.owner === side);
219
- const st = !flag ? '?' : flag.state === 'carried' ? `carried by ${unitName(bg, flag.carrier)}` : flag.state === 'ground' ? 'on the ground' : 'at its base';
220
- rows.push(H(` ${paintTeam(side, `${TEAM[side]} flag`)} ${st}`));
221
- }
548
+ rows.push(...wsgMapRows(a, bg));
549
+ const flagText = (side) => {
550
+ const flag = world.objectives.get(flagObjectiveId(side));
551
+ const st = !flag ? '?' : flag.state === 'carried' ? `carried by ${unitName(bg, flag.carrier)}` : flag.state === 'ground' ? 'on the ground' : flag.state === 'respawning' ? 'captured, respawning' : 'at its base';
552
+ return `${paintTeam(side, `${TEAM[side]} flag`)} ${st}`;
553
+ };
554
+ rows.push(H(` ${flagText(bg.team)} ${flagText(bg.foe)}`));
222
555
  }
223
- rows.push(H(''), H(` ${paintTeam(bg.team, `Your side ${world.livingOf(bg.team).length}/${world.unitsOf(bg.team).length} up`)} ${paintTeam(bg.foe, `Theirs ${world.livingOf(bg.foe).length}/${world.unitsOf(bg.foe).length} up`)}`), H(''));
556
+ for (const l of fieldLines(bg)) rows.push(H(` ${l}`));
557
+ rows.push(H(''));
224
558
  if (me) {
225
559
  const dead = !world.alive(me);
226
560
  rows.push(H(` ${bold(me.name)} ${bar(Math.max(0, me.hp), me.maxHp, 20, dead ? red : green)} ${dead ? red(`dead, back in ${Math.ceil(Math.max(0, (me.deadUntilMs ?? world.t) - world.t) / 1000)} s`) : `${Math.round((me.hp / me.maxHp) * 100)}%`}${me.carryingFlag ? yellow(' carrying the flag!') : ''}`));
227
- rows.push({ text: ` Your job: ${cyan(ROLE_TEXT[me.role] ?? me.role)} ${dim('Enter: change it')}`, act: (a2) => pickRole(a2, bg) });
561
+ const what = me.human ? `You: ${cyan(bg.myLabel ?? 'holding here')}` : `Autopilot: ${cyan(ROLE_TEXT[me.role] ?? me.role)}`;
562
+ rows.push({ text: ` ${what} ${dim('Enter: your orders')}`, act: (a2) => pickOrder(a2, bg) });
563
+ const held = [...bg.commanded.keys()].filter((id) => { const u = world.units.get(id); return u && world.alive(u); }).length;
564
+ rows.push({ text: ` Team: ${held ? yellow(`${held} on your call`) : dim('on their jobs')} ${dim('Enter: a team call')}`, act: (a2) => pickTeamCall(a2, bg) });
228
565
  }
229
- rows.push(H(''), H(dim(` This match: +${bg.honor} honor${bg.kills ? `, ${bg.kills} kills` : ''}${bg.caps ? `, ${bg.caps} ${bg.isAb ? 'bases worked' : 'captures'}` : ''}`)));
230
- if (bg.settled) rows.push(H(''), { text: green('Leave the battleground'), act: (a2) => a2.pop() });
231
- else rows.push(H(''), { text: dim('Leave now (the match goes on without you)'), act: (a2) => a2.confirm(`Leave ${bg.def.name}? You get no win honor and no marks.`, () => a2.pop()) });
566
+ rows.push(H(dim(` This match: +${bg.honor} honor${bg.kills ? `, ${bg.kills} kills` : ''}${bg.caps ? `, ${bg.caps} ${bg.isAb ? 'bases worked' : 'captures'}` : ''}`)));
567
+ if (bg.settled) rows.push({ text: green('Leave the battleground'), act: (a2) => a2.pop() });
568
+ else rows.push({ text: dim('Leave now (the match goes on without you)'), act: (a2) => a2.confirm(`Leave ${bg.def.name}? You get no win honor and no marks.`, () => a2.pop()) });
232
569
  return rows;
233
570
  },
234
571
  };
235
572
  }
236
-
237
- function pickRole(app, bg) {
238
- const roles = bg.isAb ? ['node-assault', 'node-defense'] : ['flag-carrier', 'fc-escort', 'offense', 'defense', 'mid'];
239
- app.pick('Your job', roles.map((r) => ({ label: ROLE_TEXT[r], value: r })), (r) => {
240
- const me = bg.world.units.get(bg.selfId);
241
- if (me) { me.role = r; app.say(dim(`You will ${ROLE_TEXT[r]}.`)); }
242
- });
243
- }