@g3nomaly/g3n-mods 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +7 -1
- package/README.md +12 -5
- package/mods/agent-adventure/.claude-plugin/plugin.json +23 -0
- package/mods/agent-adventure/hooks/content.ts +263 -0
- package/mods/agent-adventure/hooks/engine.ts +1125 -0
- package/mods/agent-adventure/hooks/hooks.json +1 -0
- package/mods/agent-adventure/hooks/register.tsx +465 -0
- package/mods/agent-adventure/hooks/sprite.ts +424 -0
- package/mods/agent-adventure/types/index.d.ts +248 -0
- package/mods/agent-adventure/view/panes.json +8 -0
- package/mods/agent-adventure/view/view.css +230 -0
- package/mods/agent-adventure/view/view.ts +308 -0
- package/mods/agents-panel/.claude-plugin/plugin.json +9 -1
- package/mods/agents-panel/hooks/register.tsx +18 -4
- package/mods/agents-panel/view/panes.json +8 -0
- package/mods/context-bar/.claude-plugin/plugin.json +9 -1
- package/mods/context-bar/hooks/register.tsx +4 -3
- package/mods/context-bar/view/panes.json +8 -0
- package/mods/custom-crab-agents/hooks/register.tsx +15 -1
- package/mods/delete-guard/.claude-plugin/plugin.json +9 -1
- package/mods/delete-guard/hooks/register.tsx +15 -5
- package/mods/delete-guard/view/panes.json +8 -0
- package/mods/focus-mode/.claude-plugin/plugin.json +6 -0
- package/mods/focus-mode/hooks/register.tsx +7 -4
- package/mods/focus-mode/view/panes.json +8 -0
- package/mods/mini-game/.claude-plugin/plugin.json +9 -1
- package/mods/mini-game/hooks/register.tsx +21 -7
- package/mods/mini-game/view/panes.json +8 -0
- package/mods/tool-timeline/.claude-plugin/plugin.json +9 -1
- package/mods/tool-timeline/hooks/register.tsx +18 -4
- package/mods/tool-timeline/view/panes.json +8 -0
- package/mods/you-should-know/.claude-plugin/plugin.json +3 -3
- package/mods/you-should-know/hooks/register.tsx +173 -45
- package/mods/you-should-know/types/index.d.ts +13 -4
- package/package.json +2 -1
|
@@ -0,0 +1,1125 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/agent-adventure/hooks/engine.ts
|
|
2
|
+
import type { ClassId, Effect, Fighter, Fx, Hero, LogLine, Run, SkillDef, StopKind, UnitView, ViewData, World } from '../types'
|
|
3
|
+
import {
|
|
4
|
+
BOSSES, CLASSES, CLASS_IDS, ENEMIES, FRIEND_LINES, MAX_SKILLS, NAME_PARTS, REGIONS, REGION_IDS, RELICS, RIVAL_LINES,
|
|
5
|
+
SKILLS, TAVERN_LINES,
|
|
6
|
+
} from './content'
|
|
7
|
+
import type { EnemyDef } from './content'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The game's rules as pure functions over a `World`. Each one copies the world, changes the copy and returns it, so
|
|
11
|
+
* the hooks module can run them inside `update`. Every roll comes from the world's own random state: the same world
|
|
12
|
+
* steps the same way twice. Nothing here reads the session, the code or the agents' work; the only inputs are which
|
|
13
|
+
* agents run, and the picks the model or an agent makes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Gauge a fighter needs to act; each fighter gains its speed every round of the gauge. */
|
|
17
|
+
export const READY = 20
|
|
18
|
+
/** Ticks an agent's hero stays in the party after its turn ends, so a quick reply does not send everyone home. */
|
|
19
|
+
export const LINGER_TICKS = 15
|
|
20
|
+
/** Most heroes in the guild; past it, a new agent borrows a free hero instead of signing up a new one. */
|
|
21
|
+
export const MAX_ROSTER = 12
|
|
22
|
+
/** Levels a party loses when every hero falls. */
|
|
23
|
+
export const WIPE_PENALTY = 5
|
|
24
|
+
/** An enemy's stats at level 0 and their growth per level, before its kind's multiples. */
|
|
25
|
+
export const ENEMY_BASE = { hp: 14, hpGrowth: 4.5, atk: 3.5, atkGrowth: 1, def: 1, defGrowth: 0.5 }
|
|
26
|
+
/** Rounds into a fight (an action per fighter) after which every fighter hits harder, so no fight stalls. */
|
|
27
|
+
export const FATIGUE_AFTER = 12
|
|
28
|
+
/** Bounds of the threat, the multiplier on enemy HP and attack that follows how the party fares. */
|
|
29
|
+
export const THREAT_MIN = 0.25
|
|
30
|
+
export const THREAT_MAX = 4
|
|
31
|
+
/** Fighters on the field per action a tick: a fight of 10 takes 2 actions a tick. */
|
|
32
|
+
const ACTORS_PER_ACTION = 5
|
|
33
|
+
/** Ticks the party waits on a pick at a fork before going with its default. */
|
|
34
|
+
export const FORK_WAIT = 8
|
|
35
|
+
|
|
36
|
+
const LOG_KEEP = 30
|
|
37
|
+
const FX_KEEP = 40
|
|
38
|
+
|
|
39
|
+
/** A decision the game hands to the model; the hooks module answers it with `christen`, `learn` or `chooseRoute`. */
|
|
40
|
+
export type Ask = { kind: 'christen'; heroId: string } | { kind: 'skill'; heroId: string } | { kind: 'route'; forkId: number }
|
|
41
|
+
|
|
42
|
+
/** A changed world and the decisions it now waits on. */
|
|
43
|
+
export type Outcome = { world: World; asks: Ask[] }
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
46
|
+
// Randomness and small helpers
|
|
47
|
+
|
|
48
|
+
/** Hashes text to a 32-bit seed. */
|
|
49
|
+
export function hash(text: string): number {
|
|
50
|
+
let h = 2166136261
|
|
51
|
+
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619)
|
|
52
|
+
return h >>> 0
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Draws a number from 0 up to 1 from the world's random state (mulberry32) and advances it. */
|
|
56
|
+
export function roll(w: World): number {
|
|
57
|
+
w.rng = (w.rng + 0x6d2b79f5) | 0
|
|
58
|
+
let t = w.rng
|
|
59
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
60
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
61
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Picks one item of a non-empty list with the world's random state. */
|
|
65
|
+
function pick<T>(w: World, list: readonly T[]): T {
|
|
66
|
+
return list[Math.floor(roll(w) * list.length)] as T
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* XP a hero needs to go from `level` to the next: as many fights as `4 + 2 × level`, at most 40, at the XP a fight of
|
|
73
|
+
* that level gives (see `fightXp`).
|
|
74
|
+
*/
|
|
75
|
+
export function xpToNext(level: number): number {
|
|
76
|
+
return Math.min(4 + 2 * level, 40) * (3 + level)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function log(w: World, text: string, tone: LogLine['tone'] = 'info'): void {
|
|
80
|
+
w.log.push({ id: w.seq++, text, tone })
|
|
81
|
+
if (w.log.length > LOG_KEEP) w.log.splice(0, w.log.length - LOG_KEEP)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function fx(w: World, event: Omit<Fx, 'id'>): void {
|
|
85
|
+
w.fx.push({ id: w.seq++, ...event })
|
|
86
|
+
if (w.fx.length > FX_KEEP) w.fx.splice(0, w.fx.length - FX_KEEP)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function heroById(w: World, id: string): Hero | undefined {
|
|
90
|
+
return w.heroes.find(hero => hero.id === id)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Copies a world, so a rule can change the copy freely. */
|
|
94
|
+
function copy(w: World): World {
|
|
95
|
+
return JSON.parse(JSON.stringify(w)) as World
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
99
|
+
// The guild: heroes, agents, and the picks the model or an agent makes
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Makes an empty world.
|
|
103
|
+
*
|
|
104
|
+
* @param heroes - Heroes read back from the store.
|
|
105
|
+
* @param run - The run read back from the store, or `null`.
|
|
106
|
+
* @param seed - Seed for the world's random state.
|
|
107
|
+
* @param runs - Runs started so far.
|
|
108
|
+
* @param threat - The threat read back from the store.
|
|
109
|
+
*/
|
|
110
|
+
export function newWorld(heroes: Hero[], run: Run | null, seed: number, runs = 0, threat = 1): World {
|
|
111
|
+
return { heroes, bindings: [], run, scene: { kind: 'tavern' }, fighters: [], turns: 0, log: [], fx: [], seq: 1, tick: 0, rng: seed | 0, runs, threat, isCalm: false }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Ids of the heroes in the party, in the order their agents started, christened ones only. */
|
|
115
|
+
export function partyIds(w: World): string[] {
|
|
116
|
+
const ids: string[] = []
|
|
117
|
+
for (const binding of w.bindings) {
|
|
118
|
+
const hero = heroById(w, binding.heroId)
|
|
119
|
+
if (hero !== undefined && !hero.isRecruit && !ids.includes(hero.id)) ids.push(hero.id)
|
|
120
|
+
}
|
|
121
|
+
return ids
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Makes a name from the game's name parts. */
|
|
125
|
+
function gameName(w: World): string {
|
|
126
|
+
const taken = new Set(w.heroes.map(hero => hero.name))
|
|
127
|
+
for (let tries = 0; tries < 20; tries++) {
|
|
128
|
+
const name = pick(w, NAME_PARTS[0]) + pick(w, NAME_PARTS[1])
|
|
129
|
+
if (!taken.has(name)) return name
|
|
130
|
+
}
|
|
131
|
+
return `Hero ${w.heroes.length + 1}`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The class the guild has fewest of, ties broken by a roll. */
|
|
135
|
+
function scarceClass(w: World): ClassId {
|
|
136
|
+
const counts = CLASS_IDS.map(cls => w.heroes.filter(hero => hero.cls === cls).length)
|
|
137
|
+
const fewest = Math.min(...counts)
|
|
138
|
+
return pick(w, CLASS_IDS.filter((_, i) => counts[i] === fewest))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @brief Puts a hero in the party for an agent that started: a free hero of its calling, else a new recruit, else
|
|
143
|
+
* any free hero. One agent never gets two heroes, and two agents never share one.
|
|
144
|
+
*
|
|
145
|
+
* @param world - Current world.
|
|
146
|
+
* @param agent - `main`, or the subagent's id.
|
|
147
|
+
* @param calling - What kind of agent it is: `main`, or a subagent type such as `Explore`.
|
|
148
|
+
* @param now - Milliseconds since the epoch, the new hero's creation time.
|
|
149
|
+
* @returns The world, and a `christen` ask when a recruit signed up.
|
|
150
|
+
*/
|
|
151
|
+
export function join(world: World, agent: string, calling: string, now: number): Outcome {
|
|
152
|
+
const w = copy(world)
|
|
153
|
+
const bound = w.bindings.find(binding => binding.agent === agent)
|
|
154
|
+
if (bound !== undefined) {
|
|
155
|
+
delete bound.leftAt
|
|
156
|
+
return { world: w, asks: [] }
|
|
157
|
+
}
|
|
158
|
+
const busy = new Set(w.bindings.map(binding => binding.heroId))
|
|
159
|
+
const free = w.heroes.filter(hero => !busy.has(hero.id)).sort((a, b) => b.level - a.level)
|
|
160
|
+
const own = free.find(hero => hero.calling === calling)
|
|
161
|
+
if (own !== undefined) {
|
|
162
|
+
w.bindings.push({ agent, heroId: own.id })
|
|
163
|
+
if (!own.isRecruit) log(w, `${own.name} joins the party.`)
|
|
164
|
+
return { world: w, asks: [] }
|
|
165
|
+
}
|
|
166
|
+
if (w.heroes.length < MAX_ROSTER) {
|
|
167
|
+
const hero: Hero = {
|
|
168
|
+
id: `h${now.toString(36)}${w.seq++}`,
|
|
169
|
+
name: gameName(w),
|
|
170
|
+
cls: scarceClass(w),
|
|
171
|
+
level: 1,
|
|
172
|
+
xp: 0,
|
|
173
|
+
bestLevel: 1,
|
|
174
|
+
skills: [],
|
|
175
|
+
calling,
|
|
176
|
+
feelings: {},
|
|
177
|
+
namedBy: 'game',
|
|
178
|
+
isRecruit: true,
|
|
179
|
+
trophies: [],
|
|
180
|
+
record: { runs: 0, wins: 0, wipes: 0, kills: 0 },
|
|
181
|
+
createdAt: now,
|
|
182
|
+
}
|
|
183
|
+
w.heroes.push(hero)
|
|
184
|
+
w.bindings.push({ agent, heroId: hero.id })
|
|
185
|
+
log(w, 'A new adventurer is signing up at the guild…')
|
|
186
|
+
return { world: w, asks: [{ kind: 'christen', heroId: hero.id }] }
|
|
187
|
+
}
|
|
188
|
+
const any = free.find(hero => !hero.isRecruit)
|
|
189
|
+
if (any !== undefined) {
|
|
190
|
+
w.bindings.push({ agent, heroId: any.id })
|
|
191
|
+
log(w, `${any.name} joins the party.`)
|
|
192
|
+
}
|
|
193
|
+
return { world: w, asks: [] }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Marks an agent's turn as ended; its hero leaves the party after `LINGER_TICKS`. */
|
|
197
|
+
export function leave(world: World, agent: string): World {
|
|
198
|
+
const w = copy(world)
|
|
199
|
+
const bound = w.bindings.find(binding => binding.agent === agent)
|
|
200
|
+
if (bound !== undefined) bound.leftAt = w.tick
|
|
201
|
+
return w
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Cleans a name or motto: letters, digits, spaces and light punctuation, cut to `max`. */
|
|
205
|
+
export function cleanText(text: unknown, max: number): string | undefined {
|
|
206
|
+
if (typeof text !== 'string') return undefined
|
|
207
|
+
const clean = text.replace(/[^\p{L}\p{N} '.,!?-]/gu, '').replace(/\s+/g, ' ').trim().slice(0, max).trim()
|
|
208
|
+
return clean.length >= 2 ? clean : undefined
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Finishes a recruit's sign-up with what the model picked, or the game's own picks for anything missing or invalid.
|
|
213
|
+
*
|
|
214
|
+
* @param world - Current world.
|
|
215
|
+
* @param heroId - The recruit.
|
|
216
|
+
* @param answer - The model's pick, or `undefined` when it gave none.
|
|
217
|
+
*/
|
|
218
|
+
export function christen(world: World, heroId: string, answer?: { name?: unknown; cls?: unknown; motto?: unknown }): World {
|
|
219
|
+
const w = copy(world)
|
|
220
|
+
const hero = heroById(w, heroId)
|
|
221
|
+
if (hero === undefined || !hero.isRecruit) return world
|
|
222
|
+
const name = cleanText(answer?.name, 16)
|
|
223
|
+
if (name !== undefined && !w.heroes.some(other => other.name === name)) {
|
|
224
|
+
hero.name = name
|
|
225
|
+
hero.namedBy = 'model'
|
|
226
|
+
}
|
|
227
|
+
const cls = CLASS_IDS.find(id => id === answer?.cls)
|
|
228
|
+
if (cls !== undefined) hero.cls = cls
|
|
229
|
+
const motto = cleanText(answer?.motto, 60)
|
|
230
|
+
if (motto !== undefined) hero.motto = motto
|
|
231
|
+
hero.isRecruit = false
|
|
232
|
+
log(w, `${hero.name} the ${CLASSES[hero.cls].name} joins the guild!`, 'good')
|
|
233
|
+
fx(w, { kind: 'say', at: hero.id, text: hero.motto ?? 'Reporting for duty!' })
|
|
234
|
+
return w
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Teaches a hero one of the two skills it was offered. A hero with `MAX_SKILLS` gives up its oldest skill.
|
|
239
|
+
*
|
|
240
|
+
* @param world - Current world.
|
|
241
|
+
* @param heroId - The hero with a pending choice.
|
|
242
|
+
* @param choice - `0` or `1`, which of the two.
|
|
243
|
+
* @param why - The model's reason, said by the hero.
|
|
244
|
+
*/
|
|
245
|
+
export function learn(world: World, heroId: string, choice: number, why?: string): World {
|
|
246
|
+
const w = copy(world)
|
|
247
|
+
const hero = heroById(w, heroId)
|
|
248
|
+
const id = hero?.choice?.[choice === 1 ? 1 : 0]
|
|
249
|
+
const taught = id === undefined ? undefined : SKILLS[id]
|
|
250
|
+
if (hero === undefined || taught === undefined) return world
|
|
251
|
+
delete hero.choice
|
|
252
|
+
const dropped = hero.skills.length >= MAX_SKILLS ? hero.skills.shift() : undefined
|
|
253
|
+
hero.skills.push(taught.id)
|
|
254
|
+
const forgot = dropped === undefined ? '' : `, setting aside ${SKILLS[dropped]?.name ?? dropped}`
|
|
255
|
+
log(w, `${hero.name} learns ${taught.name}${forgot}.`, 'level')
|
|
256
|
+
fx(w, { kind: 'learn', at: hero.id, text: taught.name })
|
|
257
|
+
const line = cleanText(why, 60)
|
|
258
|
+
if (line !== undefined) fx(w, { kind: 'say', at: hero.id, text: line })
|
|
259
|
+
return w
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Settles the route at a fork. Only the first pick for a fork counts.
|
|
264
|
+
*
|
|
265
|
+
* @param world - Current world.
|
|
266
|
+
* @param forkId - The fork's id, from its `route` ask.
|
|
267
|
+
* @param route - `safe` or `danger`.
|
|
268
|
+
* @param why - One line said by the hero who argued for it.
|
|
269
|
+
*/
|
|
270
|
+
export function chooseRoute(world: World, forkId: number, route: 'safe' | 'danger', why?: string): World {
|
|
271
|
+
if (world.scene.kind !== 'fork' || world.scene.id !== forkId || world.scene.route !== undefined) return world
|
|
272
|
+
const w = copy(world)
|
|
273
|
+
const party = partyIds(w)
|
|
274
|
+
const speaker = party.length === 0 ? undefined : heroById(w, pick(w, party))
|
|
275
|
+
const line = cleanText(why, 60)
|
|
276
|
+
w.scene = { ...(w.scene as Extract<World['scene'], { kind: 'fork' }>), route, ...(line === undefined ? {} : { why: line }), ...(speaker === undefined ? {} : { by: speaker.name }) }
|
|
277
|
+
if (speaker !== undefined && line !== undefined) fx(w, { kind: 'say', at: speaker.id, text: line })
|
|
278
|
+
return w
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Applies what an agent asked of its own hero through the mod's tool: a name, a motto, a feeling about another hero.
|
|
283
|
+
*
|
|
284
|
+
* @param world - Current world.
|
|
285
|
+
* @param agent - `main`, or the calling subagent's id.
|
|
286
|
+
* @param request - The tool's input.
|
|
287
|
+
* @returns The world and a line saying what changed, or why nothing did.
|
|
288
|
+
*/
|
|
289
|
+
export function agentRequest(world: World, agent: string, request: { name?: unknown; motto?: unknown; feeling?: unknown }): { world: World; text: string } {
|
|
290
|
+
const w = copy(world)
|
|
291
|
+
const binding = w.bindings.find(one => one.agent === agent)
|
|
292
|
+
const hero = binding === undefined ? undefined : heroById(w, binding.heroId)
|
|
293
|
+
if (hero === undefined) return { world, text: 'You have no hero in the game right now; nothing changed.' }
|
|
294
|
+
const done: string[] = []
|
|
295
|
+
if (request.name !== undefined) {
|
|
296
|
+
const name = cleanText(request.name, 16)
|
|
297
|
+
if (name === undefined) done.push('name refused: use 2 to 16 letters')
|
|
298
|
+
else if (w.heroes.some(other => other.id !== hero.id && other.name === name)) done.push(`name refused: another hero is called ${name}`)
|
|
299
|
+
else {
|
|
300
|
+
log(w, `${hero.name} will now be known as ${name}.`)
|
|
301
|
+
hero.name = name
|
|
302
|
+
hero.namedBy = 'agent'
|
|
303
|
+
done.push(`name set to ${name}`)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (request.motto !== undefined) {
|
|
307
|
+
const motto = cleanText(request.motto, 60)
|
|
308
|
+
if (motto === undefined) done.push('motto refused')
|
|
309
|
+
else {
|
|
310
|
+
hero.motto = motto
|
|
311
|
+
fx(w, { kind: 'say', at: hero.id, text: motto })
|
|
312
|
+
done.push('motto set')
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (typeof request.feeling === 'object' && request.feeling !== null) {
|
|
316
|
+
const { toward, value } = request.feeling as { toward?: unknown; value?: unknown }
|
|
317
|
+
const other = w.heroes.find(one => typeof toward === 'string' && one.name.toLowerCase() === toward.trim().toLowerCase())
|
|
318
|
+
if (other === undefined || other.id === hero.id || typeof value !== 'number') done.push('feeling refused: name another hero and a value from -2 to 2')
|
|
319
|
+
else {
|
|
320
|
+
hero.feelings[other.id] = clamp(Math.round(value), -2, 2)
|
|
321
|
+
done.push(`feeling toward ${other.name} set to ${hero.feelings[other.id]}`)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const status = `${hero.name}, level ${hero.level} ${CLASSES[hero.cls].name}`
|
|
325
|
+
return { world: w, text: done.length === 0 ? `${status}. Nothing asked, nothing changed.` : `${status}: ${done.join('; ')}.` }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
329
|
+
// Fighters
|
|
330
|
+
|
|
331
|
+
/** A hero's stats at its level, with its passive skills and the run's relics. */
|
|
332
|
+
export function heroFighter(hero: Hero, run: Run | null): Fighter {
|
|
333
|
+
const cls = CLASSES[hero.cls]
|
|
334
|
+
const grow = hero.level - 1
|
|
335
|
+
const bonus = { hp: 0, atk: 0, def: 0, spd: 0, crit: 0, evade: 0 }
|
|
336
|
+
const skills: SkillDef[] = []
|
|
337
|
+
for (const id of hero.skills) {
|
|
338
|
+
const known = SKILLS[id]
|
|
339
|
+
if (known === undefined) continue
|
|
340
|
+
if (known.kind === 'passive' && known.stat !== undefined) bonus[known.stat] += known.power
|
|
341
|
+
else skills.push(known)
|
|
342
|
+
}
|
|
343
|
+
let regen = 0
|
|
344
|
+
for (const id of run?.relics ?? []) {
|
|
345
|
+
const relic = RELICS[id]
|
|
346
|
+
if (relic === undefined) continue
|
|
347
|
+
bonus.atk += relic.atkPct ?? 0
|
|
348
|
+
bonus.def += relic.defPct ?? 0
|
|
349
|
+
bonus.spd += relic.spd ?? 0
|
|
350
|
+
bonus.crit += relic.crit ?? 0
|
|
351
|
+
regen += relic.regen ?? 0
|
|
352
|
+
}
|
|
353
|
+
const maxHp = Math.round((cls.base.hp + cls.growth.hp * grow) * (1 + bonus.hp))
|
|
354
|
+
const carried = run?.hp[hero.id]
|
|
355
|
+
return {
|
|
356
|
+
id: hero.id,
|
|
357
|
+
side: 'hero',
|
|
358
|
+
name: hero.name,
|
|
359
|
+
look: hero.cls,
|
|
360
|
+
level: hero.level,
|
|
361
|
+
hp: carried === undefined ? maxHp : clamp(carried, 0, maxHp),
|
|
362
|
+
maxHp,
|
|
363
|
+
atk: (cls.base.atk + cls.growth.atk * grow) * (1 + bonus.atk),
|
|
364
|
+
def: (cls.base.def + cls.growth.def * grow) * (1 + bonus.def),
|
|
365
|
+
spd: cls.base.spd + bonus.spd,
|
|
366
|
+
crit: cls.base.crit + bonus.crit,
|
|
367
|
+
evade: cls.base.evade + bonus.evade,
|
|
368
|
+
gauge: 0,
|
|
369
|
+
basic: cls.basic,
|
|
370
|
+
skills,
|
|
371
|
+
cooldowns: {},
|
|
372
|
+
effects: cls.opening === undefined ? [] : [{ kind: cls.opening.kind, turns: cls.opening.turns, power: 0.2 }],
|
|
373
|
+
regen,
|
|
374
|
+
rank: 'normal',
|
|
375
|
+
phase: 1,
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** An enemy at `level`, from the level baseline times its kind's multiples. */
|
|
380
|
+
function enemyFighter(id: string, look: string, def: EnemyDef, level: number, rank: Fighter['rank'], threat: number, hpScale = 1): Fighter {
|
|
381
|
+
const maxHp = Math.round((ENEMY_BASE.hp + ENEMY_BASE.hpGrowth * level) * def.hp * hpScale * threat)
|
|
382
|
+
const special = def.special
|
|
383
|
+
return {
|
|
384
|
+
id,
|
|
385
|
+
side: 'enemy',
|
|
386
|
+
name: rank === 'elite' ? `Elite ${def.name}` : def.name,
|
|
387
|
+
look,
|
|
388
|
+
level,
|
|
389
|
+
hp: maxHp,
|
|
390
|
+
maxHp,
|
|
391
|
+
atk: (ENEMY_BASE.atk + ENEMY_BASE.atkGrowth * level) * def.atk * (rank === 'elite' ? 1.15 : 1) * Math.sqrt(threat),
|
|
392
|
+
def: (ENEMY_BASE.def + ENEMY_BASE.defGrowth * level) * def.def,
|
|
393
|
+
spd: def.spd,
|
|
394
|
+
crit: def.crit,
|
|
395
|
+
evade: def.evade,
|
|
396
|
+
gauge: 0,
|
|
397
|
+
basic: def.basic,
|
|
398
|
+
skills: special === undefined ? [] : [special],
|
|
399
|
+
cooldowns: special === undefined ? {} : { [special.id]: 1 },
|
|
400
|
+
effects: [],
|
|
401
|
+
regen: 0,
|
|
402
|
+
rank,
|
|
403
|
+
phase: 1,
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const living = (list: Fighter[]) => list.filter(one => one.hp > 0)
|
|
408
|
+
/** Rounds fought so far: actions taken over the fighters in the fight. */
|
|
409
|
+
const rounds = (w: World) => Math.floor(w.turns / Math.max(1, w.fighters.length))
|
|
410
|
+
const sideOf = (w: World, side: Fighter['side']) => w.fighters.filter(one => one.side === side)
|
|
411
|
+
const effectPower = (f: Fighter, kind: Effect['kind']) => f.effects.filter(one => one.kind === kind).reduce((top, one) => Math.max(top, one.power), 0)
|
|
412
|
+
const hasEffect = (f: Fighter, kind: Effect['kind']) => f.effects.some(one => one.kind === kind)
|
|
413
|
+
const share = (f: Fighter) => f.hp / f.maxHp
|
|
414
|
+
|
|
415
|
+
/** Puts an effect on a fighter, replacing a weaker or shorter one of its kind. */
|
|
416
|
+
function addEffect(f: Fighter, effect: Effect): void {
|
|
417
|
+
f.effects = [...f.effects.filter(one => one.kind !== effect.kind), effect]
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
421
|
+
// Runs and stops
|
|
422
|
+
|
|
423
|
+
/** Generates a new run: a few fights, a fork, a few more, a fork, a camp and the boss. */
|
|
424
|
+
function newRun(w: World): void {
|
|
425
|
+
const region = pick(w, REGION_IDS)
|
|
426
|
+
const path: StopKind[] = []
|
|
427
|
+
for (let segment = 0; segment < 2; segment++) {
|
|
428
|
+
const fights = roll(w) < 0.4 ? 3 : 2
|
|
429
|
+
for (let i = 0; i < fights; i++) path.push('fight')
|
|
430
|
+
path.push('fork', 'fight')
|
|
431
|
+
}
|
|
432
|
+
path.push('fight', 'camp', 'boss')
|
|
433
|
+
w.runs += 1
|
|
434
|
+
w.run = { number: w.runs, region, path, at: 0, fought: 0, relics: [], potions: 1, hp: {}, isFeatherUsed: false, isDaring: false }
|
|
435
|
+
for (const id of partyIds(w)) {
|
|
436
|
+
const hero = heroById(w, id)
|
|
437
|
+
if (hero !== undefined) hero.record.runs += 1
|
|
438
|
+
}
|
|
439
|
+
log(w, `Run ${w.runs}: the party sets out for the ${REGIONS[region].name}.`, 'info')
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Average level of the party's heroes, 1 when empty. */
|
|
443
|
+
function partyLevel(w: World): number {
|
|
444
|
+
const levels = partyIds(w).map(id => heroById(w, id)?.level ?? 1)
|
|
445
|
+
return levels.length === 0 ? 1 : levels.reduce((a, b) => a + b, 0) / levels.length
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Starts the fight at the current stop, scaled by the party's level and size and how deep the run is. */
|
|
449
|
+
function startFight(w: World, kind: 'fight' | 'elite' | 'boss'): void {
|
|
450
|
+
const run = w.run
|
|
451
|
+
if (run === null) return
|
|
452
|
+
const region = REGIONS[run.region]
|
|
453
|
+
const party = partyIds(w)
|
|
454
|
+
const n = Math.max(1, party.length)
|
|
455
|
+
const tier = Math.max(1, Math.round(partyLevel(w) - 1 + Math.floor(run.fought / 3)))
|
|
456
|
+
const enemies: Fighter[] = []
|
|
457
|
+
const add = (look: string, def: EnemyDef, level: number, rank: Fighter['rank'], hpScale = 1) =>
|
|
458
|
+
enemies.push(enemyFighter(`e${w.seq++}`, look, def, level, rank, rank === 'normal' ? w.threat : Math.sqrt(w.threat), hpScale))
|
|
459
|
+
|
|
460
|
+
if (kind === 'boss') {
|
|
461
|
+
const boss = BOSSES[region.boss]
|
|
462
|
+
if (boss !== undefined) add(region.boss, boss, tier + 1, 'boss', 2.6 + 1.8 * (n - 1))
|
|
463
|
+
for (let i = 0; i < Math.floor(n / 2); i++) {
|
|
464
|
+
const look = pick(w, region.enemies)
|
|
465
|
+
add(look, ENEMIES[look] as EnemyDef, tier, 'normal')
|
|
466
|
+
}
|
|
467
|
+
} else if (kind === 'elite') {
|
|
468
|
+
const look = pick(w, region.elites)
|
|
469
|
+
add(look, ENEMIES[look] as EnemyDef, tier + 1, 'elite', 2)
|
|
470
|
+
for (let i = 0; i < n - 1; i++) {
|
|
471
|
+
const other = pick(w, region.enemies)
|
|
472
|
+
add(other, ENEMIES[other] as EnemyDef, tier, 'normal')
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
const count = clamp(n + (roll(w) < (n === 1 ? 0.3 : 0.5) ? 1 : 0), 1, 6)
|
|
476
|
+
for (let i = 0; i < count; i++) {
|
|
477
|
+
const look = pick(w, region.enemies)
|
|
478
|
+
add(look, ENEMIES[look] as EnemyDef, tier, 'normal')
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const heroes = party.map(id => heroById(w, id)).filter((hero): hero is Hero => hero !== undefined).map(hero => heroFighter(hero, run))
|
|
483
|
+
w.fighters = [...heroes, ...enemies]
|
|
484
|
+
w.turns = 0
|
|
485
|
+
w.scene = { kind: 'fight' }
|
|
486
|
+
run.fought += 1
|
|
487
|
+
const names = enemies.map(one => one.name)
|
|
488
|
+
const lead = enemies[0]
|
|
489
|
+
if (kind === 'boss' && lead !== undefined) log(w, `${lead.name} blocks the way!`, 'bad')
|
|
490
|
+
else log(w, `${names.length === 1 ? 'A' : names.length} ${names.length === 1 ? names[0] : 'foes'} ${names.length === 1 ? 'appears' : 'appear'}: ${[...new Set(names)].join(', ')}.`)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Enters the stop the run is at. */
|
|
494
|
+
function enterStop(w: World, asks: Ask[]): void {
|
|
495
|
+
const run = w.run
|
|
496
|
+
if (run === null) return
|
|
497
|
+
const stop = run.path[run.at]
|
|
498
|
+
if (stop === 'fight' || stop === 'elite' || stop === 'boss') {
|
|
499
|
+
startFight(w, stop)
|
|
500
|
+
} else if (stop === 'fork') {
|
|
501
|
+
const id = w.seq++
|
|
502
|
+
w.scene = { kind: 'fork', wait: FORK_WAIT, id }
|
|
503
|
+
log(w, 'The path forks: a quiet trail, or a dark one with treasure behind it.')
|
|
504
|
+
asks.push({ kind: 'route', forkId: id })
|
|
505
|
+
} else if (stop === 'camp') {
|
|
506
|
+
for (const id of partyIds(w)) {
|
|
507
|
+
const hero = heroById(w, id)
|
|
508
|
+
if (hero === undefined) continue
|
|
509
|
+
const max = heroFighter(hero, { ...run, hp: {} }).maxHp
|
|
510
|
+
run.hp[id] = Math.min(max, (run.hp[id] ?? max) + Math.round(max * 0.6))
|
|
511
|
+
}
|
|
512
|
+
run.potions += 1
|
|
513
|
+
w.scene = { kind: 'camp', wait: 5 }
|
|
514
|
+
log(w, 'The party makes camp before the boss. Wounds mended, +1 potion.', 'loot')
|
|
515
|
+
} else {
|
|
516
|
+
w.scene = { kind: 'cleared', wait: 6 }
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Moves to the next stop, or ends the run after the last. */
|
|
521
|
+
function advance(w: World): void {
|
|
522
|
+
const run = w.run
|
|
523
|
+
if (run === null) return
|
|
524
|
+
run.at += 1
|
|
525
|
+
if (run.at >= run.path.length) {
|
|
526
|
+
w.scene = { kind: 'cleared', wait: 6 }
|
|
527
|
+
for (const id of partyIds(w)) {
|
|
528
|
+
const hero = heroById(w, id)
|
|
529
|
+
if (hero !== undefined) hero.record.wins += 1
|
|
530
|
+
}
|
|
531
|
+
log(w, `Run ${run.number} cleared! The party heads home with its trophies.`, 'good')
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
w.scene = { kind: 'travel', wait: 2 }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Share of its max HP a hero carries into the run's next fight; 1 outside a run. */
|
|
538
|
+
export function hpShare(w: World, heroId: string): number {
|
|
539
|
+
const hero = heroById(w, heroId)
|
|
540
|
+
if (w.run === null || hero === undefined) return 1
|
|
541
|
+
const max = heroFighter(hero, { ...w.run, hp: {} }).maxHp
|
|
542
|
+
return (w.run.hp[heroId] ?? max) / max
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** The route the party takes when nobody picked one: daring when it is in good shape. */
|
|
546
|
+
export function defaultRoute(w: World): 'safe' | 'danger' {
|
|
547
|
+
if (w.run === null) return 'safe'
|
|
548
|
+
const shares = partyIds(w).map(id => hpShare(w, id))
|
|
549
|
+
const average = shares.length === 0 ? 1 : shares.reduce((a, b) => a + b, 0) / shares.length
|
|
550
|
+
return average >= 0.75 ? 'danger' : 'safe'
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Sets the stop after a fork by the route taken. */
|
|
554
|
+
function takeRoute(w: World, route: 'safe' | 'danger'): void {
|
|
555
|
+
const run = w.run
|
|
556
|
+
if (run === null) return
|
|
557
|
+
run.path[run.at + 1] = route === 'danger' ? 'elite' : 'fight'
|
|
558
|
+
if (route === 'danger') {
|
|
559
|
+
run.isDaring = true
|
|
560
|
+
log(w, 'The party takes the dark path. Double XP and a relic await… if they survive.', 'loot')
|
|
561
|
+
} else {
|
|
562
|
+
run.potions += 1
|
|
563
|
+
log(w, 'The party takes the quiet trail and finds a potion on the way.', 'loot')
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
568
|
+
// Fighting
|
|
569
|
+
|
|
570
|
+
/** Gives a hero XP, levelling it up as often as it fills, and offers a skill at each new tenth level. */
|
|
571
|
+
function grantXp(w: World, hero: Hero, xp: number, asks: Ask[]): void {
|
|
572
|
+
hero.xp += xp
|
|
573
|
+
while (hero.xp >= xpToNext(hero.level)) {
|
|
574
|
+
hero.xp -= xpToNext(hero.level)
|
|
575
|
+
hero.level += 1
|
|
576
|
+
fx(w, { kind: 'level', at: hero.id, text: `Level ${hero.level}!` })
|
|
577
|
+
if (hero.level > hero.bestLevel) {
|
|
578
|
+
hero.bestLevel = hero.level
|
|
579
|
+
if (hero.level % 10 === 0) offerSkills(w, hero, asks)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Offers a hero two skills of its class it does not know yet. */
|
|
585
|
+
function offerSkills(w: World, hero: Hero, asks: Ask[]): void {
|
|
586
|
+
if (hero.choice !== undefined) return
|
|
587
|
+
const open = CLASSES[hero.cls].pool.filter(one => !hero.skills.includes(one.id))
|
|
588
|
+
if (open.length < 2) return
|
|
589
|
+
const first = pick(w, open)
|
|
590
|
+
const second = pick(w, open.filter(one => one !== first))
|
|
591
|
+
hero.choice = [first.id, second.id]
|
|
592
|
+
log(w, `${hero.name} reached level ${hero.level} and may learn ${first.name} or ${second.name}.`, 'level')
|
|
593
|
+
asks.push({ kind: 'skill', heroId: hero.id })
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Adds heroes who joined mid-fight and takes out those whose agents left, keeping their HP for the run. */
|
|
597
|
+
function reconcile(w: World): void {
|
|
598
|
+
const run = w.run
|
|
599
|
+
const party = partyIds(w)
|
|
600
|
+
for (const f of sideOf(w, 'hero')) {
|
|
601
|
+
if (!party.includes(f.id)) {
|
|
602
|
+
if (run !== null) run.hp[f.id] = f.hp
|
|
603
|
+
log(w, `${f.name} heads back to the tavern.`)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
w.fighters = w.fighters.filter(f => f.side === 'enemy' || party.includes(f.id))
|
|
607
|
+
for (const id of party) {
|
|
608
|
+
if (w.fighters.some(f => f.id === id)) continue
|
|
609
|
+
const hero = heroById(w, id)
|
|
610
|
+
if (hero === undefined) continue
|
|
611
|
+
w.fighters.splice(sideOf(w, 'hero').length, 0, heroFighter(hero, run))
|
|
612
|
+
log(w, `${hero.name} rushes into the fight!`)
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Picks the fighter who acts next: everyone gains speed until one holds the most gauge at `READY` or above. */
|
|
617
|
+
function nextActor(w: World): Fighter | undefined {
|
|
618
|
+
const alive = living(w.fighters)
|
|
619
|
+
if (alive.length === 0) return undefined
|
|
620
|
+
for (let guard = 0; guard < 100; guard++) {
|
|
621
|
+
const top = alive.reduce((best, f) => (f.gauge > best.gauge ? f : best), alive[0] as Fighter)
|
|
622
|
+
if (top.gauge >= READY) {
|
|
623
|
+
top.gauge -= READY
|
|
624
|
+
return top
|
|
625
|
+
}
|
|
626
|
+
for (const f of alive) f.gauge += f.spd
|
|
627
|
+
}
|
|
628
|
+
return alive[0]
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Applies damage to `target`: a miss on evasion, a crit by chance, softened by defense and shields. Returns it. */
|
|
632
|
+
function strikeOnce(w: World, actor: Fighter, target: Fighter, power: number): number {
|
|
633
|
+
if (roll(w) < target.evade) {
|
|
634
|
+
fx(w, { kind: 'miss', at: target.id })
|
|
635
|
+
return 0
|
|
636
|
+
}
|
|
637
|
+
const fatigue = Math.max(0, rounds(w) - FATIGUE_AFTER) * 0.08
|
|
638
|
+
const atk = actor.atk * (1 + effectPower(actor, 'rally')) * (1 + effectPower(actor, 'enrage')) * (1 + fatigue)
|
|
639
|
+
let raw = atk * power * (0.85 + 0.3 * roll(w))
|
|
640
|
+
const isCrit = roll(w) < actor.crit
|
|
641
|
+
if (isCrit) raw *= 1.75
|
|
642
|
+
// Defense is weighed against the attacker's level, so a matchup plays the same at level 5 and level 50.
|
|
643
|
+
const pierce = 10 + 3 * actor.level
|
|
644
|
+
const amount = Math.max(1, Math.round(raw * (pierce / (pierce + target.def)) * (1 - effectPower(target, 'shield'))))
|
|
645
|
+
hurt(w, target, amount, actor, isCrit)
|
|
646
|
+
return amount
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Takes HP from a fighter, downs it at 0, and moves a boss to its second phase below half. */
|
|
650
|
+
function hurt(w: World, target: Fighter, amount: number, by: Fighter | undefined, isCrit = false): void {
|
|
651
|
+
target.hp = Math.max(0, target.hp - amount)
|
|
652
|
+
fx(w, { kind: 'hit', at: target.id, amount, isCrit })
|
|
653
|
+
if (target.hp === 0) {
|
|
654
|
+
target.effects = []
|
|
655
|
+
fx(w, { kind: 'down', at: target.id })
|
|
656
|
+
log(w, target.side === 'hero' ? `${target.name} is knocked out!` : `${target.name} is defeated.`, target.side === 'hero' ? 'bad' : 'good')
|
|
657
|
+
if (by?.side === 'hero' && target.side === 'enemy') {
|
|
658
|
+
const hero = heroById(w, by.id)
|
|
659
|
+
if (hero !== undefined) hero.record.kills += 1
|
|
660
|
+
}
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
if (target.rank === 'boss' && target.phase === 1 && target.hp < target.maxHp / 2) {
|
|
664
|
+
target.phase = 2
|
|
665
|
+
const boss = Object.values(BOSSES).find(one => one.name === target.name)
|
|
666
|
+
if (boss !== undefined) {
|
|
667
|
+
addEffect(target, { kind: 'enrage', turns: 999, power: boss.phase2.atkPct })
|
|
668
|
+
target.skills = [boss.phase2.special]
|
|
669
|
+
target.cooldowns = { [boss.phase2.special.id]: 0 }
|
|
670
|
+
fx(w, { kind: 'phase', at: target.id, text: 'Phase 2' })
|
|
671
|
+
log(w, boss.phase2.line, 'bad')
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function heal(w: World, target: Fighter, amount: number): number {
|
|
677
|
+
const healed = Math.min(target.maxHp - target.hp, Math.round(amount))
|
|
678
|
+
if (healed > 0) {
|
|
679
|
+
target.hp += healed
|
|
680
|
+
fx(w, { kind: 'heal', at: target.id, amount: healed })
|
|
681
|
+
}
|
|
682
|
+
return healed
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The enemy a hero aims a single-target skill at: the weakest, to finish it off. */
|
|
686
|
+
function focus(list: Fighter[]): Fighter | undefined {
|
|
687
|
+
return living(list).sort((a, b) => a.hp - b.hp)[0]
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** The hero an enemy attacks: a taunting one if any, else a random one. */
|
|
691
|
+
function enemyTarget(w: World, heroes: Fighter[]): Fighter | undefined {
|
|
692
|
+
const alive = living(heroes)
|
|
693
|
+
const taunting = alive.filter(f => hasEffect(f, 'taunt'))
|
|
694
|
+
if (taunting.length > 0) return pick(w, taunting)
|
|
695
|
+
return alive.length === 0 ? undefined : pick(w, alive)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Tells whether using `skill` now helps, so the fighter keeps it for when it does. */
|
|
699
|
+
function isUseful(skill: SkillDef, actor: Fighter, allies: Fighter[], foes: Fighter[]): boolean {
|
|
700
|
+
const alive = living(allies)
|
|
701
|
+
const hurt = alive.filter(f => share(f) < 0.55)
|
|
702
|
+
switch (skill.kind) {
|
|
703
|
+
case 'revive': return allies.some(f => f.hp === 0)
|
|
704
|
+
case 'heal': return hurt.length > 0
|
|
705
|
+
case 'healAll': return alive.filter(f => share(f) < 0.7).length >= 2 || hurt.length > 0
|
|
706
|
+
case 'healSelf': return share(actor) < 0.45
|
|
707
|
+
case 'shield': return !alive.some(f => hasEffect(f, 'shield')) && alive.some(f => share(f) < 0.8)
|
|
708
|
+
case 'taunt': return !hasEffect(actor, 'taunt')
|
|
709
|
+
case 'rally': return !alive.some(f => hasEffect(f, 'rally'))
|
|
710
|
+
case 'cleave': return living(foes).length >= 2
|
|
711
|
+
case 'burn': return living(foes).some(f => !hasEffect(f, 'burn'))
|
|
712
|
+
default: return living(foes).length > 0
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Uses a skill or basic action. Returns a short phrase of what happened for the log. */
|
|
717
|
+
function use(w: World, actor: Fighter, skill: SkillDef, allies: Fighter[], foes: Fighter[]): string {
|
|
718
|
+
fx(w, { kind: 'act', at: actor.id, from: actor.id, text: skill.cd > 0 ? skill.name : undefined })
|
|
719
|
+
const target = actor.side === 'hero' ? focus(foes) : enemyTarget(w, foes)
|
|
720
|
+
switch (skill.kind) {
|
|
721
|
+
case 'strike': {
|
|
722
|
+
if (target === undefined) return ''
|
|
723
|
+
const dealt = strikeOnce(w, actor, target, skill.power)
|
|
724
|
+
return dealt === 0 ? `misses ${target.name}` : `hits ${target.name} for ${dealt}`
|
|
725
|
+
}
|
|
726
|
+
case 'multi': {
|
|
727
|
+
let total = 0
|
|
728
|
+
for (let i = 0; i < (skill.hits ?? 2); i++) {
|
|
729
|
+
const alive = living(foes)
|
|
730
|
+
if (alive.length === 0) break
|
|
731
|
+
total += strikeOnce(w, actor, pick(w, alive), skill.power)
|
|
732
|
+
}
|
|
733
|
+
return `strikes ${skill.hits ?? 2} times for ${total}`
|
|
734
|
+
}
|
|
735
|
+
case 'cleave': {
|
|
736
|
+
const total = living(foes).reduce((sum, foe) => sum + strikeOnce(w, actor, foe, skill.power), 0)
|
|
737
|
+
return `hits every foe for ${total} in all`
|
|
738
|
+
}
|
|
739
|
+
case 'stun': {
|
|
740
|
+
if (target === undefined) return ''
|
|
741
|
+
const dealt = strikeOnce(w, actor, target, skill.power)
|
|
742
|
+
if (target.hp > 0 && dealt > 0) {
|
|
743
|
+
const resists = target.rank === 'boss' || (target.rank === 'elite' && roll(w) < 0.5)
|
|
744
|
+
if (!resists) {
|
|
745
|
+
addEffect(target, { kind: 'stun', turns: 1, power: 0 })
|
|
746
|
+
fx(w, { kind: 'buff', at: target.id, text: 'Stunned' })
|
|
747
|
+
return `stuns ${target.name}`
|
|
748
|
+
}
|
|
749
|
+
return `hits ${target.name}, who shrugs off the stun`
|
|
750
|
+
}
|
|
751
|
+
return `hits ${target.name} for ${dealt}`
|
|
752
|
+
}
|
|
753
|
+
case 'burn': {
|
|
754
|
+
const fresh = living(foes).find(f => !hasEffect(f, 'burn')) ?? target
|
|
755
|
+
if (fresh === undefined) return ''
|
|
756
|
+
strikeOnce(w, actor, fresh, 0.6)
|
|
757
|
+
if (fresh.hp > 0) addEffect(fresh, { kind: 'burn', turns: skill.turns ?? 3, power: Math.max(1, Math.round(actor.atk * skill.power)) })
|
|
758
|
+
return `sets ${fresh.name} burning`
|
|
759
|
+
}
|
|
760
|
+
case 'drain': {
|
|
761
|
+
if (target === undefined) return ''
|
|
762
|
+
const dealt = strikeOnce(w, actor, target, skill.power)
|
|
763
|
+
heal(w, actor, dealt / 2)
|
|
764
|
+
return `drains ${dealt} from ${target.name}`
|
|
765
|
+
}
|
|
766
|
+
case 'mend': {
|
|
767
|
+
// Alone, a healer mends only when it must; with allies it keeps everyone topped up.
|
|
768
|
+
const below = living(allies).length > 1 ? 0.7 : 0.45
|
|
769
|
+
const hurt = living(allies).filter(f => share(f) < below).sort((a, b) => share(a) - share(b))[0]
|
|
770
|
+
if (hurt !== undefined) return `mends ${hurt.name} for ${heal(w, hurt, hurt.maxHp * skill.power)}`
|
|
771
|
+
if (target === undefined) return ''
|
|
772
|
+
const dealt = strikeOnce(w, actor, target, 1.1)
|
|
773
|
+
return `smites ${target.name} for ${dealt}`
|
|
774
|
+
}
|
|
775
|
+
case 'heal': {
|
|
776
|
+
const hurt = living(allies).sort((a, b) => share(a) - share(b))[0]
|
|
777
|
+
return hurt === undefined ? '' : `heals ${hurt.name} for ${heal(w, hurt, hurt.maxHp * skill.power)}`
|
|
778
|
+
}
|
|
779
|
+
case 'healSelf': return `recovers ${heal(w, actor, actor.maxHp * skill.power)} HP`
|
|
780
|
+
case 'healAll': {
|
|
781
|
+
const total = living(allies).reduce((sum, f) => sum + heal(w, f, f.maxHp * skill.power), 0)
|
|
782
|
+
return `heals the party for ${total}`
|
|
783
|
+
}
|
|
784
|
+
case 'revive': {
|
|
785
|
+
const fallen = allies.find(f => f.hp === 0)
|
|
786
|
+
if (fallen === undefined) return ''
|
|
787
|
+
fallen.hp = Math.max(1, Math.round(fallen.maxHp * skill.power))
|
|
788
|
+
fx(w, { kind: 'heal', at: fallen.id, amount: fallen.hp })
|
|
789
|
+
return `raises ${fallen.name}!`
|
|
790
|
+
}
|
|
791
|
+
case 'shield': {
|
|
792
|
+
for (const f of living(allies)) {
|
|
793
|
+
addEffect(f, { kind: 'shield', turns: skill.turns ?? 2, power: skill.power })
|
|
794
|
+
fx(w, { kind: 'buff', at: f.id, text: 'Shielded' })
|
|
795
|
+
}
|
|
796
|
+
return 'shields the party'
|
|
797
|
+
}
|
|
798
|
+
case 'taunt': {
|
|
799
|
+
addEffect(actor, { kind: 'taunt', turns: skill.turns ?? 3, power: 0 })
|
|
800
|
+
addEffect(actor, { kind: 'shield', turns: skill.turns ?? 3, power: skill.power })
|
|
801
|
+
fx(w, { kind: 'buff', at: actor.id, text: 'Taunting' })
|
|
802
|
+
return 'draws every attack'
|
|
803
|
+
}
|
|
804
|
+
case 'rally': {
|
|
805
|
+
for (const f of living(allies)) addEffect(f, { kind: 'rally', turns: skill.turns ?? 3, power: skill.power })
|
|
806
|
+
fx(w, { kind: 'buff', at: actor.id, text: skill.name })
|
|
807
|
+
return 'rallies the party'
|
|
808
|
+
}
|
|
809
|
+
case 'passive': return ''
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Runs one fighter's turn: its effects, a potion when a hero is low, then its best useful skill or its basic action.
|
|
815
|
+
*/
|
|
816
|
+
function takeTurn(w: World, actor: Fighter): void {
|
|
817
|
+
const run = w.run
|
|
818
|
+
for (const id of Object.keys(actor.cooldowns)) actor.cooldowns[id] = Math.max(0, (actor.cooldowns[id] ?? 0) - 1)
|
|
819
|
+
if (actor.regen > 0) heal(w, actor, actor.maxHp * actor.regen)
|
|
820
|
+
const burn = actor.effects.find(one => one.kind === 'burn')
|
|
821
|
+
if (burn !== undefined) hurt(w, actor, burn.power, undefined)
|
|
822
|
+
const isStunned = hasEffect(actor, 'stun')
|
|
823
|
+
actor.effects = actor.effects.map(one => ({ ...one, turns: one.turns - 1 })).filter(one => one.turns > 0)
|
|
824
|
+
if (actor.hp === 0) return
|
|
825
|
+
if (isStunned) {
|
|
826
|
+
log(w, `${actor.name} is stunned and loses a turn.`)
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
const allies = sideOf(w, actor.side)
|
|
830
|
+
const foes = sideOf(w, actor.side === 'hero' ? 'enemy' : 'hero')
|
|
831
|
+
|
|
832
|
+
if (actor.side === 'hero' && run !== null && run.potions > 0 && share(actor) < 0.3) {
|
|
833
|
+
run.potions -= 1
|
|
834
|
+
heal(w, actor, actor.maxHp * 0.45)
|
|
835
|
+
log(w, `${actor.name} drinks a potion.`, 'loot')
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const ready = actor.skills.find(one => (actor.cooldowns[one.id] ?? 0) === 0 && isUseful(one, actor, allies, foes))
|
|
839
|
+
const chosen = ready ?? actor.basic
|
|
840
|
+
if (ready !== undefined) actor.cooldowns[ready.id] = ready.cd + 1
|
|
841
|
+
const what = use(w, actor, chosen, allies, foes)
|
|
842
|
+
if (what !== '') log(w, `${actor.name} ${chosen.cd > 0 ? `uses ${chosen.name} and ` : ''}${what}.`, actor.side === 'hero' ? 'info' : 'bad')
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** XP each hero gets for a won fight: more for stronger and more foes, split by party size, doubled when daring. */
|
|
846
|
+
function fightXp(w: World, enemies: Fighter[]): number {
|
|
847
|
+
const level = Math.min(...enemies.map(f => f.level))
|
|
848
|
+
const rank = enemies.some(f => f.rank === 'boss') ? 4 : enemies.some(f => f.rank === 'elite') ? 2 : 1
|
|
849
|
+
return Math.round((3 + level) * rank * (w.run?.isDaring === true ? 2 : 1))
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Moves the threat by how the fight went: up when the party ended it quickly with most of its HP, down when it barely
|
|
854
|
+
* made it or the fight dragged on. A wipe lowers it more, in `loseFight`.
|
|
855
|
+
*/
|
|
856
|
+
function adapt(w: World, heroes: Fighter[]): void {
|
|
857
|
+
const left = heroes.reduce((sum, f) => sum + share(f), 0) / Math.max(1, heroes.length)
|
|
858
|
+
const long = rounds(w)
|
|
859
|
+
const before = w.threat
|
|
860
|
+
if (left > 0.8 && long < 8) w.threat = Math.min(THREAT_MAX, w.threat * 1.03)
|
|
861
|
+
else if (left < 0.45 || long > 10) w.threat = Math.max(THREAT_MIN, w.threat * 0.93)
|
|
862
|
+
if (Math.floor(before * 10) !== Math.floor(w.threat * 10)) log(w, w.threat > before ? 'The dungeon stirs; its monsters grow bolder.' : 'The monsters grow wary.')
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** Ends a won fight: XP, loot, trophies, and HP carried to the next stop. */
|
|
866
|
+
function winFight(w: World, asks: Ask[]): void {
|
|
867
|
+
const run = w.run
|
|
868
|
+
if (run === null) return
|
|
869
|
+
const heroes = sideOf(w, 'hero')
|
|
870
|
+
const enemies = sideOf(w, 'enemy')
|
|
871
|
+
const xp = fightXp(w, enemies)
|
|
872
|
+
adapt(w, heroes)
|
|
873
|
+
const boss = enemies.find(f => f.rank === 'boss')
|
|
874
|
+
const elite = enemies.some(f => f.rank === 'elite')
|
|
875
|
+
for (const f of heroes) {
|
|
876
|
+
run.hp[f.id] = f.hp === 0 ? Math.round(f.maxHp * 0.3) : Math.min(f.maxHp, f.hp + Math.round(f.maxHp * 0.3))
|
|
877
|
+
const hero = heroById(w, f.id)
|
|
878
|
+
if (hero === undefined) continue
|
|
879
|
+
grantXp(w, hero, xp, asks)
|
|
880
|
+
if (boss !== undefined && !hero.trophies.includes(boss.name)) hero.trophies.push(boss.name)
|
|
881
|
+
}
|
|
882
|
+
log(w, `Victory! +${xp} XP each.`, 'good')
|
|
883
|
+
if (boss !== undefined) log(w, `${boss.name} falls. A trophy for the tavern wall!`, 'loot')
|
|
884
|
+
if (elite) {
|
|
885
|
+
const open = Object.keys(RELICS).filter(id => !run.relics.includes(id))
|
|
886
|
+
if (open.length > 0) {
|
|
887
|
+
const found = pick(w, open)
|
|
888
|
+
run.relics.push(found)
|
|
889
|
+
log(w, `Found a relic: ${RELICS[found]?.icon} ${RELICS[found]?.name} (${RELICS[found]?.blurb}).`, 'loot')
|
|
890
|
+
}
|
|
891
|
+
} else if (boss === undefined && roll(w) < 0.3) {
|
|
892
|
+
run.potions += 1
|
|
893
|
+
log(w, 'Found a potion.', 'loot')
|
|
894
|
+
}
|
|
895
|
+
run.isDaring = false
|
|
896
|
+
w.fighters = []
|
|
897
|
+
w.scene = { kind: 'victory', wait: 3 }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Ends a lost fight: a feather raises the party once a run; otherwise everyone loses `WIPE_PENALTY` levels. */
|
|
901
|
+
function loseFight(w: World): void {
|
|
902
|
+
const run = w.run
|
|
903
|
+
const heroes = sideOf(w, 'hero')
|
|
904
|
+
if (run !== null && run.relics.includes('feather') && !run.isFeatherUsed) {
|
|
905
|
+
run.isFeatherUsed = true
|
|
906
|
+
for (const f of heroes) {
|
|
907
|
+
f.hp = Math.round(f.maxHp * 0.4)
|
|
908
|
+
fx(w, { kind: 'heal', at: f.id, amount: f.hp })
|
|
909
|
+
}
|
|
910
|
+
log(w, 'The Phoenix Feather bursts into flame and the party rises again!', 'loot')
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
for (const f of heroes) {
|
|
914
|
+
const hero = heroById(w, f.id)
|
|
915
|
+
if (hero === undefined) continue
|
|
916
|
+
hero.level = Math.max(1, hero.level - WIPE_PENALTY)
|
|
917
|
+
hero.xp = 0
|
|
918
|
+
hero.record.wipes += 1
|
|
919
|
+
}
|
|
920
|
+
w.threat = Math.max(THREAT_MIN, w.threat * 0.7)
|
|
921
|
+
log(w, `The party is defeated. Everyone loses ${WIPE_PENALTY} levels and wakes up in the tavern.`, 'bad')
|
|
922
|
+
w.fighters = []
|
|
923
|
+
w.scene = { kind: 'wipe', wait: 6 }
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Runs one action of the fight in progress, and ends the fight when one side is down. */
|
|
927
|
+
function fightStep(w: World, asks: Ask[]): void {
|
|
928
|
+
reconcile(w)
|
|
929
|
+
// A crowded fight takes several actions a tick, so it moves about as fast as a small one.
|
|
930
|
+
const actions = Math.max(1, Math.ceil(w.fighters.length / ACTORS_PER_ACTION))
|
|
931
|
+
for (let i = 0; i < actions; i++) {
|
|
932
|
+
const actor = nextActor(w)
|
|
933
|
+
const before = rounds(w)
|
|
934
|
+
w.turns += 1
|
|
935
|
+
if (before < FATIGUE_AFTER && rounds(w) >= FATIGUE_AFTER) log(w, 'Everyone is tiring; blows land harder now.')
|
|
936
|
+
if (actor !== undefined) takeTurn(w, actor)
|
|
937
|
+
if (living(sideOf(w, 'enemy')).length === 0) return winFight(w, asks)
|
|
938
|
+
if (living(sideOf(w, 'hero')).length === 0) return loseFight(w)
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
943
|
+
// The tick
|
|
944
|
+
|
|
945
|
+
/** Sends the party home: the fight in progress is dropped and every hero rests to full. */
|
|
946
|
+
function goHome(w: World): void {
|
|
947
|
+
if (w.scene.kind === 'wipe' || w.scene.kind === 'cleared') w.run = null
|
|
948
|
+
// A won fight or a camp already happened: the party comes back to the stop after it.
|
|
949
|
+
if (w.run !== null && (w.scene.kind === 'victory' || w.scene.kind === 'camp')) {
|
|
950
|
+
w.run.at += 1
|
|
951
|
+
if (w.run.at >= w.run.path.length) w.run = null
|
|
952
|
+
}
|
|
953
|
+
if (w.run !== null) w.run.hp = {}
|
|
954
|
+
w.fighters = []
|
|
955
|
+
w.scene = { kind: 'tavern' }
|
|
956
|
+
log(w, 'The party heads back to the tavern to rest.')
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** A hero in the tavern says something now and then. */
|
|
960
|
+
function chatter(w: World): void {
|
|
961
|
+
const heroes = w.heroes.filter(hero => !hero.isRecruit)
|
|
962
|
+
if (heroes.length === 0 || roll(w) > 0.35) return
|
|
963
|
+
const hero = pick(w, heroes)
|
|
964
|
+
const friends = Object.entries(hero.feelings).filter(([, v]) => v >= 1).map(([id]) => heroById(w, id)).filter(one => one !== undefined)
|
|
965
|
+
const rivals = Object.entries(hero.feelings).filter(([, v]) => v <= -1).map(([id]) => heroById(w, id)).filter(one => one !== undefined)
|
|
966
|
+
const r = roll(w)
|
|
967
|
+
let text = pick(w, TAVERN_LINES)
|
|
968
|
+
if (friends.length > 0 && r < 0.35) text = pick(w, FRIEND_LINES).replace('{friend}', pick(w, friends).name)
|
|
969
|
+
else if (rivals.length > 0 && r < 0.5) text = pick(w, RIVAL_LINES).replace('{friend}', pick(w, rivals).name)
|
|
970
|
+
else if (hero.motto !== undefined && r < 0.65) text = hero.motto
|
|
971
|
+
fx(w, { kind: 'say', at: hero.id, text })
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* @brief Advances the game by one tick: who is in the party, then one step of whatever the party is doing.
|
|
976
|
+
*
|
|
977
|
+
* With nobody in the party the heroes go to the tavern and chat. Otherwise they travel, fight one action at a time,
|
|
978
|
+
* wait at a fork, camp, and start a new run after a cleared run or a wipe.
|
|
979
|
+
*
|
|
980
|
+
* @param world - Current world.
|
|
981
|
+
* @returns The next world and the decisions it waits on.
|
|
982
|
+
*/
|
|
983
|
+
export function step(world: World): Outcome {
|
|
984
|
+
const w = copy(world)
|
|
985
|
+
const asks: Ask[] = []
|
|
986
|
+
w.tick += 1
|
|
987
|
+
w.bindings = w.bindings.filter(binding => binding.leftAt === undefined || w.tick - binding.leftAt <= LINGER_TICKS)
|
|
988
|
+
const party = partyIds(w)
|
|
989
|
+
|
|
990
|
+
if (party.length === 0) {
|
|
991
|
+
if (w.scene.kind !== 'tavern') goHome(w)
|
|
992
|
+
else chatter(w)
|
|
993
|
+
return { world: w, asks }
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const scene = w.scene
|
|
997
|
+
switch (scene.kind) {
|
|
998
|
+
case 'tavern':
|
|
999
|
+
if (w.run === null) newRun(w)
|
|
1000
|
+
else log(w, `The party heads back into the ${REGIONS[w.run.region].name}.`)
|
|
1001
|
+
w.scene = { kind: 'travel', wait: 3 }
|
|
1002
|
+
break
|
|
1003
|
+
case 'travel':
|
|
1004
|
+
if (--scene.wait <= 0) enterStop(w, asks)
|
|
1005
|
+
break
|
|
1006
|
+
case 'fight':
|
|
1007
|
+
fightStep(w, asks)
|
|
1008
|
+
break
|
|
1009
|
+
case 'victory':
|
|
1010
|
+
case 'camp':
|
|
1011
|
+
if (--scene.wait <= 0) advance(w)
|
|
1012
|
+
break
|
|
1013
|
+
case 'fork':
|
|
1014
|
+
if (scene.route !== undefined) scene.wait = Math.min(scene.wait, 3)
|
|
1015
|
+
if (--scene.wait <= 0) {
|
|
1016
|
+
takeRoute(w, scene.route ?? defaultRoute(w))
|
|
1017
|
+
advance(w)
|
|
1018
|
+
}
|
|
1019
|
+
break
|
|
1020
|
+
case 'wipe':
|
|
1021
|
+
case 'cleared':
|
|
1022
|
+
if (--scene.wait <= 0) {
|
|
1023
|
+
w.run = null
|
|
1024
|
+
newRun(w)
|
|
1025
|
+
w.scene = { kind: 'travel', wait: 3 }
|
|
1026
|
+
}
|
|
1027
|
+
break
|
|
1028
|
+
}
|
|
1029
|
+
return { world: w, asks }
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
1033
|
+
// What the pane draws
|
|
1034
|
+
|
|
1035
|
+
const TAGS: Readonly<Record<Effect['kind'], string>> = {
|
|
1036
|
+
stun: 'stunned', shield: 'shielded', burn: 'burning', taunt: 'taunting', rally: 'rallied', enrage: 'enraged',
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** The pane's caption for the scene. */
|
|
1040
|
+
function caption(w: World): string {
|
|
1041
|
+
const region = w.run === null ? '' : REGIONS[w.run.region].name
|
|
1042
|
+
switch (w.scene.kind) {
|
|
1043
|
+
case 'tavern': return w.heroes.length === 0 ? 'The tavern is empty. Heroes sign up when agents start working.' : 'Resting at the tavern'
|
|
1044
|
+
case 'travel': return `Travelling through the ${region}…`
|
|
1045
|
+
case 'fight': return sideOf(w, 'enemy').some(f => f.rank === 'boss') ? 'Boss fight!' : `Fighting in the ${region}`
|
|
1046
|
+
case 'victory': return 'Victory!'
|
|
1047
|
+
case 'fork': return w.scene.route === undefined
|
|
1048
|
+
? 'The party argues about the route…'
|
|
1049
|
+
: `${w.scene.by ?? 'The party'} picks the ${w.scene.route === 'danger' ? 'dark path' : 'quiet trail'}`
|
|
1050
|
+
case 'camp': return 'Camping before the boss'
|
|
1051
|
+
case 'wipe': return 'Defeated… waking up in the tavern'
|
|
1052
|
+
case 'cleared': return 'Run cleared!'
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Turns the world into what the pane draws. */
|
|
1057
|
+
export function toView(w: World): ViewData {
|
|
1058
|
+
const run = w.run
|
|
1059
|
+
const active = new Set(partyIds(w))
|
|
1060
|
+
const fighting = w.fighters.length > 0
|
|
1061
|
+
const heroUnits: UnitView[] = fighting
|
|
1062
|
+
? sideOf(w, 'hero').map(f => {
|
|
1063
|
+
const hero = heroById(w, f.id)
|
|
1064
|
+
return { ...unit(f), title: CLASSES[f.look as ClassId]?.name ?? f.look, isActive: true, xpShare: hero === undefined ? 0 : hero.xp / xpToNext(hero.level) }
|
|
1065
|
+
})
|
|
1066
|
+
: w.heroes
|
|
1067
|
+
.filter(hero => !hero.isRecruit && (w.scene.kind === 'tavern' || active.has(hero.id)))
|
|
1068
|
+
.map(hero => {
|
|
1069
|
+
const f = heroFighter(hero, w.scene.kind === 'tavern' ? null : run)
|
|
1070
|
+
return {
|
|
1071
|
+
...unit(f),
|
|
1072
|
+
title: CLASSES[hero.cls].name,
|
|
1073
|
+
isActive: active.has(hero.id),
|
|
1074
|
+
xpShare: hero.xp / xpToNext(hero.level),
|
|
1075
|
+
skills: hero.skills.map(id => SKILLS[id]?.name ?? id),
|
|
1076
|
+
...(hero.motto === undefined ? {} : { motto: hero.motto }),
|
|
1077
|
+
trophies: hero.trophies.length,
|
|
1078
|
+
}
|
|
1079
|
+
})
|
|
1080
|
+
const recruits = w.heroes.filter(hero => hero.isRecruit).map(hero => ({
|
|
1081
|
+
id: hero.id, name: 'Recruit', look: 'recruit', side: 'hero' as const, level: 1, hp: 1, maxHp: 1, rank: 'normal' as const,
|
|
1082
|
+
isDown: false, tags: [], title: 'Signing up…', isActive: true,
|
|
1083
|
+
}))
|
|
1084
|
+
return {
|
|
1085
|
+
scene: w.scene.kind,
|
|
1086
|
+
region: run === null ? null : { id: run.region, name: REGIONS[run.region].name },
|
|
1087
|
+
runNumber: run?.number ?? w.runs,
|
|
1088
|
+
stops: run === null ? [] : run.path.map((kind, i) => ({ kind, state: i < run.at ? 'done' : i === run.at ? 'here' : 'ahead' })),
|
|
1089
|
+
heroes: [...heroUnits, ...recruits],
|
|
1090
|
+
enemies: sideOf(w, 'enemy').map(f => ({ ...unit(f), title: f.rank === 'boss' ? 'Boss' : f.rank === 'elite' ? 'Elite' : '' })),
|
|
1091
|
+
log: w.log.slice(-6),
|
|
1092
|
+
fx: w.fx,
|
|
1093
|
+
relics: (run?.relics ?? []).map(id => RELICS[id]).filter(one => one !== undefined).map(one => ({ name: one.name, icon: one.icon, blurb: one.blurb })),
|
|
1094
|
+
potions: run?.potions ?? 0,
|
|
1095
|
+
caption: caption(w),
|
|
1096
|
+
isCalm: w.isCalm,
|
|
1097
|
+
trophies: new Set(w.heroes.flatMap(hero => hero.trophies)).size,
|
|
1098
|
+
roster: w.heroes.filter(hero => !hero.isRecruit).sort((a, b) => b.level - a.level).map(hero => ({
|
|
1099
|
+
id: hero.id,
|
|
1100
|
+
name: hero.name,
|
|
1101
|
+
look: hero.cls,
|
|
1102
|
+
title: CLASSES[hero.cls].name,
|
|
1103
|
+
level: hero.level,
|
|
1104
|
+
xpShare: hero.xp / xpToNext(hero.level),
|
|
1105
|
+
skills: hero.skills.map(id => SKILLS[id]?.name ?? id),
|
|
1106
|
+
...(hero.motto === undefined ? {} : { motto: hero.motto }),
|
|
1107
|
+
trophies: hero.trophies.length,
|
|
1108
|
+
isActive: active.has(hero.id),
|
|
1109
|
+
record: [
|
|
1110
|
+
`${hero.record.runs} run${hero.record.runs === 1 ? '' : 's'}`,
|
|
1111
|
+
`${hero.record.wins} cleared`,
|
|
1112
|
+
`${hero.record.kills} foes`,
|
|
1113
|
+
hero.record.wipes > 0 ? `${hero.record.wipes} wipe${hero.record.wipes === 1 ? '' : 's'}` : '',
|
|
1114
|
+
].filter(Boolean).join(' · '),
|
|
1115
|
+
})),
|
|
1116
|
+
threat: Math.round(w.threat * 100),
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function unit(f: Fighter): Omit<UnitView, 'title'> {
|
|
1121
|
+
return {
|
|
1122
|
+
id: f.id, name: f.name, look: f.look, side: f.side, level: f.level, hp: f.hp, maxHp: f.maxHp, rank: f.rank,
|
|
1123
|
+
isDown: f.hp === 0, tags: [...new Set(f.effects.map(one => TAGS[one.kind]))],
|
|
1124
|
+
}
|
|
1125
|
+
}
|