polats Claude Opus 4.8 (1M context) commited on
Commit
608a401
·
1 Parent(s): c0ec0a4

Pixi battlefield: real deterministic engine running in-browser

Browse files

Serve web/ (Pixi + bundled engine) at /, Gradio barracks at /app.

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

Files changed (4) hide show
  1. app.py +11 -28
  2. web/engine.js +1526 -0
  3. web/index.html +31 -0
  4. web/main.js +79 -0
app.py CHANGED
@@ -1,38 +1,17 @@
1
- """Tiny Army — HF Space skeleton.
2
 
3
- FastAPI serves the (placeholder) Pixi battle frontend at "/", and a Gradio app —
4
- the "barracks" is mounted at "/app". For now the war-diary generator is a stub;
5
- the hack wires it to a local llama.cpp small model. The combat engine + souls +
6
- ONNX captain will run client-side in the frontend.
7
  """
8
  import gradio as gr
9
  import uvicorn
10
  from fastapi import FastAPI
11
- from fastapi.responses import HTMLResponse
12
 
13
  app = FastAPI(title="Tiny Army")
14
 
15
- INDEX = """<!doctype html>
16
- <html><head><meta charset="utf-8"><title>Tiny Army</title>
17
- <style>
18
- body{margin:0;height:100vh;display:grid;place-items:center;background:#0b0e12;
19
- color:#f4ecd8;font-family:ui-monospace,Menlo,monospace;text-align:center}
20
- .badge{font-size:64px} a{color:#ffd54a}
21
- p{color:#9aa4b2;max-width:32rem;line-height:1.5}
22
- </style></head>
23
- <body><div>
24
- <div class="badge">🪖</div>
25
- <h1>Tiny Army</h1>
26
- <p><em>Tiny Army: every fighter writes its own legend — and the legend is true.</em></p>
27
- <p>The battlefield (Pixi) mounts here. For now, visit the barracks:
28
- <a href="/app">/app</a></p>
29
- </div></body></html>"""
30
-
31
-
32
- @app.get("/", response_class=HTMLResponse)
33
- def index():
34
- return INDEX
35
-
36
 
37
  @app.get("/healthz")
38
  def healthz():
@@ -50,14 +29,18 @@ def write_diary(unit: str, traits: str) -> str:
50
 
51
  with gr.Blocks(title="Tiny Army — Barracks", theme=gr.themes.Soft()) as barracks:
52
  gr.Markdown("# 🪖 Tiny Army — Barracks\n"
53
- "*Every fighter writes its own legend.* (skeleton — diary is a stub)")
 
54
  with gr.Row():
55
  unit = gr.Textbox(label="Unit", value="Bram the Warrior")
56
  traits = gr.Textbox(label="Traits", value="Cautious, Veteran, Vengeful")
57
  out = gr.Textbox(label="War diary", lines=6)
58
  gr.Button("Write diary", variant="primary").click(write_diary, [unit, traits], out)
59
 
 
 
60
  app = gr.mount_gradio_app(app, barracks, path="/app")
 
61
 
62
 
63
  if __name__ == "__main__":
 
1
+ """Tiny Army — HF Space.
2
 
3
+ FastAPI serves the Pixi battle frontend (web/) at "/", where the deterministic
4
+ combat engine runs in-browser. The Gradio "barracks" is mounted at "/app"; its
5
+ war-diary generator is a stub for now, wired to a local llama.cpp small model
6
+ during the hack.
7
  """
8
  import gradio as gr
9
  import uvicorn
10
  from fastapi import FastAPI
11
+ from fastapi.staticfiles import StaticFiles
12
 
13
  app = FastAPI(title="Tiny Army")
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  @app.get("/healthz")
17
  def healthz():
 
29
 
30
  with gr.Blocks(title="Tiny Army — Barracks", theme=gr.themes.Soft()) as barracks:
31
  gr.Markdown("# 🪖 Tiny Army — Barracks\n"
32
+ "*Every fighter writes its own legend.* (skeleton — diary is a stub) · "
33
+ "[← battlefield](/)")
34
  with gr.Row():
35
  unit = gr.Textbox(label="Unit", value="Bram the Warrior")
36
  traits = gr.Textbox(label="Traits", value="Cautious, Veteran, Vengeful")
37
  out = gr.Textbox(label="War diary", lines=6)
38
  gr.Button("Write diary", variant="primary").click(write_diary, [unit, traits], out)
39
 
40
+ # Gradio barracks at /app; the Pixi frontend (static) at / — mounted last so the
41
+ # more specific /app and /healthz routes win.
42
  app = gr.mount_gradio_app(app, barracks, path="/app")
43
+ app.mount("/", StaticFiles(directory="web", html=True), name="web")
44
 
45
 
46
  if __name__ == "__main__":
web/engine.js ADDED
@@ -0,0 +1,1526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // src/engine/skills.js
2
+ var FIRST_15 = [
3
+ // ── Warrior: adrenaline-fuelled condition → spike (Swordsmanship line) ──
4
+ {
5
+ id: 382,
6
+ name: "Sever Artery",
7
+ profession: "Warrior",
8
+ attribute: "Swordsmanship",
9
+ category: "melee_attack",
10
+ target: "foe",
11
+ cost: { adrenaline: 4 },
12
+ cast: 0,
13
+ recharge: 0,
14
+ requires: ["on_hit"],
15
+ effects: [{ op: "apply_condition", condition: "bleeding", duration: { scale: [5, 25] } }]
16
+ },
17
+ {
18
+ id: 384,
19
+ name: "Gash",
20
+ profession: "Warrior",
21
+ attribute: "Swordsmanship",
22
+ category: "melee_attack",
23
+ target: "foe",
24
+ cost: { adrenaline: 6 },
25
+ cast: 0,
26
+ recharge: 0,
27
+ // The payoff: bonus damage + Deep Wound, but only on an already-Bleeding foe.
28
+ requires: ["on_hit", { target: "bleeding" }],
29
+ effects: [
30
+ { op: "bonus_damage", amount: { scale: [5, 20] } },
31
+ { op: "apply_condition", condition: "deepWound", duration: { scale: [5, 20] } }
32
+ ]
33
+ },
34
+ {
35
+ id: 385,
36
+ name: "Final Thrust",
37
+ profession: "Warrior",
38
+ attribute: "Swordsmanship",
39
+ category: "melee_attack",
40
+ target: "foe",
41
+ cost: { adrenaline: 10 },
42
+ cast: 0,
43
+ recharge: 0,
44
+ requires: ["on_hit"],
45
+ effects: [
46
+ { op: "lose_all_adrenaline" },
47
+ { op: "bonus_damage", amount: { scale: [1, 40] } },
48
+ // "doubled if below 50%" — applying the same bonus a second time, gated.
49
+ { op: "bonus_damage", amount: { scale: [1, 40] }, if: { target_below_health: 0.5 } }
50
+ ]
51
+ },
52
+ // ── Ranger: preparations + ranged conditions/interrupt ──
53
+ {
54
+ id: 435,
55
+ name: "Apply Poison",
56
+ profession: "Ranger",
57
+ attribute: "Wilderness Survival",
58
+ category: "preparation",
59
+ target: "self",
60
+ cost: { energy: 15 },
61
+ cast: 2,
62
+ recharge: 12,
63
+ // The differentiator: a self rider — future physical attacks inflict Poison.
64
+ effects: [{
65
+ op: "preparation",
66
+ duration: { fixed: 24 },
67
+ on_attack: [{ op: "apply_condition", condition: "poison", duration: { scale: [3, 15] } }]
68
+ }]
69
+ },
70
+ {
71
+ id: 391,
72
+ name: "Hunter's Shot",
73
+ profession: "Ranger",
74
+ attribute: "Marksmanship",
75
+ category: "bow_attack",
76
+ target: "foe",
77
+ cost: { energy: 5 },
78
+ cast: 1,
79
+ recharge: 10,
80
+ requires: ["on_hit"],
81
+ effects: [{ op: "apply_condition", condition: "bleeding", duration: { scale: [3, 25] } }]
82
+ },
83
+ {
84
+ id: 426,
85
+ name: "Savage Shot",
86
+ profession: "Ranger",
87
+ attribute: "Marksmanship",
88
+ category: "bow_attack",
89
+ target: "foe",
90
+ cost: { energy: 10 },
91
+ cast: 0.5,
92
+ recharge: 5,
93
+ requires: ["on_hit"],
94
+ effects: [
95
+ { op: "interrupt" },
96
+ // Bonus only if the interrupted action was a spell.
97
+ { op: "bonus_damage", amount: { scale: [13, 28] }, if: { target: "casting_spell" } }
98
+ ]
99
+ },
100
+ // ── Necromancer: trigger-hexes (the event bus / physway) ──
101
+ {
102
+ id: 121,
103
+ name: "Spiteful Spirit",
104
+ profession: "Necromancer",
105
+ attribute: "Curses",
106
+ category: "hex",
107
+ target: "foe",
108
+ cost: { energy: 15 },
109
+ cast: 2,
110
+ recharge: 10,
111
+ elite: true,
112
+ effects: [{
113
+ op: "hex",
114
+ duration: { scale: [8, 20] },
115
+ trigger: "on_action",
116
+ payload: [{ op: "damage", damageType: "shadow", amount: { scale: [5, 35] }, scope: "target_and_adjacent" }]
117
+ }]
118
+ },
119
+ {
120
+ id: 101,
121
+ name: "Barbs",
122
+ profession: "Necromancer",
123
+ attribute: "Curses",
124
+ category: "hex",
125
+ target: "foe",
126
+ cost: { energy: 10 },
127
+ cast: 2,
128
+ recharge: 5,
129
+ effects: [{
130
+ op: "hex",
131
+ duration: { fixed: 30 },
132
+ // Passive amplifier — no discrete trigger; the damage pipeline reads it.
133
+ payload: [{ op: "amplify_damage", amount: { scale: [1, 15] }, vs: "physical" }]
134
+ }]
135
+ },
136
+ {
137
+ id: 150,
138
+ name: "Mark of Pain",
139
+ profession: "Necromancer",
140
+ attribute: "Curses",
141
+ category: "hex",
142
+ target: "foe",
143
+ cost: { energy: 10 },
144
+ cast: 1,
145
+ recharge: 20,
146
+ effects: [{
147
+ op: "hex",
148
+ duration: { fixed: 30 },
149
+ trigger: "on_physical_hit",
150
+ payload: [{ op: "damage", damageType: "shadow", amount: { scale: [10, 40] }, scope: "adjacent_to_target" }]
151
+ }]
152
+ },
153
+ // ── Monk: the damage-interception pipeline ──
154
+ {
155
+ id: 245,
156
+ name: "Protective Spirit",
157
+ profession: "Monk",
158
+ attribute: "Protection Prayers",
159
+ category: "enchantment",
160
+ target: "ally",
161
+ cost: { energy: 10 },
162
+ cast: 0.25,
163
+ recharge: 5,
164
+ effects: [{
165
+ op: "enchant",
166
+ duration: { scale: [5, 23] },
167
+ // Cap: a single hit can't remove more than 10% of max Health.
168
+ payload: [{ op: "cap_damage", maxFraction: 0.1 }]
169
+ }]
170
+ },
171
+ {
172
+ id: 307,
173
+ name: "Reversal of Fortune",
174
+ profession: "Monk",
175
+ attribute: "Protection Prayers",
176
+ category: "enchantment",
177
+ target: "ally",
178
+ cost: { energy: 5 },
179
+ cast: 0.25,
180
+ recharge: 2,
181
+ effects: [{
182
+ op: "enchant",
183
+ duration: { fixed: 8 },
184
+ charges: 1,
185
+ trigger: "on_incoming_damage",
186
+ payload: [{ op: "convert_damage_to_heal", cap: { scale: [15, 80] } }]
187
+ }]
188
+ },
189
+ {
190
+ id: 1114,
191
+ name: "Spirit Bond",
192
+ profession: "Monk",
193
+ attribute: "Protection Prayers",
194
+ category: "enchantment",
195
+ target: "ally",
196
+ cost: { energy: 10 },
197
+ cast: 0.25,
198
+ recharge: 2,
199
+ effects: [{
200
+ op: "enchant",
201
+ duration: { fixed: 8 },
202
+ charges: 10,
203
+ trigger: "on_incoming_damage",
204
+ threshold: { perHitDamageOver: 50 },
205
+ payload: [{ op: "heal", amount: { scale: [30, 90] }, scope: "target" }]
206
+ }]
207
+ },
208
+ // ── Assassin: the combo chain (lead → off-hand → dual) ──
209
+ {
210
+ id: 782,
211
+ name: "Jagged Strike",
212
+ profession: "Assassin",
213
+ attribute: "Dagger Mastery",
214
+ category: "lead_attack",
215
+ target: "foe",
216
+ cost: { energy: 5 },
217
+ cast: 0.5,
218
+ recharge: 1,
219
+ requires: ["on_hit"],
220
+ effects: [
221
+ { op: "apply_condition", condition: "bleeding", duration: { scale: [5, 20] } },
222
+ { op: "set_combo_mark", stage: "lead" }
223
+ ]
224
+ },
225
+ {
226
+ id: 780,
227
+ name: "Fox Fangs",
228
+ profession: "Assassin",
229
+ attribute: "Dagger Mastery",
230
+ category: "offhand_attack",
231
+ target: "foe",
232
+ cost: { energy: 5 },
233
+ cast: 0.5,
234
+ recharge: 3,
235
+ requires: ["on_hit", { combo_follows: "lead" }],
236
+ effects: [
237
+ { op: "bonus_damage", amount: { scale: [10, 35] }, unblockable: true },
238
+ { op: "set_combo_mark", stage: "offhand" }
239
+ ]
240
+ },
241
+ {
242
+ id: 775,
243
+ name: "Death Blossom",
244
+ profession: "Assassin",
245
+ attribute: "Dagger Mastery",
246
+ category: "dual_attack",
247
+ target: "foe",
248
+ cost: { energy: 5 },
249
+ cast: 0,
250
+ recharge: 2,
251
+ requires: ["on_hit", { combo_follows: "offhand" }],
252
+ effects: [
253
+ { op: "bonus_damage", amount: { scale: [20, 45] } },
254
+ { op: "damage", amount: { scale: [20, 45] }, scope: "adjacent_to_target" }
255
+ ]
256
+ }
257
+ ];
258
+ var VARIANT_EXTRA = [
259
+ // ── Warrior · Sentinel (soak / protect) ──
260
+ {
261
+ id: 348,
262
+ name: '"Watch Yourself!"',
263
+ profession: "Warrior",
264
+ attribute: "Tactics",
265
+ category: "shout",
266
+ target: "party",
267
+ cost: { adrenaline: 4 },
268
+ cast: 0,
269
+ recharge: 4,
270
+ // Party armor for 10s, but the buff also ends after 10 incoming attacks.
271
+ effects: [{ op: "armor_mod", amount: { scale: [5, 25] }, duration: { fixed: 10 }, attacksLeft: 10, scope: "party" }]
272
+ },
273
+ {
274
+ id: 372,
275
+ name: "Gladiator's Defense",
276
+ profession: "Warrior",
277
+ attribute: "Tactics",
278
+ category: "stance",
279
+ target: "self",
280
+ cost: { energy: 5 },
281
+ cast: 0,
282
+ recharge: 30,
283
+ elite: true,
284
+ // 75% block; whoever you block in melee takes 5…35 back.
285
+ effects: [{ op: "block", chance: 0.75, vs: "melee", reflect: { scale: [5, 35] }, duration: { scale: [5, 11] } }]
286
+ },
287
+ {
288
+ id: 1,
289
+ name: "Healing Signet",
290
+ profession: "Warrior",
291
+ attribute: "Tactics",
292
+ category: "signet",
293
+ target: "self",
294
+ cost: {},
295
+ cast: 2,
296
+ recharge: 4,
297
+ effects: [{ op: "heal", amount: { scale: [82, 172] }, scope: "self" }],
298
+ whileActivating: [{ op: "armor_mod", amount: -40, duration: { fixed: 0 }, scope: "self" }]
299
+ // −40 armor while using
300
+ },
301
+ // ── Warrior · Breaker (knockdown control) ──
302
+ {
303
+ id: 332,
304
+ name: "Bull's Strike",
305
+ profession: "Warrior",
306
+ attribute: "Strength",
307
+ category: "melee_attack",
308
+ target: "foe",
309
+ cost: { energy: 5 },
310
+ cast: 0,
311
+ recharge: 10,
312
+ requires: ["on_hit", { target: "moving" }],
313
+ effects: [
314
+ { op: "bonus_damage", amount: { scale: [5, 30] } },
315
+ { op: "knockdown", duration: { fixed: 2 } }
316
+ ]
317
+ },
318
+ {
319
+ id: 331,
320
+ name: "Hammer Bash",
321
+ profession: "Warrior",
322
+ attribute: "Hammer Mastery",
323
+ category: "melee_attack",
324
+ target: "foe",
325
+ cost: { adrenaline: 6 },
326
+ cast: 0,
327
+ recharge: 0,
328
+ requires: ["on_hit"],
329
+ effects: [
330
+ { op: "knockdown", duration: { fixed: 2 } },
331
+ { op: "lose_all_adrenaline" }
332
+ ]
333
+ },
334
+ {
335
+ id: 352,
336
+ name: "Crushing Blow",
337
+ profession: "Warrior",
338
+ attribute: "Hammer Mastery",
339
+ category: "melee_attack",
340
+ target: "foe",
341
+ cost: { energy: 5 },
342
+ cast: 0,
343
+ recharge: 10,
344
+ requires: ["on_hit"],
345
+ effects: [
346
+ { op: "bonus_damage", amount: { scale: [1, 20] } },
347
+ { op: "apply_condition", condition: "deepWound", duration: { scale: [5, 20] }, if: { target: "knocked_down" } }
348
+ ]
349
+ },
350
+ // ── Ranger · Sharpshooter (Punishing Shot completes the interrupt bar) ──
351
+ {
352
+ id: 409,
353
+ name: "Punishing Shot",
354
+ profession: "Ranger",
355
+ attribute: "Marksmanship",
356
+ category: "bow_attack",
357
+ target: "foe",
358
+ cost: { energy: 10 },
359
+ cast: 0.5,
360
+ recharge: 5,
361
+ elite: true,
362
+ requires: ["on_hit"],
363
+ effects: [
364
+ { op: "bonus_damage", amount: { scale: [10, 20] } },
365
+ { op: "interrupt" }
366
+ ]
367
+ },
368
+ // ── Ranger · Toxicologist (stacked degen) ──
369
+ {
370
+ id: 1470,
371
+ name: "Barbed Arrows",
372
+ profession: "Ranger",
373
+ attribute: "Wilderness Survival",
374
+ category: "preparation",
375
+ target: "self",
376
+ cost: { energy: 10 },
377
+ cast: 2,
378
+ recharge: 12,
379
+ effects: [{
380
+ op: "preparation",
381
+ duration: { fixed: 24 },
382
+ on_attack: [{ op: "apply_condition", condition: "bleeding", duration: { scale: [3, 15] } }]
383
+ }],
384
+ whileActivating: [{ op: "armor_mod", amount: -40, duration: { fixed: 0 }, scope: "self" }]
385
+ // −40 armor while activating
386
+ },
387
+ {
388
+ id: 1466,
389
+ name: "Burning Arrow",
390
+ profession: "Ranger",
391
+ attribute: "Marksmanship",
392
+ category: "bow_attack",
393
+ target: "foe",
394
+ cost: { energy: 10 },
395
+ cast: 0,
396
+ recharge: 5,
397
+ elite: true,
398
+ requires: ["on_hit"],
399
+ effects: [
400
+ { op: "bonus_damage", amount: { scale: [10, 30] } },
401
+ { op: "apply_condition", condition: "burning", duration: { scale: [1, 7] } }
402
+ ]
403
+ },
404
+ // ── Ranger · Survivalist (sustain / kite) ──
405
+ {
406
+ id: 446,
407
+ name: "Troll Unguent",
408
+ profession: "Ranger",
409
+ attribute: "Wilderness Survival",
410
+ category: "skill",
411
+ target: "self",
412
+ cost: { energy: 5 },
413
+ cast: 3,
414
+ recharge: 10,
415
+ effects: [{ op: "regen_mod", pips: { scale: [3, 10] }, duration: { fixed: 13 }, scope: "self" }]
416
+ },
417
+ {
418
+ id: 1727,
419
+ name: "Natural Stride",
420
+ profession: "Ranger",
421
+ attribute: "Wilderness Survival",
422
+ category: "stance",
423
+ target: "self",
424
+ cost: { energy: 5 },
425
+ cast: 0,
426
+ recharge: 12,
427
+ // Move 33% faster + 50% block; the stance ends if you become hexed/enchanted.
428
+ effects: [
429
+ { op: "move_speed", mult: 1.33, duration: { scale: [1, 8] }, endsOnHexEnchant: true },
430
+ { op: "block", chance: 0.5, vs: "all", duration: { scale: [1, 8] }, endsOnHexEnchant: true }
431
+ ]
432
+ },
433
+ {
434
+ id: 393,
435
+ name: "Crippling Shot",
436
+ profession: "Ranger",
437
+ attribute: "Marksmanship",
438
+ category: "bow_attack",
439
+ target: "foe",
440
+ cost: { energy: 10 },
441
+ cast: 0,
442
+ recharge: 2,
443
+ elite: true,
444
+ requires: ["on_hit"],
445
+ effects: [{ op: "apply_condition", condition: "crippled", duration: { scale: [1, 12] }, unblockable: true }]
446
+ },
447
+ // ── Necromancer · Vampire (life-steal sustain) ──
448
+ {
449
+ id: 153,
450
+ name: "Vampiric Gaze",
451
+ profession: "Necromancer",
452
+ attribute: "Blood Magic",
453
+ category: "spell",
454
+ target: "foe",
455
+ cost: { energy: 10 },
456
+ cast: 1,
457
+ recharge: 8,
458
+ effects: [{ op: "life_steal", amount: { scale: [18, 60] } }]
459
+ },
460
+ {
461
+ id: 109,
462
+ name: "Life Siphon",
463
+ profession: "Necromancer",
464
+ attribute: "Blood Magic",
465
+ category: "hex",
466
+ target: "foe",
467
+ cost: { energy: 10 },
468
+ cast: 1,
469
+ recharge: 5,
470
+ effects: [
471
+ { op: "regen_mod", pips: { scale: [-1, -3] }, duration: { scale: [12, 24] }, scope: "target" },
472
+ { op: "regen_mod", pips: { scale: [1, 3] }, duration: { scale: [12, 24] }, scope: "self" }
473
+ ]
474
+ },
475
+ {
476
+ id: 115,
477
+ name: "Blood Renewal",
478
+ profession: "Necromancer",
479
+ attribute: "Blood Magic",
480
+ category: "enchantment",
481
+ target: "self",
482
+ cost: { energy: 1, sacrifice: 15 },
483
+ cast: 1,
484
+ recharge: 7,
485
+ // +3…6 regen for 7s, then a burst heal of 40…190 when the enchant ends.
486
+ effects: [
487
+ { op: "regen_mod", pips: { scale: [3, 6] }, duration: { fixed: 7 }, scope: "self" },
488
+ { op: "enchant", duration: { fixed: 7 }, trigger: "on_end", payload: [{ op: "heal", amount: { scale: [40, 190] }, scope: "self" }] }
489
+ ]
490
+ },
491
+ // ── Necromancer · Plaguebearer (condition spread) ──
492
+ {
493
+ id: 118,
494
+ name: "Enfeebling Blood",
495
+ profession: "Necromancer",
496
+ attribute: "Curses",
497
+ category: "spell",
498
+ target: "foe",
499
+ cost: { energy: 1, sacrifice: 10 },
500
+ cast: 1,
501
+ recharge: 8,
502
+ effects: [{ op: "apply_condition", condition: "weakness", duration: { scale: [5, 20] }, scope: "target_and_adjacent" }]
503
+ },
504
+ {
505
+ id: 106,
506
+ name: "Rotting Flesh",
507
+ profession: "Necromancer",
508
+ attribute: "Death Magic",
509
+ category: "spell",
510
+ target: "foe",
511
+ cost: { energy: 15 },
512
+ cast: 3,
513
+ recharge: 3,
514
+ effects: [{ op: "apply_condition", condition: "disease", duration: { scale: [10, 25] } }]
515
+ },
516
+ {
517
+ id: 135,
518
+ name: "Faintheartedness",
519
+ profession: "Necromancer",
520
+ attribute: "Curses",
521
+ category: "hex",
522
+ target: "foe",
523
+ cost: { energy: 10 },
524
+ cast: 1,
525
+ recharge: 8,
526
+ effects: [
527
+ { op: "regen_mod", pips: { scale: [0, -3] }, duration: { scale: [3, 16] }, scope: "target" },
528
+ { op: "attack_speed", mult: 2, duration: { scale: [3, 16] }, scope: "target" }
529
+ ]
530
+ },
531
+ // ── Monk · Healer (raw healing) ──
532
+ {
533
+ id: 281,
534
+ name: "Orison of Healing",
535
+ profession: "Monk",
536
+ attribute: "Healing Prayers",
537
+ category: "spell",
538
+ target: "ally",
539
+ cost: { energy: 5 },
540
+ cast: 1,
541
+ recharge: 2,
542
+ effects: [{ op: "heal", amount: { scale: [20, 70] }, scope: "target" }]
543
+ },
544
+ {
545
+ id: 283,
546
+ name: "Dwayna's Kiss",
547
+ profession: "Monk",
548
+ attribute: "Healing Prayers",
549
+ category: "spell",
550
+ target: "other_ally",
551
+ cost: { energy: 5 },
552
+ cast: 1,
553
+ recharge: 3,
554
+ // Heal, +10…35 more for each enchantment and hex on the target ally.
555
+ effects: [{ op: "heal", amount: { scale: [15, 60] }, scope: "target", plusPerMod: { kinds: ["enchant", "hex"], amount: { scale: [10, 35] } } }]
556
+ },
557
+ {
558
+ id: 282,
559
+ name: "Word of Healing",
560
+ profession: "Monk",
561
+ attribute: "Healing Prayers",
562
+ category: "spell",
563
+ target: "ally",
564
+ cost: { energy: 5 },
565
+ cast: 0.75,
566
+ recharge: 3,
567
+ elite: true,
568
+ // Conditional bonus first so the "<50% Health" check reads the pre-heal HP
569
+ // (the base heal below would otherwise lift the ally over the threshold).
570
+ effects: [
571
+ { op: "heal", amount: { scale: [30, 115] }, scope: "target", if: { target_below_health: 0.5 } },
572
+ { op: "heal", amount: { scale: [5, 100] }, scope: "target" }
573
+ ]
574
+ },
575
+ // ── Monk · Smiter (holy offense) ──
576
+ {
577
+ id: 312,
578
+ name: "Holy Strike",
579
+ profession: "Monk",
580
+ attribute: "Smiting Prayers",
581
+ category: "spell",
582
+ target: "foe",
583
+ cost: { energy: 5 },
584
+ cast: 0.75,
585
+ recharge: 8,
586
+ effects: [
587
+ { op: "damage", damageType: "holy", amount: { scale: [10, 55] }, scope: "target" },
588
+ { op: "damage", damageType: "holy", amount: { scale: [10, 55] }, scope: "target", if: { target: "knocked_down" } }
589
+ ]
590
+ },
591
+ {
592
+ id: 240,
593
+ name: "Smite",
594
+ profession: "Monk",
595
+ attribute: "Smiting Prayers",
596
+ category: "spell",
597
+ target: "foe",
598
+ cost: { energy: 10 },
599
+ cast: 1,
600
+ recharge: 10,
601
+ effects: [
602
+ { op: "damage", damageType: "holy", amount: { scale: [10, 55] }, scope: "target_and_adjacent" },
603
+ { op: "damage", damageType: "holy", amount: { scale: [10, 35] }, scope: "target_and_adjacent", if: { target: "attacking" } }
604
+ ]
605
+ },
606
+ {
607
+ id: 252,
608
+ name: "Banish",
609
+ profession: "Monk",
610
+ attribute: "Smiting Prayers",
611
+ category: "spell",
612
+ target: "foe",
613
+ cost: { energy: 5 },
614
+ cast: 1,
615
+ recharge: 10,
616
+ // Double vs summoned creatures — a no-op until summons exist, but recorded.
617
+ effects: [{ op: "damage", damageType: "holy", amount: { scale: [20, 56] }, scope: "target", vsSummoned: 2 }]
618
+ },
619
+ // ── Assassin · Nightstalker (shadow-step burst) ──
620
+ {
621
+ id: 952,
622
+ name: "Death's Charge",
623
+ profession: "Assassin",
624
+ attribute: "Shadow Arts",
625
+ category: "skill",
626
+ target: "foe",
627
+ cost: { energy: 5 },
628
+ cast: 0.25,
629
+ recharge: 30,
630
+ // Shadow-step to the foe; heal only if that foe has more Health than you.
631
+ effects: [
632
+ { op: "shadow_step", to: "foe" },
633
+ { op: "heal", amount: { scale: [65, 200] }, scope: "self", if: { target_health_above_self: true } }
634
+ ]
635
+ },
636
+ {
637
+ id: 1024,
638
+ name: "Black Mantis Thrust",
639
+ profession: "Assassin",
640
+ attribute: "Deadly Arts",
641
+ category: "lead_attack",
642
+ target: "foe",
643
+ cost: { energy: 5 },
644
+ cast: 1,
645
+ recharge: 6,
646
+ requires: ["on_hit"],
647
+ effects: [
648
+ { op: "bonus_damage", amount: { scale: [8, 20] } },
649
+ { op: "apply_condition", condition: "crippled", duration: { scale: [3, 15] }, if: { target: "hexed" } },
650
+ { op: "set_combo_mark", stage: "lead" }
651
+ ]
652
+ },
653
+ // ── Assassin · Saboteur (control / Deadly Arts) ──
654
+ {
655
+ id: 858,
656
+ name: "Dancing Daggers",
657
+ profession: "Assassin",
658
+ attribute: "Deadly Arts",
659
+ category: "spell",
660
+ target: "foe",
661
+ cost: { energy: 5 },
662
+ cast: 1,
663
+ recharge: 5,
664
+ // Three earth projectiles, each 5…35; counts as a lead attack.
665
+ effects: [
666
+ { op: "damage", damageType: "earth", amount: { scale: [5, 35] }, projectiles: 3, delivery: "projectile_spell", scope: "target" },
667
+ { op: "set_combo_mark", stage: "lead" }
668
+ ]
669
+ },
670
+ {
671
+ id: 784,
672
+ name: "Entangling Asp",
673
+ profession: "Assassin",
674
+ attribute: "Deadly Arts",
675
+ category: "spell",
676
+ target: "foe",
677
+ cost: { energy: 10 },
678
+ cast: 1,
679
+ recharge: 20,
680
+ requires: [{ combo_follows: "lead" }],
681
+ effects: [
682
+ { op: "knockdown", duration: { fixed: 2 } },
683
+ { op: "apply_condition", condition: "poison", duration: { scale: [5, 20] } }
684
+ ]
685
+ },
686
+ {
687
+ id: 988,
688
+ name: "Temple Strike",
689
+ profession: "Assassin",
690
+ attribute: "Dagger Mastery",
691
+ category: "offhand_attack",
692
+ target: "foe",
693
+ cost: { energy: 15 },
694
+ cast: 0,
695
+ recharge: 20,
696
+ elite: true,
697
+ requires: ["on_hit", { combo_follows: "lead" }],
698
+ effects: [
699
+ { op: "interrupt", if: { target: "casting_spell" } },
700
+ // interrupts a spell
701
+ { op: "apply_condition", condition: "dazed", duration: { scale: [1, 10] } },
702
+ { op: "apply_condition", condition: "blind", duration: { scale: [1, 10] } }
703
+ ]
704
+ }
705
+ ];
706
+ var CB_SKILLS = [...FIRST_15, ...VARIANT_EXTRA];
707
+
708
+ // src/engine/rng.js
709
+ function makeRng(seed) {
710
+ let a = seed >>> 0;
711
+ return function rng() {
712
+ a |= 0;
713
+ a = a + 1831565813 | 0;
714
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
715
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
716
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
717
+ };
718
+ }
719
+
720
+ // src/engine/range.js
721
+ var MELEE_GW = 144;
722
+ var BASIC_MELEE_GW = MELEE_GW / 2;
723
+ var BOW_GW = 1e3;
724
+
725
+ // src/engine/teamBattle.js
726
+ var byId = Object.fromEntries(CB_SKILLS.map((s) => [s.id, s]));
727
+ var skillById = (id) => byId[id] || null;
728
+ var FIELD = { w: 1e3, h: 600 };
729
+ var FORMATION = [
730
+ { x: 0.31, y: 0.66 },
731
+ { x: 0.47, y: 0.74 },
732
+ { x: 0.13, y: 0.73 },
733
+ { x: 0.28, y: 0.82 },
734
+ { x: 0.44, y: 0.91 }
735
+ ];
736
+ var HIT_TOLERANCE = 130;
737
+ function val(v, rank = 12) {
738
+ if (typeof v === "number") return v;
739
+ if (v == null) return 0;
740
+ if (v.fixed != null) return v.fixed;
741
+ if (v.scale) {
742
+ const [a, b] = v.scale;
743
+ return Math.round(a + (b - a) * rank / 15);
744
+ }
745
+ return 0;
746
+ }
747
+ var DEGEN = { bleeding: 4, poison: 4, burning: 8, disease: 4 };
748
+ var ATTACK_CATEGORIES = ["melee_attack", "bow_attack", "lead_attack", "offhand_attack", "dual_attack", "scythe_attack", "spear_attack"];
749
+ var isAttack = (s) => ATTACK_CATEGORIES.includes(s.category);
750
+ var CLASS_TEMPLATES = {
751
+ Warrior: { maxHp: 520, role: "melee", weapon: { min: 15, max: 22, interval: 1.5, range: BASIC_MELEE_GW }, moveSpeed: 130, maxEnergy: 25, energyRegen: 0.5, armor: 80 },
752
+ Assassin: { maxHp: 480, role: "melee", weapon: { min: 7, max: 17, interval: 1.33, range: BASIC_MELEE_GW }, moveSpeed: 175, maxEnergy: 30, energyRegen: 1.2, armor: 55 },
753
+ Ranger: { maxHp: 430, role: "ranged", weapon: { min: 12, max: 28, interval: 1.9, range: BOW_GW, projSpeed: 850 }, moveSpeed: 155, preferredRange: 620, maxEnergy: 35, energyRegen: 1, armor: 45 },
754
+ Monk: { maxHp: 470, role: "melee", weapon: { min: 8, max: 14, interval: 1.6, range: BASIC_MELEE_GW }, moveSpeed: 140, maxEnergy: 40, energyRegen: 1.4, armor: 60 },
755
+ Necromancer: { maxHp: 450, role: "ranged", weapon: { min: 10, max: 20, interval: 1.8, range: BOW_GW, projSpeed: 720 }, moveSpeed: 140, preferredRange: 520, maxEnergy: 35, energyRegen: 1, armor: 45 }
756
+ };
757
+ var DEFAULT_TEMPLATE = { maxHp: 300, role: "melee", weapon: { min: 10, max: 16, interval: 1.5, range: BASIC_MELEE_GW }, moveSpeed: 150, maxEnergy: 30, energyRegen: 1, armor: 50 };
758
+ function templateFor(unit) {
759
+ if (unit.template) return unit.template;
760
+ if (unit.profession && CLASS_TEMPLATES[unit.profession]) return CLASS_TEMPLATES[unit.profession];
761
+ if (unit.stats) {
762
+ const s = unit.stats;
763
+ const basic = s.basicDamage ?? 12;
764
+ const ranged = unit.attackType === "ranged";
765
+ return {
766
+ maxHp: s.hp ?? 100,
767
+ role: ranged ? "ranged" : "melee",
768
+ armor: s.armor ?? 40,
769
+ moveSpeed: 150,
770
+ maxEnergy: 30,
771
+ energyRegen: 1,
772
+ preferredRange: ranged ? 600 : void 0,
773
+ weapon: {
774
+ min: Math.max(1, Math.round(basic * 0.8)),
775
+ max: Math.round(basic * 1.3),
776
+ interval: 1.4,
777
+ range: ranged ? BOW_GW : BASIC_MELEE_GW,
778
+ ...ranged ? { projSpeed: 800 } : {}
779
+ }
780
+ };
781
+ }
782
+ return DEFAULT_TEMPLATE;
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(skillById).filter(Boolean);
789
+ return {
790
+ id,
791
+ team,
792
+ name: unit.name || id,
793
+ profession: unit.profession || null,
794
+ role: tpl.role,
795
+ rank: unit.rank ?? 12,
796
+ armor: tpl.armor ?? 0,
797
+ weapon: { ...tpl.weapon },
798
+ moveSpeed: tpl.moveSpeed,
799
+ preferredRange: tpl.preferredRange,
800
+ radius: radiusOf(unit, tpl),
801
+ maxEnergy: tpl.maxEnergy,
802
+ energyRegen: tpl.energyRegen,
803
+ baseMaxHp: tpl.maxHp,
804
+ maxHp: tpl.maxHp,
805
+ hp: tpl.maxHp,
806
+ energy: tpl.maxEnergy,
807
+ adrenaline: 0,
808
+ bar,
809
+ x: pt.x * FIELD.w,
810
+ y: pt.y * FIELD.h,
811
+ facing: team === "player" ? 1 : -1,
812
+ faceX: team === "player" ? 1 : -1,
813
+ faceY: team === "player" ? -1 : 1,
814
+ // players look up-right, enemies down-left
815
+ attackTimer: tpl.weapon.interval,
816
+ casting: null,
817
+ recharge: {},
818
+ conds: [],
819
+ marks: {},
820
+ prep: null,
821
+ alive: true,
822
+ mods: [],
823
+ kd: 0
824
+ };
825
+ }
826
+ function makeTeamBattle({ seed = 1, players = [], enemies = [] } = {}) {
827
+ const actors = [];
828
+ players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i)));
829
+ enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i)));
830
+ return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null };
831
+ }
832
+ var ADJACENT_GW = 140;
833
+ var BODY_RADIUS = { melee: 35, ranged: 32 };
834
+ var DEFAULT_RADIUS = 32;
835
+ var DEOVERLAP_ITERS = 3;
836
+ var DEOVERLAP_FRACTION = 0.5;
837
+ var CONTACT_SLOP = 2;
838
+ var MAX_BATTLE_T = 90;
839
+ var COLLISION_Y_WEIGHT = 3.2;
840
+ var radiusOf = (unit, tpl) => unit.template?.radius ?? unit.stats?.radius ?? tpl.radius ?? BODY_RADIUS[tpl.role] ?? DEFAULT_RADIUS;
841
+ var edgeGap = (a, t) => dist(a, t) - (a.radius || 0) - (t.radius || 0);
842
+ var MELEE_REACH = 2;
843
+ var reachOf = (a) => a.role === "ranged" ? a.weapon.range : MELEE_REACH;
844
+ var SPELL_RANGE = 900;
845
+ var ARMOR_IGNORING = /* @__PURE__ */ new Set(["shadow", "holy", "dark", "chaos"]);
846
+ var dist = (a, e) => Math.hypot(a.x - e.x, a.y - e.y);
847
+ var hasCond = (a, type) => a.conds.some((c) => c.type === type);
848
+ var isKd = (b, a) => a.kd > b.t;
849
+ var gainAdr = (a, n) => {
850
+ a.adrenaline = Math.min(25, a.adrenaline + n);
851
+ };
852
+ var livingFoes = (b, a) => b.actors.filter((x) => x.alive && x.team !== a.team);
853
+ var alliesOf = (b, a) => b.actors.filter((x) => x.alive && x.team === a.team);
854
+ function nearestFoe(b, a) {
855
+ let best = null, bd = Infinity;
856
+ for (const x of livingFoes(b, a)) {
857
+ const d = dist(a, x);
858
+ if (d < bd) {
859
+ bd = d;
860
+ best = x;
861
+ }
862
+ }
863
+ return best;
864
+ }
865
+ function mostWoundedAlly(b, a, includeSelf = true) {
866
+ let best = null, bf = Infinity;
867
+ for (const x of alliesOf(b, a)) {
868
+ if (!includeSelf && x === a) continue;
869
+ const f = x.hp / x.maxHp;
870
+ if (f < bf) {
871
+ bf = f;
872
+ best = x;
873
+ }
874
+ }
875
+ return best;
876
+ }
877
+ var adjacentTo = (b, tgt) => b.actors.filter((x) => x.alive && x.team === tgt.team && x !== tgt && dist(x, tgt) <= ADJACENT_GW);
878
+ var addMod = (b, a, m) => a.mods.push({ until: b.t + (m.dur ?? 0), ...m });
879
+ var activeMods = (b, a, kind) => a.mods.filter((m) => m.kind === kind && m.until > b.t);
880
+ var hasModKind = (b, a, kind) => a.mods.some((m) => m.kind === kind && m.until > b.t);
881
+ var sumRegenPips = (b, a) => activeMods(b, a, "regen").reduce((n, m) => n + m.pips, 0);
882
+ var bonusArmor = (b, a) => activeMods(b, a, "armor").reduce((n, m) => n + m.amount, 0);
883
+ var attackSpeedMult = (b, a) => activeMods(b, a, "attackSpeed").reduce((n, m) => n * m.mult, 1);
884
+ var moveSpeedMult = (b, a) => (hasCond(a, "crippled") ? 0.5 : 1) * activeMods(b, a, "moveSpeed").reduce((n, m) => n * m.mult, 1);
885
+ var hasHex = (b, a) => hasModKind(b, a, "hex");
886
+ var countCat = (b, a, kinds) => a.mods.filter((m) => m.until > b.t && kinds.includes(m.cat)).length;
887
+ var mitigate = (dmg, armor) => dmg * (100 / (100 + (armor || 0)));
888
+ function log(b, kind, who, extra = {}) {
889
+ b.log.push({ t: Math.round(b.t * 100) / 100, kind, who: who?.id, ...extra });
890
+ }
891
+ function applyCondition(b, tgt, type, dur, empowered) {
892
+ if (!tgt.alive) return;
893
+ const ex = tgt.conds.find((c) => c.type === type);
894
+ if (ex) {
895
+ ex.until = Math.max(ex.until, b.t + dur);
896
+ return;
897
+ }
898
+ tgt.conds.push({ type, until: b.t + dur });
899
+ if (type === "deepWound") {
900
+ tgt.maxHp = Math.round(tgt.baseMaxHp * 0.8);
901
+ if (tgt.hp > tgt.maxHp) tgt.hp = tgt.maxHp;
902
+ }
903
+ log(b, "cond", tgt, { cond: type, amount: Math.round(dur), ...empowered ? { empowered: true } : {} });
904
+ }
905
+ function expireConds(b, a) {
906
+ for (const c of a.conds) if (c.until <= b.t && c.type === "deepWound") a.maxHp = a.baseMaxHp;
907
+ a.conds = a.conds.filter((c) => c.until > b.t);
908
+ for (const m of a.mods) if (m.kind === "onEnd" && m.until <= b.t && !m.fired) {
909
+ m.fired = true;
910
+ const src = b.actors.find((x) => x.id === m.srcId) || a;
911
+ for (const e of m.payload || []) applyEffect(b, src, a, e, "spell");
912
+ }
913
+ a.mods = a.mods.filter((m) => m.until > b.t);
914
+ }
915
+ function healActor(b, a, amount, empowered) {
916
+ if (!a.alive || amount <= 0) return;
917
+ a.hp = Math.min(a.maxHp, a.hp + Math.round(amount));
918
+ log(b, "heal", a, { amount: Math.round(amount), ...empowered ? { empowered: true } : {} });
919
+ }
920
+ function dealDamage(b, src, tgt, amount, label, opts = {}) {
921
+ if (!tgt.alive) return 0;
922
+ const { damageType = "physical", delivery = "spell", armorIgnoring = false, empowered = false } = opts;
923
+ const physical = delivery === "melee" || delivery === "projectile";
924
+ if (physical) {
925
+ const blk = blockRoll(b, tgt, delivery);
926
+ if (blk) {
927
+ if (blk.reflect && delivery === "melee") dealDamage(b, tgt, src, blk.reflect, "reflect", { delivery: "spell", armorIgnoring: true });
928
+ log(b, "miss", tgt, { name: label });
929
+ return 0;
930
+ }
931
+ }
932
+ let dmg = amount;
933
+ if (physical) {
934
+ for (const m of activeMods(b, tgt, "amplify")) if (m.vs === "physical") dmg += m.amount;
935
+ }
936
+ if (!(armorIgnoring || ARMOR_IGNORING.has(damageType))) dmg = mitigate(dmg, tgt.armor + bonusArmor(b, tgt));
937
+ const cap = activeMods(b, tgt, "cap")[0];
938
+ if (cap) dmg = Math.min(dmg, cap.fraction * tgt.maxHp);
939
+ const conv = activeMods(b, tgt, "convert").find((m) => m.charges > 0);
940
+ if (conv) {
941
+ healActor(b, tgt, Math.min(dmg, conv.cap));
942
+ conv.charges--;
943
+ dmg = 0;
944
+ }
945
+ for (const m of activeMods(b, tgt, "onIncomingHeal")) {
946
+ if (m.charges > 0 && dmg > m.threshold) {
947
+ healActor(b, tgt, m.amount);
948
+ m.charges--;
949
+ }
950
+ }
951
+ dmg = Math.max(0, Math.round(dmg));
952
+ tgt.hp -= dmg;
953
+ log(b, "hit", tgt, { src: src.id, amount: dmg, name: label, ...empowered ? { empowered: true } : {} });
954
+ gainAdr(tgt, 1);
955
+ if (physical) {
956
+ for (const m of activeMods(b, tgt, "armor")) if (m.attacksLeft != null && --m.attacksLeft <= 0) m.until = b.t;
957
+ fireTrigger(b, tgt, "onPhysicalHit");
958
+ }
959
+ if (tgt.hp <= 0) kill(b, tgt);
960
+ return dmg;
961
+ }
962
+ function blockRoll(b, tgt, delivery) {
963
+ for (const m of activeMods(b, tgt, "block")) {
964
+ if (m.vs === "all" || m.vs === delivery) {
965
+ if (b.rng() < m.chance) return m;
966
+ }
967
+ }
968
+ return null;
969
+ }
970
+ function kill(b, a) {
971
+ if (!a.alive) return;
972
+ a.alive = false;
973
+ a.hp = 0;
974
+ log(b, "death", a);
975
+ if (hasCond(a, "disease")) for (const x of adjacentTo(b, a)) applyCondition(b, x, "disease", 10);
976
+ }
977
+ function applyContainer(b, src, tgt, e) {
978
+ const dur = val(e.duration, src.rank);
979
+ const cat = e.op;
980
+ const p = e.payload?.[0] || {};
981
+ if (e.op === "hex") addMod(b, tgt, { kind: "hex", cat, dur });
982
+ for (const m of tgt.mods) if (m.endsOnHexEnchant && m.until > b.t) m.until = b.t;
983
+ if (p.op === "amplify_damage") {
984
+ addMod(b, tgt, { kind: "amplify", cat, vs: p.vs || "physical", amount: val(p.amount, src.rank), dur });
985
+ return;
986
+ }
987
+ if (p.op === "cap_damage") {
988
+ addMod(b, tgt, { kind: "cap", cat, fraction: p.maxFraction, dur });
989
+ return;
990
+ }
991
+ if (p.op === "convert_damage_to_heal") {
992
+ addMod(b, tgt, { kind: "convert", cat, cap: val(p.cap, src.rank), charges: e.charges ?? 1, dur });
993
+ return;
994
+ }
995
+ if (e.trigger === "on_end") {
996
+ addMod(b, tgt, { kind: "onEnd", cat, payload: e.payload, srcId: src.id, srcRank: src.rank, dur });
997
+ return;
998
+ }
999
+ if (e.trigger === "on_incoming_damage" && p.op === "heal") {
1000
+ addMod(b, tgt, { kind: "onIncomingHeal", cat, amount: val(p.amount, src.rank), threshold: e.threshold?.perHitDamageOver ?? 0, charges: e.charges ?? 1, dur });
1001
+ return;
1002
+ }
1003
+ if (e.trigger === "on_action") {
1004
+ addMod(b, tgt, { kind: "onAction", cat, payload: e.payload, srcId: src.id, srcRank: src.rank, dur });
1005
+ return;
1006
+ }
1007
+ if (e.trigger === "on_physical_hit") {
1008
+ addMod(b, tgt, { kind: "onPhysicalHit", cat, payload: e.payload, srcId: src.id, srcRank: src.rank, dur });
1009
+ return;
1010
+ }
1011
+ addMod(b, tgt, { kind: "enchant", cat, dur });
1012
+ }
1013
+ function fireTrigger(b, a, kind) {
1014
+ for (const m of activeMods(b, a, kind)) {
1015
+ const fakeSrc = b.actors.find((x) => x.id === m.srcId) || { id: m.srcId, rank: m.srcRank };
1016
+ for (const e of m.payload || []) applyEffect(b, fakeSrc, a, e, "spell");
1017
+ }
1018
+ }
1019
+ function resolveScope(b, src, tgt, scope) {
1020
+ switch (scope) {
1021
+ case "self":
1022
+ return [src];
1023
+ case "party":
1024
+ return alliesOf(b, src);
1025
+ case "target_and_adjacent":
1026
+ return [tgt, ...adjacentTo(b, tgt)];
1027
+ case "adjacent_to_target":
1028
+ return adjacentTo(b, tgt);
1029
+ case "nearby":
1030
+ case "area":
1031
+ return [tgt, ...adjacentTo(b, tgt)];
1032
+ default:
1033
+ return [tgt];
1034
+ }
1035
+ }
1036
+ function applyEffect(b, src, tgt, e, delivery = "spell", s = null) {
1037
+ if (e.if && !branchOk(b, e.if, src, tgt)) return;
1038
+ if (e.if && s) logEmpower(b, src, tgt, s, empowerLabel(e, src.rank), reasonOf(e.if));
1039
+ const emp = !!e.if;
1040
+ const targets = resolveScope(b, src, tgt, e.scope);
1041
+ for (const t of targets) {
1042
+ if (!t || !t.alive) continue;
1043
+ const dur = e.duration != null ? val(e.duration, src.rank) : 0;
1044
+ switch (e.op) {
1045
+ case "damage": {
1046
+ const amt = val(e.amount, src.rank);
1047
+ const n = e.projectiles || 0;
1048
+ if (n > 1 || e.delivery === "projectile_spell") fireSpellProjectiles(b, src, t, amt, e, n || 1);
1049
+ else dealDamage(b, src, t, amt, src.name || "spell", { damageType: e.damageType, delivery, empowered: emp });
1050
+ break;
1051
+ }
1052
+ case "life_steal": {
1053
+ const dealt = dealDamage(b, src, t, val(e.amount, src.rank), src.name || "steal", { delivery, armorIgnoring: true });
1054
+ healActor(b, src, dealt);
1055
+ break;
1056
+ }
1057
+ case "heal": {
1058
+ let amt = val(e.amount, src.rank);
1059
+ let scaled = 0;
1060
+ if (e.plusPerMod) {
1061
+ scaled = countCat(b, t, e.plusPerMod.kinds) * val(e.plusPerMod.amount, src.rank);
1062
+ amt += scaled;
1063
+ }
1064
+ if (s && scaled > 0) logEmpower(b, src, t, s, `+${scaled} heal`, `${countCat(b, t, e.plusPerMod.kinds)} effects`);
1065
+ healActor(b, t, amt, emp || scaled > 0);
1066
+ break;
1067
+ }
1068
+ case "apply_condition":
1069
+ applyCondition(b, t, e.condition, dur, emp);
1070
+ break;
1071
+ case "knockdown":
1072
+ t.kd = Math.max(t.kd, b.t + dur);
1073
+ t.casting = null;
1074
+ break;
1075
+ case "interrupt":
1076
+ if (t.casting) {
1077
+ t.casting = null;
1078
+ log(b, "cond", t, { cond: "interrupted", amount: 0, ...emp ? { empowered: true } : {} });
1079
+ }
1080
+ break;
1081
+ case "regen_mod":
1082
+ addMod(b, t, { kind: "regen", pips: val(e.pips, src.rank), dur });
1083
+ break;
1084
+ case "attack_speed":
1085
+ addMod(b, t, { kind: "attackSpeed", mult: e.mult, dur });
1086
+ break;
1087
+ case "armor_mod":
1088
+ addMod(b, t, { kind: "armor", amount: val(e.amount, src.rank), attacksLeft: e.attacksLeft, dur });
1089
+ break;
1090
+ case "move_speed":
1091
+ addMod(b, src, { kind: "moveSpeed", mult: e.mult, endsOnHexEnchant: e.endsOnHexEnchant, dur });
1092
+ break;
1093
+ case "block":
1094
+ addMod(b, src, { kind: "block", chance: e.chance, vs: e.vs || "all", reflect: e.reflect ? val(e.reflect, src.rank) : 0, endsOnHexEnchant: e.endsOnHexEnchant, dur });
1095
+ break;
1096
+ case "shadow_step":
1097
+ shadowStep(b, src, t);
1098
+ break;
1099
+ case "set_combo_mark":
1100
+ src.marks[t.id] = { stage: e.stage, until: b.t + 20 };
1101
+ break;
1102
+ case "lose_all_adrenaline":
1103
+ src.adrenaline = 0;
1104
+ break;
1105
+ case "preparation":
1106
+ src.prep = { on_attack: e.on_attack, until: b.t + dur };
1107
+ break;
1108
+ case "hex":
1109
+ case "enchant":
1110
+ applyContainer(b, src, t, e);
1111
+ break;
1112
+ default:
1113
+ break;
1114
+ }
1115
+ }
1116
+ }
1117
+ function shadowStep(b, a, tgt) {
1118
+ const dx = a.x - tgt.x, dy = a.y - tgt.y, len = Math.hypot(dx, dy) || 1;
1119
+ a.x = Math.max(0, Math.min(FIELD.w, tgt.x + dx / len * (BASIC_MELEE_GW * 0.8)));
1120
+ a.y = Math.max(0, Math.min(FIELD.h, tgt.y + dy / len * (BASIC_MELEE_GW * 0.8)));
1121
+ }
1122
+ function fireSpellProjectiles(b, src, tgt, amt, e, n) {
1123
+ const base = dist(src, tgt) / 900;
1124
+ for (let i = 0; i < n; i++) {
1125
+ b.projectiles.push({
1126
+ srcId: src.id,
1127
+ tgtId: tgt.id,
1128
+ aimX: tgt.x,
1129
+ aimY: tgt.y,
1130
+ bornT: b.t,
1131
+ hitT: b.t + base + i * 0.1,
1132
+ spell: true,
1133
+ amount: amt,
1134
+ damageType: e.damageType,
1135
+ label: src.name || "spell"
1136
+ });
1137
+ }
1138
+ log(b, "shoot", src, { name: src.name });
1139
+ }
1140
+ function branchOk(b, req, a, tgt) {
1141
+ if (req.target_below_health != null) return tgt.hp / tgt.maxHp < req.target_below_health;
1142
+ if (req.target_health_above_self) return tgt.hp > a.hp;
1143
+ if (req.target === "bleeding") return hasCond(tgt, "bleeding");
1144
+ if (req.target === "casting_spell") return !!tgt.casting;
1145
+ if (req.target === "moving") return !!tgt.moving;
1146
+ if (req.target === "knocked_down") return isKd(b, tgt);
1147
+ if (req.target === "hexed") return hasHex(b, tgt);
1148
+ if (req.target === "attacking") return tgt.attackedAt != null && b.t - tgt.attackedAt < 1.2;
1149
+ if (req.self === "enchanted") return hasModKind(b, a, "cap") || hasModKind(b, a, "convert");
1150
+ return true;
1151
+ }
1152
+ function reasonOf(req) {
1153
+ if (!req) return "";
1154
+ if (req.target_below_health != null) return `foe <${req.target_below_health * 100}%`;
1155
+ if (req.target_health_above_self) return "foe has more HP";
1156
+ if (req.target === "bleeding") return "foe Bleeding";
1157
+ if (req.target === "casting_spell") return "foe casting";
1158
+ if (req.target === "moving") return "foe moving";
1159
+ if (req.target === "knocked_down") return "knocked down";
1160
+ if (req.target === "hexed") return "foe hexed";
1161
+ if (req.target === "attacking") return "foe attacking";
1162
+ return "";
1163
+ }
1164
+ function empowerLabel(e, rank) {
1165
+ switch (e.op) {
1166
+ case "bonus_damage":
1167
+ case "damage":
1168
+ return `+${val(e.amount, rank)} dmg`;
1169
+ case "apply_condition":
1170
+ return `+${e.condition}`;
1171
+ case "heal":
1172
+ return `+${val(e.amount, rank)} heal`;
1173
+ case "interrupt":
1174
+ return "INTERRUPT";
1175
+ default:
1176
+ return "bonus";
1177
+ }
1178
+ }
1179
+ function logEmpower(b, src, tgt, s, label, reason) {
1180
+ log(b, "empower", src, { tgt: tgt?.id, skillId: s?.id, name: s?.name, label, reason });
1181
+ }
1182
+ function strike(b, a, enemy, s) {
1183
+ a.attackTimer = a.weapon.interval * attackSpeedMult(b, a);
1184
+ a.attackedAt = b.t;
1185
+ if (hasCond(a, "blind") && b.rng() < 0.9) {
1186
+ if (a.role !== "ranged") log(b, "swing", a, { name: s ? s.name : "attack" });
1187
+ log(b, "miss", enemy, { name: s ? s.name : "attack" });
1188
+ return;
1189
+ }
1190
+ let weaponDmg = a.weapon.min + b.rng() * (a.weapon.max - a.weapon.min);
1191
+ if (hasCond(a, "weakness")) weaponDmg *= 0.75;
1192
+ let bonus = 0, empEffect = null;
1193
+ if (s) {
1194
+ for (const e of s.effects) if (e.op === "bonus_damage" && (!e.if || branchOk(b, e.if, a, enemy))) {
1195
+ bonus += val(e.amount, a.rank);
1196
+ if (e.if) empEffect = e;
1197
+ }
1198
+ }
1199
+ if (a.role === "ranged") {
1200
+ const flight = dist(a, enemy) / (a.weapon.projSpeed || 800);
1201
+ b.projectiles.push({ srcId: a.id, tgtId: enemy.id, fromX: a.x, fromY: a.y, aimX: enemy.x, aimY: enemy.y, bornT: b.t, hitT: b.t + flight, weaponDmg, bonus, s, empEffect });
1202
+ log(b, "shoot", a, { name: s ? s.name : "shot", skillId: s?.id });
1203
+ } else {
1204
+ log(b, "swing", a, { name: s ? s.name : "attack" });
1205
+ applyHit(b, a, enemy, s, weaponDmg, bonus, empEffect);
1206
+ }
1207
+ }
1208
+ function applyHit(b, a, enemy, s, weaponDmg, bonus, empEffect = null) {
1209
+ if (!enemy.alive) return;
1210
+ const delivery = a.role === "ranged" ? "projectile" : "melee";
1211
+ if (empEffect) logEmpower(b, a, enemy, s, empowerLabel(empEffect, a.rank), reasonOf(empEffect.if));
1212
+ dealDamage(b, a, enemy, weaponDmg + bonus, s ? s.name : "attack", { delivery, empowered: !!empEffect });
1213
+ if (s) {
1214
+ for (const e of s.effects) {
1215
+ if (e.op === "bonus_damage") continue;
1216
+ if (e.if && !branchOk(b, e.if, a, enemy)) continue;
1217
+ const emp = !!e.if;
1218
+ if (emp && e.op !== "damage") logEmpower(b, a, enemy, s, empowerLabel(e, a.rank), reasonOf(e.if));
1219
+ switch (e.op) {
1220
+ case "apply_condition":
1221
+ for (const t of resolveScope(b, a, enemy, e.scope)) applyCondition(b, t, e.condition, val(e.duration, a.rank), emp);
1222
+ break;
1223
+ case "set_combo_mark":
1224
+ a.marks[enemy.id] = { stage: e.stage, until: b.t + 20 };
1225
+ break;
1226
+ case "lose_all_adrenaline":
1227
+ a.adrenaline = 0;
1228
+ break;
1229
+ case "knockdown":
1230
+ enemy.kd = Math.max(enemy.kd, b.t + val(e.duration, a.rank));
1231
+ enemy.casting = null;
1232
+ break;
1233
+ case "interrupt":
1234
+ if (enemy.casting) {
1235
+ enemy.casting = null;
1236
+ log(b, "cond", enemy, { cond: "interrupted", amount: 0, ...emp ? { empowered: true } : {} });
1237
+ }
1238
+ break;
1239
+ case "damage":
1240
+ applyEffect(b, a, enemy, e, "melee", s);
1241
+ break;
1242
+ default:
1243
+ break;
1244
+ }
1245
+ }
1246
+ if (s.category === "dual_attack") delete a.marks[enemy.id];
1247
+ }
1248
+ if (a.prep && a.prep.until > b.t) for (const e of a.prep.on_attack) {
1249
+ if (e.op === "apply_condition") applyCondition(b, enemy, e.condition, val(e.duration, a.rank));
1250
+ }
1251
+ gainAdr(a, 1);
1252
+ }
1253
+ function advanceProjectiles(b) {
1254
+ const live = [];
1255
+ for (const p of b.projectiles) {
1256
+ if (b.t < p.hitT) {
1257
+ live.push(p);
1258
+ continue;
1259
+ }
1260
+ const src = b.actors.find((x) => x.id === p.srcId);
1261
+ const tgt = b.actors.find((x) => x.id === p.tgtId);
1262
+ if (!src || !tgt || !tgt.alive) continue;
1263
+ if (Math.hypot(tgt.x - p.aimX, tgt.y - p.aimY) > HIT_TOLERANCE) {
1264
+ log(b, "miss", tgt, { name: p.label || p.s?.name || "shot" });
1265
+ continue;
1266
+ }
1267
+ if (p.spell) dealDamage(b, src, tgt, p.amount, p.label, { damageType: p.damageType, delivery: "spell" });
1268
+ else applyHit(b, src, tgt, p.s, p.weaponDmg, p.bonus, p.empEffect);
1269
+ }
1270
+ b.projectiles = live;
1271
+ }
1272
+ var fireOnAction = (b, a) => fireTrigger(b, a, "onAction");
1273
+ function applyActivationPenalty(b, a, s, cast) {
1274
+ for (const e of s.whileActivating || []) {
1275
+ if (e.op === "armor_mod") addMod(b, a, { kind: "armor", amount: val(e.amount, a.rank), dur: cast });
1276
+ }
1277
+ }
1278
+ var COMBO_STAGE = { lead_attack: "lead", offhand_attack: "offhand", dual_attack: "dual" };
1279
+ function performSkill(b, a, tgt, s) {
1280
+ if (s.cost?.energy) a.energy -= s.cost.energy;
1281
+ if (s.cost?.adrenaline) a.adrenaline -= s.cost.adrenaline;
1282
+ if (s.cost?.sacrifice) a.hp = Math.max(1, a.hp - Math.round(a.maxHp * s.cost.sacrifice / 100));
1283
+ log(b, "cast", a, { name: s.name, elite: !!s.elite, skillId: s.id, tgt: tgt.id, ...COMBO_STAGE[s.category] ? { combo: COMBO_STAGE[s.category] } : {} });
1284
+ a.recharge[s.name] = b.t + (s.recharge || 0);
1285
+ fireOnAction(b, a);
1286
+ if (isAttack(s)) {
1287
+ strike(b, a, tgt, s);
1288
+ return;
1289
+ }
1290
+ for (const e of s.effects) applyEffect(b, a, tgt, e, "spell", s);
1291
+ }
1292
+ var isSupport = (s) => !!s && ["self", "ally", "other_ally", "party"].includes(s.target);
1293
+ var enchantModKind = (e) => {
1294
+ const p = e.payload?.[0] || {};
1295
+ if (p.op === "cap_damage") return "cap";
1296
+ if (p.op === "convert_damage_to_heal") return "convert";
1297
+ if (e.trigger === "on_incoming_damage") return "onIncomingHeal";
1298
+ return null;
1299
+ };
1300
+ function skillTarget(b, a, s, foe) {
1301
+ if (s.target === "self" || s.target === "party") return a;
1302
+ if (s.target === "ally") return mostWoundedAlly(b, a, true);
1303
+ if (s.target === "other_ally") return mostWoundedAlly(b, a, false);
1304
+ return foe;
1305
+ }
1306
+ function usable(b, a, s, tgt, foe) {
1307
+ if (!tgt) return false;
1308
+ if (b.t < (a.recharge[s.name] || 0)) return false;
1309
+ if (s.cost?.energy && a.energy < s.cost.energy) return false;
1310
+ if (s.cost?.adrenaline && a.adrenaline < s.cost.adrenaline) return false;
1311
+ if (isAttack(s) && edgeGap(a, foe) > reachOf(a)) return false;
1312
+ if (!isAttack(s) && !isSupport(s) && dist(a, foe) > SPELL_RANGE) return false;
1313
+ for (const r of s.requires || []) {
1314
+ if (r === "on_hit") continue;
1315
+ if (r.combo_follows && a.marks[foe.id]?.stage !== r.combo_follows) return false;
1316
+ if (r.target === "bleeding" && !hasCond(foe, "bleeding")) return false;
1317
+ if (r.target === "casting_spell" && !foe.casting) return false;
1318
+ if (r.target === "moving" && !foe.moving) return false;
1319
+ if (r.target === "knocked_down" && !isKd(b, foe)) return false;
1320
+ }
1321
+ if (s.effects.some((e) => e.op === "preparation") && a.prep && a.prep.until > b.t) return false;
1322
+ if (isSupport(s)) {
1323
+ if (s.effects.some((e) => e.op === "heal") && tgt.hp / tgt.maxHp >= 0.7 && s.effects.every((e) => e.op === "heal" || e.op === "shadow_step")) return false;
1324
+ for (const e of s.effects) {
1325
+ if (e.op === "enchant") {
1326
+ const k = enchantModKind(e);
1327
+ if (k && hasModKind(b, tgt, k)) return false;
1328
+ }
1329
+ const selfBuffKind = { block: "block", armor_mod: "armor", regen_mod: "regen", attack_speed: "attackSpeed", move_speed: "moveSpeed" }[e.op];
1330
+ if (selfBuffKind && hasModKind(b, a, selfBuffKind)) return false;
1331
+ }
1332
+ } else {
1333
+ const meaningful = s.effects.filter((e) => e.op !== "set_combo_mark" && e.op !== "preparation");
1334
+ if (meaningful.length && meaningful.every((e) => e.op === "apply_condition" && hasCond(foe, e.condition))) return false;
1335
+ }
1336
+ return true;
1337
+ }
1338
+ function chooseAction(b, a, foe) {
1339
+ for (const s of a.bar) {
1340
+ const tgt = skillTarget(b, a, s, foe);
1341
+ if (usable(b, a, s, tgt, foe)) return { skill: s, target: tgt };
1342
+ }
1343
+ return null;
1344
+ }
1345
+ function moveActor(b, a, enemy, dt) {
1346
+ const d = dist(a, enemy);
1347
+ let toward = 0;
1348
+ if (a.role === "ranged") {
1349
+ if (d < (a.preferredRange ?? a.weapon.range * 0.7)) toward = -1;
1350
+ else if (d > a.weapon.range) toward = 1;
1351
+ } else if (edgeGap(a, enemy) > reachOf(a)) {
1352
+ toward = 1;
1353
+ }
1354
+ if (!toward) {
1355
+ a.vx = 0;
1356
+ a.vy = 0;
1357
+ return;
1358
+ }
1359
+ const speed = a.moveSpeed * moveSpeedMult(b, a);
1360
+ const dx = enemy.x - a.x, dy = enemy.y - a.y, len = Math.hypot(dx, dy) || 1;
1361
+ const desVx = dx / len * speed * toward, desVy = dy / len * speed * toward;
1362
+ const [vx, vy] = avoidVelocity(b, a, enemy, desVx, desVy, speed);
1363
+ a.x = clampField(a.x + vx * dt, a.radius, FIELD.w);
1364
+ a.y = clampField(a.y + vy * dt, a.radius, FIELD.h);
1365
+ a.vx = vx;
1366
+ a.vy = vy;
1367
+ a.moving = true;
1368
+ }
1369
+ var RVO_TAU = 1.6;
1370
+ var RVO_RANGE = 280;
1371
+ var RVO_W = 240;
1372
+ var RVO_ANGLES = [0, 0.3, -0.3, 0.62, -0.62, 0.98, -0.98, 1.4, -1.4];
1373
+ var RVO_SPEEDS = [1, 0.6];
1374
+ function avoidVelocity(b, a, enemy, desVx, desVy, speed) {
1375
+ const KY = COLLISION_Y_WEIGHT;
1376
+ const obs = [];
1377
+ for (const o of b.actors) {
1378
+ if (o === a || !o.alive || o === enemy) continue;
1379
+ const rpx = o.x - a.x, rpy = (o.y - a.y) * KY;
1380
+ if (rpx * rpx + rpy * rpy > RVO_RANGE * RVO_RANGE) continue;
1381
+ obs.push({ rpx, rpy, ovx: o.vx || 0, ovy: (o.vy || 0) * KY, R: a.radius + o.radius });
1382
+ }
1383
+ if (!obs.length) return [desVx, desVy];
1384
+ const baseAng = Math.atan2(desVy, desVx);
1385
+ let best = [desVx, desVy], bestPen = Infinity;
1386
+ for (const da of RVO_ANGLES) {
1387
+ const ang = baseAng + da, cs = Math.cos(ang), sn = Math.sin(ang);
1388
+ for (const sf of RVO_SPEEDS) {
1389
+ const cvx = cs * speed * sf, cvy = sn * speed * sf;
1390
+ const cvxw = cvx, cvyw = cvy * KY;
1391
+ let minTtc = Infinity;
1392
+ for (const o of obs) {
1393
+ const t = timeToHit(o.rpx, o.rpy, o.ovx - cvxw, o.ovy - cvyw, o.R);
1394
+ if (t < minTtc) minTtc = t;
1395
+ }
1396
+ const collPen = minTtc <= RVO_TAU ? RVO_W / Math.max(minTtc, 0.05) : 0;
1397
+ const dev = Math.hypot(cvx - desVx, cvy - desVy);
1398
+ const pen = collPen + dev;
1399
+ if (pen < bestPen) {
1400
+ bestPen = pen;
1401
+ best = [cvx, cvy];
1402
+ }
1403
+ }
1404
+ }
1405
+ return best;
1406
+ }
1407
+ function timeToHit(px, py, rvx, rvy, R) {
1408
+ const c = px * px + py * py - R * R;
1409
+ if (c <= 0) return 0;
1410
+ const a2 = rvx * rvx + rvy * rvy;
1411
+ if (a2 < 1e-6) return Infinity;
1412
+ const b2 = px * rvx + py * rvy;
1413
+ if (b2 >= 0) return Infinity;
1414
+ const disc = b2 * b2 - a2 * c;
1415
+ if (disc <= 0) return Infinity;
1416
+ return (-b2 - Math.sqrt(disc)) / a2;
1417
+ }
1418
+ var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v));
1419
+ function resolveOverlaps(b) {
1420
+ const live = b.actors.filter((a) => a.alive);
1421
+ for (let it = 0; it < DEOVERLAP_ITERS; it++) {
1422
+ for (let i = 0; i < live.length; i++) {
1423
+ for (let j = i + 1; j < live.length; j++) {
1424
+ const a = live[i], o = live[j];
1425
+ const dx = o.x - a.x, dy = (o.y - a.y) * COLLISION_Y_WEIGHT;
1426
+ const d = Math.hypot(dx, dy) || 0.01;
1427
+ const overlap = a.radius + o.radius - d;
1428
+ if (overlap <= CONTACT_SLOP) continue;
1429
+ const ux = dx / d, uy = dy / d;
1430
+ const aFix = isImmovable(b, a), oFix = isImmovable(b, o);
1431
+ const push = overlap * DEOVERLAP_FRACTION;
1432
+ const aShare = aFix ? 0 : oFix ? 1 : 0.5;
1433
+ const oShare = oFix ? 0 : aFix ? 1 : 0.5;
1434
+ const yPush = uy / COLLISION_Y_WEIGHT;
1435
+ a.x = clampField(a.x - ux * push * aShare, a.radius, FIELD.w);
1436
+ a.y = clampField(a.y - yPush * push * aShare, a.radius, FIELD.h);
1437
+ o.x = clampField(o.x + ux * push * oShare, o.radius, FIELD.w);
1438
+ o.y = clampField(o.y + yPush * push * oShare, o.radius, FIELD.h);
1439
+ }
1440
+ }
1441
+ }
1442
+ }
1443
+ var isImmovable = (b, a) => !!a.casting || isKd(b, a);
1444
+ function step(b, dt) {
1445
+ if (b.over) return;
1446
+ b.t += dt;
1447
+ for (const a of b.actors) {
1448
+ if (!a.alive) continue;
1449
+ a.energy = Math.min(a.maxEnergy, a.energy + a.energyRegen * dt);
1450
+ let degen = 0;
1451
+ for (const c of a.conds) if (c.until > b.t) degen += DEGEN[c.type] || 0;
1452
+ const rate = degen - sumRegenPips(b, a) * 2;
1453
+ if (rate) {
1454
+ a.hp = Math.min(a.maxHp, a.hp - rate * dt);
1455
+ if (a.hp <= 0) kill(b, a);
1456
+ }
1457
+ expireConds(b, a);
1458
+ a.attackTimer -= dt;
1459
+ for (const id of Object.keys(a.marks)) if (a.marks[id]?.until <= b.t) delete a.marks[id];
1460
+ }
1461
+ advanceProjectiles(b);
1462
+ for (const a of b.actors) {
1463
+ if (!a.alive || b.over) continue;
1464
+ const enemy = nearestFoe(b, a);
1465
+ if (!enemy) continue;
1466
+ a.facing = enemy.x < a.x ? -1 : 1;
1467
+ a.faceX = a.facing;
1468
+ a.faceY = enemy.y < a.y ? -1 : 1;
1469
+ a.moving = false;
1470
+ if (isKd(b, a)) {
1471
+ a.casting = null;
1472
+ continue;
1473
+ }
1474
+ if (a.casting) {
1475
+ a.casting.left -= dt;
1476
+ if (a.casting.left <= 0) {
1477
+ const { skill, target } = a.casting;
1478
+ a.casting = null;
1479
+ performSkill(b, a, target?.alive ? target : enemy, skill);
1480
+ }
1481
+ continue;
1482
+ }
1483
+ const action = chooseAction(b, a, enemy);
1484
+ if (action) {
1485
+ const cast = (action.skill.cast || 0) * (hasCond(a, "dazed") ? 2 : 1);
1486
+ if (cast <= 0) performSkill(b, a, action.target, action.skill);
1487
+ else {
1488
+ applyActivationPenalty(b, a, action.skill, cast);
1489
+ a.casting = { skill: action.skill, target: action.target, left: cast };
1490
+ }
1491
+ } else if (edgeGap(a, enemy) <= reachOf(a) && a.attackTimer <= 0) {
1492
+ fireOnAction(b, a);
1493
+ strike(b, a, enemy, null);
1494
+ } else {
1495
+ moveActor(b, a, enemy, dt);
1496
+ }
1497
+ }
1498
+ resolveOverlaps(b);
1499
+ const playerAlive = b.actors.some((a) => a.alive && a.team === "player");
1500
+ const enemyAlive = b.actors.some((a) => a.alive && a.team === "enemy");
1501
+ if (!playerAlive || !enemyAlive) {
1502
+ b.over = true;
1503
+ b.winner = playerAlive ? "player" : enemyAlive ? "enemy" : null;
1504
+ } else if (b.t >= MAX_BATTLE_T) {
1505
+ const hp = (team) => b.actors.filter((a) => a.alive && a.team === team).reduce((n, a) => n + a.hp, 0);
1506
+ const ph = hp("player"), eh = hp("enemy");
1507
+ b.over = true;
1508
+ b.winner = ph === eh ? null : ph > eh ? "player" : "enemy";
1509
+ }
1510
+ }
1511
+ function runToEnd(opts, dt = 0.05, maxT = 120) {
1512
+ const b = makeTeamBattle(opts);
1513
+ while (!b.over && b.t < maxT) step(b, dt);
1514
+ return b;
1515
+ }
1516
+ export {
1517
+ CLASS_TEMPLATES,
1518
+ COLLISION_Y_WEIGHT,
1519
+ FIELD,
1520
+ isSupport,
1521
+ makeTeamBattle,
1522
+ runToEnd,
1523
+ skillById,
1524
+ step,
1525
+ val
1526
+ };
web/index.html ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Tiny Army</title>
7
+ <style>
8
+ html, body { margin: 0; height: 100%; background: #0b0e12; color: #f4ecd8;
9
+ font-family: ui-monospace, Menlo, monospace; }
10
+ #wrap { display: flex; flex-direction: column; height: 100vh; }
11
+ header { padding: 10px 14px; display: flex; justify-content: space-between;
12
+ align-items: center; border-bottom: 1px solid #20262e; }
13
+ header strong { letter-spacing: .03em; }
14
+ header span { color: #9aa4b2; font-size: 13px; }
15
+ header a { color: #ffd54a; text-decoration: none; }
16
+ #stage { flex: 1; min-height: 0; }
17
+ canvas { display: block; width: 100%; height: 100%; }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <div id="wrap">
22
+ <header>
23
+ <strong>🪖 Tiny Army</strong>
24
+ <span>deterministic auto-battler engine running in-browser ·
25
+ <a href="/app">barracks →</a></span>
26
+ </header>
27
+ <div id="stage"></div>
28
+ </div>
29
+ <script type="module" src="./main.js"></script>
30
+ </body>
31
+ </html>
web/main.js ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Tiny Army — Pixi stage driven by the real deterministic combat engine
2
+ // (engine.js is an esbuild bundle of the auto-battler's src/engine/teamBattle.js).
3
+ import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8/dist/pixi.min.mjs'
4
+ import { makeTeamBattle, step, FIELD } from './engine.js'
5
+
6
+ // A small demo line-up; later this comes from the player's saved roster.
7
+ const PLAYERS = [
8
+ { profession: 'Warrior', name: 'Bram', skills: [] },
9
+ { profession: 'Ranger', name: 'Sela', skills: [] },
10
+ { profession: 'Monk', name: 'Oda', skills: [] },
11
+ { profession: 'Assassin', name: 'Vex', skills: [] },
12
+ ]
13
+ const ENEMIES = [
14
+ { name: 'Orc Reaver', stats: { hp: 340, armor: 30, basicDamage: 16 }, attackType: 'melee', skills: [] },
15
+ { name: 'Orc Reaver', stats: { hp: 340, armor: 30, basicDamage: 16 }, attackType: 'melee', skills: [] },
16
+ { name: 'Bog Shaman', stats: { hp: 280, armor: 30, basicDamage: 12 }, attackType: 'ranged', skills: [] },
17
+ { name: 'Dire Wolf', stats: { hp: 240, armor: 20, basicDamage: 14 }, attackType: 'melee', skills: [] },
18
+ ]
19
+
20
+ let battle
21
+ let seed = 1
22
+ let overAt = 0
23
+ const fresh = () => { battle = makeTeamBattle({ seed: seed++, players: PLAYERS, enemies: ENEMIES }); overAt = 0 }
24
+
25
+ const host = document.getElementById('stage')
26
+ const app = new PIXI.Application()
27
+ await app.init({ background: 0x0b0e12, resizeTo: host, antialias: true })
28
+ host.appendChild(app.canvas)
29
+
30
+ const bg = new PIXI.Graphics()
31
+ const g = new PIXI.Graphics()
32
+ const labels = new PIXI.Container()
33
+ app.stage.addChild(bg, g, labels)
34
+
35
+ const banner = new PIXI.Text({ text: '', style: { fill: 0xffd54a, fontFamily: 'monospace', fontSize: 20, fontWeight: '700' } })
36
+ banner.anchor.set(0.5, 0)
37
+ app.stage.addChild(banner)
38
+
39
+ // One reusable name label per actor.
40
+ const nameStyle = { fill: 0xc9d2dd, fontFamily: 'monospace', fontSize: 11 }
41
+ const nameText = {}
42
+ fresh()
43
+ for (const a of battle.actors) { const t = new PIXI.Text({ text: a.name, style: nameStyle }); t.anchor.set(0.5, 1); nameText[a.id] = t; labels.addChild(t) }
44
+
45
+ app.ticker.add(() => {
46
+ const W = app.screen.width, H = app.screen.height
47
+ const sx = W / FIELD.w, sy = H / FIELD.h
48
+
49
+ if (!battle.over) { for (let i = 0; i < 3; i++) if (!battle.over) step(battle, 0.05) }
50
+ else if (!overAt) overAt = performance.now()
51
+ if (overAt && performance.now() - overAt > 3000) fresh()
52
+
53
+ bg.clear()
54
+ bg.rect(0, 0, W, H).fill(0x10151b)
55
+ bg.rect(0, H * 0.5 - 1, W, 2).fill({ color: 0xffffff, alpha: 0.04 })
56
+
57
+ g.clear()
58
+ for (const a of battle.actors) {
59
+ const cx = a.x * sx, cy = a.y * sy
60
+ const r = Math.max(6, (a.radius || 24) * sx)
61
+ const col = a.team === 'player' ? 0x4ad6ff : 0xff5a4a
62
+ const t = nameText[a.id]
63
+ if (!a.alive) {
64
+ g.circle(cx, cy, r).fill({ color: col, alpha: 0.1 })
65
+ if (t) t.visible = false
66
+ continue
67
+ }
68
+ g.circle(cx, cy, r).fill({ color: col, alpha: 0.85 }).stroke({ color: 0xffffff, width: 1, alpha: 0.5 })
69
+ const hp = Math.max(0, a.hp) / a.maxHp
70
+ g.rect(cx - r, cy - r - 9, r * 2, 4).fill({ color: 0x000000, alpha: 0.6 })
71
+ g.rect(cx - r, cy - r - 9, r * 2 * hp, 4).fill(hp > 0.35 ? 0x6ee36e : 0xe36e6e)
72
+ if (t) { t.visible = true; t.position.set(cx, cy - r - 11) }
73
+ }
74
+
75
+ banner.position.set(W / 2, 12)
76
+ banner.text = battle.over
77
+ ? (battle.winner === 'player' ? '★ Your tiny army wins' : battle.winner === 'enemy' ? '☠ Enemies win' : 'Draw')
78
+ : ''
79
+ })