polats Claude Opus 4.8 (1M context) commited on
Commit
f92b745
·
1 Parent(s): 81baca3

Fun: leveling now scales power + spend attribute points

Browse files

statBonuses(level+spent) -> rank/hpMul/dmgMul fed into buildPlayer, so a higher-level
hero is genuinely tougher and hits harder. Level-up triggers an immediate live buff +
full heal. Attribute points are spendable (+5% HP / +5% DMG) from the character sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

web/classesSandbox.js CHANGED
@@ -976,6 +976,10 @@ function templateFor(unit) {
976
  }
977
  function makeActor(unit, team, id, slot) {
978
  const tpl = templateFor(unit);
 
 
 
 
979
  const p = FORMATION[slot % FORMATION.length];
980
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
981
  const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
@@ -991,15 +995,15 @@ function makeActor(unit, team, id, slot) {
991
  role: tpl.role,
992
  rank: unit.rank ?? 12,
993
  armor: tpl.armor ?? 0,
994
- weapon: { ...tpl.weapon },
995
  moveSpeed: tpl.moveSpeed,
996
  preferredRange: tpl.preferredRange,
997
  radius: radiusOf(unit, tpl),
998
  maxEnergy: tpl.maxEnergy,
999
  energyRegen: tpl.energyRegen,
1000
- baseMaxHp: tpl.maxHp,
1001
- maxHp: tpl.maxHp,
1002
- hp: tpl.maxHp,
1003
  energy: tpl.maxEnergy,
1004
  adrenaline: 0,
1005
  bar,
 
976
  }
977
  function makeActor(unit, team, id, slot) {
978
  const tpl = templateFor(unit);
979
+ const hpMul = unit.hpMul ?? unit.statMul ?? 1;
980
+ const dmgMul = unit.dmgMul ?? unit.statMul ?? 1;
981
+ const maxHp = Math.max(1, Math.round(tpl.maxHp * hpMul));
982
+ const weapon = { ...tpl.weapon, min: Math.max(1, Math.round(tpl.weapon.min * dmgMul)), max: Math.max(1, Math.round(tpl.weapon.max * dmgMul)) };
983
  const p = FORMATION[slot % FORMATION.length];
984
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
985
  const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
 
995
  role: tpl.role,
996
  rank: unit.rank ?? 12,
997
  armor: tpl.armor ?? 0,
998
+ weapon,
999
  moveSpeed: tpl.moveSpeed,
1000
  preferredRange: tpl.preferredRange,
1001
  radius: radiusOf(unit, tpl),
1002
  maxEnergy: tpl.maxEnergy,
1003
  energyRegen: tpl.energyRegen,
1004
+ baseMaxHp: maxHp,
1005
+ maxHp,
1006
+ hp: maxHp,
1007
  energy: tpl.maxEnergy,
1008
  adrenaline: 0,
1009
  bar,
web/comboBattler.js CHANGED
@@ -5056,6 +5056,10 @@ function templateFor(unit) {
5056
  }
5057
  function makeActor(unit, team, id, slot) {
5058
  const tpl = templateFor(unit);
 
 
 
 
5059
  const p = FORMATION[slot % FORMATION.length];
5060
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
5061
  const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
@@ -5071,15 +5075,15 @@ function makeActor(unit, team, id, slot) {
5071
  role: tpl.role,
5072
  rank: unit.rank ?? 12,
5073
  armor: tpl.armor ?? 0,
5074
- weapon: { ...tpl.weapon },
5075
  moveSpeed: tpl.moveSpeed,
5076
  preferredRange: tpl.preferredRange,
5077
  radius: radiusOf(unit, tpl),
5078
  maxEnergy: tpl.maxEnergy,
5079
  energyRegen: tpl.energyRegen,
5080
- baseMaxHp: tpl.maxHp,
5081
- maxHp: tpl.maxHp,
5082
- hp: tpl.maxHp,
5083
  energy: tpl.maxEnergy,
5084
  adrenaline: 0,
5085
  bar,
@@ -6373,6 +6377,17 @@ function mountComboBattler(pixi, host, opts = {}) {
6373
  const p = pa();
6374
  if (emoteLayer && p && battle) emoteLayer.show(p.id, key, battle.t, 1.6);
6375
  }
 
 
 
 
 
 
 
 
 
 
 
6376
  function drawRings() {
6377
  const p = pa();
6378
  rings.clear();
@@ -6665,7 +6680,7 @@ function mountComboBattler(pixi, host, opts = {}) {
6665
  const p = pa();
6666
  return p ? { name: p.name, profession: p.profession, hp: Math.round(p.hp), maxHp: Math.round(p.maxHp), skills: (p.bar || []).map((s) => s.name) } : null;
6667
  }
6668
- const ctrl = { ready, selectHero, getSpawnWorld, getSnapshot, getHero, heroEmote, setHeroSkills, resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
6669
  if (typeof window !== "undefined") {
6670
  window.__comboSnap = () => ctrl.getSnapshot();
6671
  window.__combo = ctrl;
 
5056
  }
5057
  function makeActor(unit, team, id, slot) {
5058
  const tpl = templateFor(unit);
5059
+ const hpMul = unit.hpMul ?? unit.statMul ?? 1;
5060
+ const dmgMul = unit.dmgMul ?? unit.statMul ?? 1;
5061
+ const maxHp = Math.max(1, Math.round(tpl.maxHp * hpMul));
5062
+ const weapon = { ...tpl.weapon, min: Math.max(1, Math.round(tpl.weapon.min * dmgMul)), max: Math.max(1, Math.round(tpl.weapon.max * dmgMul)) };
5063
  const p = FORMATION[slot % FORMATION.length];
5064
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
5065
  const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
 
5075
  role: tpl.role,
5076
  rank: unit.rank ?? 12,
5077
  armor: tpl.armor ?? 0,
5078
+ weapon,
5079
  moveSpeed: tpl.moveSpeed,
5080
  preferredRange: tpl.preferredRange,
5081
  radius: radiusOf(unit, tpl),
5082
  maxEnergy: tpl.maxEnergy,
5083
  energyRegen: tpl.energyRegen,
5084
+ baseMaxHp: maxHp,
5085
+ maxHp,
5086
+ hp: maxHp,
5087
  energy: tpl.maxEnergy,
5088
  adrenaline: 0,
5089
  bar,
 
6377
  const p = pa();
6378
  if (emoteLayer && p && battle) emoteLayer.show(p.id, key, battle.t, 1.6);
6379
  }
6380
+ function levelUpHero() {
6381
+ const p = pa();
6382
+ if (!p) return;
6383
+ p.rank = Math.min(20, (p.rank || 12) + 1);
6384
+ p.baseMaxHp = Math.round(p.baseMaxHp * 1.06);
6385
+ p.maxHp = Math.round(p.maxHp * 1.06);
6386
+ p.weapon.min = Math.round(p.weapon.min * 1.05);
6387
+ p.weapon.max = Math.max(p.weapon.min + 1, Math.round(p.weapon.max * 1.05));
6388
+ p.hp = p.maxHp;
6389
+ emit();
6390
+ }
6391
  function drawRings() {
6392
  const p = pa();
6393
  rings.clear();
 
6680
  const p = pa();
6681
  return p ? { name: p.name, profession: p.profession, hp: Math.round(p.hp), maxHp: Math.round(p.maxHp), skills: (p.bar || []).map((s) => s.name) } : null;
6682
  }
6683
+ const ctrl = { ready, selectHero, getSpawnWorld, getSnapshot, getHero, heroEmote, levelUpHero, setHeroSkills, resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
6684
  if (typeof window !== "undefined") {
6685
  window.__comboSnap = () => ctrl.getSnapshot();
6686
  window.__combo = ctrl;
web/enemiesSandbox.js CHANGED
@@ -809,6 +809,10 @@ function templateFor(unit) {
809
  }
810
  function makeActor(unit, team, id, slot) {
811
  const tpl = templateFor(unit);
 
 
 
 
812
  const p = FORMATION[slot % FORMATION.length];
813
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
814
  const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
@@ -824,15 +828,15 @@ function makeActor(unit, team, id, slot) {
824
  role: tpl.role,
825
  rank: unit.rank ?? 12,
826
  armor: tpl.armor ?? 0,
827
- weapon: { ...tpl.weapon },
828
  moveSpeed: tpl.moveSpeed,
829
  preferredRange: tpl.preferredRange,
830
  radius: radiusOf(unit, tpl),
831
  maxEnergy: tpl.maxEnergy,
832
  energyRegen: tpl.energyRegen,
833
- baseMaxHp: tpl.maxHp,
834
- maxHp: tpl.maxHp,
835
- hp: tpl.maxHp,
836
  energy: tpl.maxEnergy,
837
  adrenaline: 0,
838
  bar,
 
809
  }
810
  function makeActor(unit, team, id, slot) {
811
  const tpl = templateFor(unit);
812
+ const hpMul = unit.hpMul ?? unit.statMul ?? 1;
813
+ const dmgMul = unit.dmgMul ?? unit.statMul ?? 1;
814
+ const maxHp = Math.max(1, Math.round(tpl.maxHp * hpMul));
815
+ const weapon = { ...tpl.weapon, min: Math.max(1, Math.round(tpl.weapon.min * dmgMul)), max: Math.max(1, Math.round(tpl.weapon.max * dmgMul)) };
816
  const p = FORMATION[slot % FORMATION.length];
817
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
818
  const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
 
828
  role: tpl.role,
829
  rank: unit.rank ?? 12,
830
  armor: tpl.armor ?? 0,
831
+ weapon,
832
  moveSpeed: tpl.moveSpeed,
833
  preferredRange: tpl.preferredRange,
834
  radius: radiusOf(unit, tpl),
835
  maxEnergy: tpl.maxEnergy,
836
  energyRegen: tpl.energyRegen,
837
+ baseMaxHp: maxHp,
838
+ maxHp,
839
+ hp: maxHp,
840
  energy: tpl.maxEnergy,
841
  adrenaline: 0,
842
  bar,
web/engine.js CHANGED
@@ -783,6 +783,10 @@ function templateFor(unit) {
783
  }
784
  function makeActor(unit, team, id, slot) {
785
  const tpl = templateFor(unit);
 
 
 
 
786
  const p = FORMATION[slot % FORMATION.length];
787
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
788
  const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
@@ -798,15 +802,15 @@ function makeActor(unit, team, id, slot) {
798
  role: tpl.role,
799
  rank: unit.rank ?? 12,
800
  armor: tpl.armor ?? 0,
801
- weapon: { ...tpl.weapon },
802
  moveSpeed: tpl.moveSpeed,
803
  preferredRange: tpl.preferredRange,
804
  radius: radiusOf(unit, tpl),
805
  maxEnergy: tpl.maxEnergy,
806
  energyRegen: tpl.energyRegen,
807
- baseMaxHp: tpl.maxHp,
808
- maxHp: tpl.maxHp,
809
- hp: tpl.maxHp,
810
  energy: tpl.maxEnergy,
811
  adrenaline: 0,
812
  bar,
 
783
  }
784
  function makeActor(unit, team, id, slot) {
785
  const tpl = templateFor(unit);
786
+ const hpMul = unit.hpMul ?? unit.statMul ?? 1;
787
+ const dmgMul = unit.dmgMul ?? unit.statMul ?? 1;
788
+ const maxHp = Math.max(1, Math.round(tpl.maxHp * hpMul));
789
+ const weapon = { ...tpl.weapon, min: Math.max(1, Math.round(tpl.weapon.min * dmgMul)), max: Math.max(1, Math.round(tpl.weapon.max * dmgMul)) };
790
  const p = FORMATION[slot % FORMATION.length];
791
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
792
  const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
 
802
  role: tpl.role,
803
  rank: unit.rank ?? 12,
804
  armor: tpl.armor ?? 0,
805
+ weapon,
806
  moveSpeed: tpl.moveSpeed,
807
  preferredRange: tpl.preferredRange,
808
  radius: radiusOf(unit, tpl),
809
  maxEnergy: tpl.maxEnergy,
810
  energyRegen: tpl.energyRegen,
811
+ baseMaxHp: maxHp,
812
+ maxHp,
813
+ hp: maxHp,
814
  energy: tpl.maxEnergy,
815
  adrenaline: 0,
816
  bar,
web/progression.js CHANGED
@@ -62,6 +62,30 @@ export function enemyXpValue(enemy) {
62
  return ENEMY_XP.normal
63
  }
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  // Append a capped, structured event to a persona's log (newest last). Used for the chat
66
  // "what's happening to you" context (woid-style perception log) and the level-up feed.
67
  const MAX_EVENTS = 60
 
62
  return ENEMY_XP.normal
63
  }
64
 
65
+ // Combat stat scaling from level + spent attribute points. rank feeds the engine's skill
66
+ // scaling; hpMul/dmgMul scale survivability + damage so leveling actually makes you stronger.
67
+ export function statBonuses(progression) {
68
+ const p = progression || {}
69
+ const lvl = p.level || 1
70
+ const spent = p.spent || { hp: 0, dmg: 0 }
71
+ return {
72
+ rank: Math.min(20, 7 + lvl),
73
+ hpMul: 1 + (lvl - 1) * 0.07 + (spent.hp || 0) * 0.05,
74
+ dmgMul: 1 + (lvl - 1) * 0.05 + (spent.dmg || 0) * 0.05,
75
+ }
76
+ }
77
+
78
+ // Spend one attribute point into a stat ('hp' | 'dmg'). Returns a NEW progression object;
79
+ // no-op if no points or an unknown stat.
80
+ export function spendPoint(progression, stat) {
81
+ const p = { level: 1, xp: 0, kills: 0, attributePoints: 0, ...(progression || {}) }
82
+ p.spent = { hp: 0, dmg: 0, ...(p.spent || {}) }
83
+ if ((p.attributePoints || 0) <= 0 || !['hp', 'dmg'].includes(stat)) return p
84
+ p.attributePoints -= 1
85
+ p.spent[stat] = (p.spent[stat] || 0) + 1
86
+ return p
87
+ }
88
+
89
  // Append a capped, structured event to a persona's log (newest last). Used for the chat
90
  // "what's happening to you" context (woid-style perception log) and the level-up feed.
91
  const MAX_EVENTS = 60
web/tiny.js CHANGED
@@ -16,7 +16,7 @@ import { mountComboBattler } from '/web/comboBattler.js'
16
  import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
17
  import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
18
  import { listPersonas, onRosterChange, getPortrait, getPersona, patchPersona, setActiveHeroId } from '/web/personaStore.js'
19
- import { applyXp, enemyXpValue, levelProgress, xpToNext, appendEvent } from '/web/progression.js'
20
  import { getSkillIcon, getSkillArt, putSkillArt } from '/web/personaStore.js'
21
  import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
22
  import { generatePortrait } from '/web/imagen.js'
@@ -229,6 +229,9 @@ const buildPlayer = (chars, p) => {
229
  const equipped = resolveEquipped(p)
230
  const unit = { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' }
231
  if (equipped.length) unit.skills = equipped
 
 
 
232
  return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit }
233
  }
234
  const PICK_CARD_CSS = 'display:flex;flex-direction:column;align-items:center;gap:4px;width:76px;padding:8px 6px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.92);color:#e8e8e8;cursor:pointer;font:600 11px var(--tac-font,system-ui)'
@@ -383,7 +386,7 @@ whenEl('battle-stage', async (el) => {
383
  if (res.leveledUp) events = appendEvent(events, 'level_up', { level: prog.level, ts: Date.now() })
384
  patchPersona(id, { progression: prog, events })
385
  currentHero = getPersona(id)
386
- if (res.leveledUp) { showLevelUp(prog); comboCtrl?.heroEmote?.('love') }
387
  if (sheetOpen) renderSheet()
388
  }
389
  // A brief level-up banner over the canvas (mobile-friendly: centered, auto-dismiss).
@@ -549,6 +552,28 @@ whenEl('battle-stage', async (el) => {
549
  } catch (e) { status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e); busy = false; go.disabled = false; ta.disabled = false }
550
  })
551
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  const renderSheet = () => {
553
  sheet.innerHTML = ''
554
  const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
@@ -573,7 +598,7 @@ whenEl('battle-stage', async (el) => {
573
  const meta = document.createElement('div'); meta.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;color:#9aa4b2;margin-bottom:10px'
574
  const kills = document.createElement('span'); kills.textContent = `⚔ ${prog.kills} kills`
575
  meta.append(kills)
576
- if (prog.attributePoints > 0) { const pts = document.createElement('span'); pts.textContent = `★ ${prog.attributePoints} point${prog.attributePoints > 1 ? 's' : ''}`; pts.style.color = '#ffe082'; meta.append(pts) }
577
  sheet.append(meta)
578
  }
579
  // Talk to this fighter (chat + voice). Needs a saved hero (id) to persist the conversation.
 
16
  import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
17
  import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
18
  import { listPersonas, onRosterChange, getPortrait, getPersona, patchPersona, setActiveHeroId } from '/web/personaStore.js'
19
+ import { applyXp, enemyXpValue, levelProgress, xpToNext, appendEvent, statBonuses, spendPoint } from '/web/progression.js'
20
  import { getSkillIcon, getSkillArt, putSkillArt } from '/web/personaStore.js'
21
  import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
22
  import { generatePortrait } from '/web/imagen.js'
 
229
  const equipped = resolveEquipped(p)
230
  const unit = { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' }
231
  if (equipped.length) unit.skills = equipped
232
+ // Leveling makes the fighter stronger: level + spent points → rank (skill scaling) + HP/damage.
233
+ const sb = statBonuses(p?.progression)
234
+ unit.rank = sb.rank; unit.hpMul = sb.hpMul; unit.dmgMul = sb.dmgMul
235
  return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit }
236
  }
237
  const PICK_CARD_CSS = 'display:flex;flex-direction:column;align-items:center;gap:4px;width:76px;padding:8px 6px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.92);color:#e8e8e8;cursor:pointer;font:600 11px var(--tac-font,system-ui)'
 
386
  if (res.leveledUp) events = appendEvent(events, 'level_up', { level: prog.level, ts: Date.now() })
387
  patchPersona(id, { progression: prog, events })
388
  currentHero = getPersona(id)
389
+ if (res.leveledUp) { showLevelUp(prog); comboCtrl?.heroEmote?.('love'); comboCtrl?.levelUpHero?.() }
390
  if (sheetOpen) renderSheet()
391
  }
392
  // A brief level-up banner over the canvas (mobile-friendly: centered, auto-dismiss).
 
552
  } catch (e) { status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e); busy = false; go.disabled = false; ta.disabled = false }
553
  })
554
  }
555
+ // Spend attribute points (earned on level-up) into +HP or +Damage. Applies on next spawn.
556
+ const openSpend = () => {
557
+ const { sh } = openToolSheet('Spend attribute points')
558
+ const render = () => {
559
+ sh.querySelectorAll('.spend-dyn').forEach((n) => n.remove())
560
+ const pr = getPersona(currentHero.id).progression
561
+ const sp = pr.spent || { hp: 0, dmg: 0 }
562
+ const info = document.createElement('div'); info.className = 'spend-dyn'; info.style.cssText = 'color:#9aa4b2;font-size:12px;margin-bottom:10px'
563
+ info.textContent = `${pr.attributePoints} point${pr.attributePoints !== 1 ? 's' : ''} to spend · current: +${Math.round((sp.hp || 0) * 5)}% HP, +${Math.round((sp.dmg || 0) * 5)}% DMG`
564
+ sh.append(info)
565
+ const mk = (label, stat) => {
566
+ const b = document.createElement('button'); b.className = 'spend-dyn'; b.type = 'button'; b.textContent = label
567
+ const none = pr.attributePoints <= 0; b.disabled = none
568
+ b.style.cssText = `width:100%;padding:12px;border-radius:10px;border:1px solid #2a3340;background:#1a2230;color:#e8e8e8;font:600 13px var(--tac-font,system-ui);cursor:${none ? 'default' : 'pointer'};margin-bottom:8px;opacity:${none ? 0.5 : 1}`
569
+ b.addEventListener('click', () => { const np = spendPoint(getPersona(currentHero.id).progression, stat); patchPersona(currentHero.id, { progression: np }); currentHero = getPersona(currentHero.id); render(); renderSheet() })
570
+ sh.append(b)
571
+ }
572
+ mk('+5% Max HP', 'hp'); mk('+5% Damage', 'dmg')
573
+ const note = document.createElement('div'); note.className = 'spend-dyn'; note.style.cssText = 'color:#6b7686;font-size:11px;margin-top:4px'; note.textContent = 'Takes full effect on your next spawn.'; sh.append(note)
574
+ }
575
+ render()
576
+ }
577
  const renderSheet = () => {
578
  sheet.innerHTML = ''
579
  const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
 
598
  const meta = document.createElement('div'); meta.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;color:#9aa4b2;margin-bottom:10px'
599
  const kills = document.createElement('span'); kills.textContent = `⚔ ${prog.kills} kills`
600
  meta.append(kills)
601
+ if (prog.attributePoints > 0) { const pts = document.createElement('button'); pts.type = 'button'; pts.textContent = `★ ${prog.attributePoints} point${prog.attributePoints > 1 ? 's' : ''} — spend`; pts.style.cssText = 'background:none;border:none;color:#ffe082;font:inherit;cursor:pointer;text-decoration:underline'; pts.addEventListener('click', openSpend); meta.append(pts) }
602
  sheet.append(meta)
603
  }
604
  // Talk to this fighter (chat + voice). Needs a saved hero (id) to persist the conversation.