Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ffa6db317 | ||
|
|
1fa1c8c070 | ||
|
|
802dadc7f3 | ||
|
|
d61cee55ed | ||
|
|
679900e7f5 | ||
|
|
ff64c6ddcc | ||
|
|
a6cca2810c | ||
|
|
f48e6130b8 | ||
|
|
61de17d63e | ||
|
|
5629041879 | ||
|
|
ed3f43c93f |
@@ -15,6 +15,9 @@
|
|||||||
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
|
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
|
||||||
- Focus state must always be visible and predictable when controller navigation is active.
|
- Focus state must always be visible and predictable when controller navigation is active.
|
||||||
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
|
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
|
||||||
|
- Controller navigation fixes must not break live combat bindings. When changing shared input routing, verify that combat-only actions still win during active combat: movement, ability casts, party targeting, pause, and overlay navigation must all work on AYN Thor controls.
|
||||||
|
- `data-game-nav-active` and other custom navigation markers may coexist with live combat HUDs. Do not treat those markers as menu mode during active combat unless a pause/dialog/result overlay is visible.
|
||||||
|
- Before considering any controller input change complete, test both states: no-overlay combat and menu/dialog navigation. No-overlay combat must include at least one successful ability cast from a controller binding and continuous analog movement on the AYN Thor control scheme.
|
||||||
|
|
||||||
## Architecture Requirements
|
## Architecture Requirements
|
||||||
|
|
||||||
@@ -34,6 +37,7 @@
|
|||||||
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
|
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
|
||||||
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
|
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
|
||||||
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
|
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
|
||||||
|
- IWT2 combat must preserve AYN Thor face/shoulder button ability bindings even when party frames, HUD controls, or target rails use custom controller navigation state. Menu navigation priority must never swallow ability inputs while `data-combat-active="true"` and no overlay is open.
|
||||||
- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically.
|
- IWT2 boss mechanics and indicators must be modular. Add reusable hit-shape, damage/status, telegraph/indicator, hazard, and marker primitives under `src/modes/iwt2/sim/` or `src/modes/iwt2/render/` instead of hardcoding boss-specific rules in Phaser scenes. Boss AI may compose these primitives, but renderer code should draw sim-owned indicators generically.
|
||||||
- Every IWT2 boss must have a boss pet collection-log reward. Add the pet to IWT2 boss reward metadata when adding the boss, and keep boss kill pet drops at a rare 1 in 500 chance.
|
- Every IWT2 boss must have a boss pet collection-log reward. Add the pet to IWT2 boss reward metadata when adding the boss, and keep boss kill pet drops at a rare 1 in 500 chance.
|
||||||
- IWT2 boss and party movement must be built with crowding and hazard escape in mind from the first implementation. New bosses must not rely on straight-line chase or single-vector hazard avoidance if the boss can create ground effects, wall pressure, summons, or multi-boss overlap.
|
- IWT2 boss and party movement must be built with crowding and hazard escape in mind from the first implementation. New bosses must not rely on straight-line chase or single-vector hazard avoidance if the boss can create ground effects, wall pressure, summons, or multi-boss overlap.
|
||||||
|
|||||||
+31
-3
@@ -54,12 +54,18 @@ code updates to be a Git pull plus app restart.
|
|||||||
|
|
||||||
Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**.
|
Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**.
|
||||||
|
|
||||||
Repository:
|
Repository URL:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://git.whoagland.com/phenom/i-want-to-heal.git
|
https://git.whoagland.com/phenom/i-want-to-heal.git
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Local Gitea repository path on the same TrueNAS host:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal.git
|
||||||
|
```
|
||||||
|
|
||||||
TrueNAS paths:
|
TrueNAS paths:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -67,12 +73,15 @@ TrueNAS paths:
|
|||||||
/mnt/usbssds/apps/iwanttoheal/data
|
/mnt/usbssds/apps/iwanttoheal/data
|
||||||
```
|
```
|
||||||
|
|
||||||
Create the app directory and clone the repo:
|
Create the app directory and clone the repo. When Gitea is hosted on the same
|
||||||
|
TrueNAS box, prefer the local repository path instead of HTTPS. This avoids
|
||||||
|
Git-over-HTTPS TLS failures while downloading large pack files.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal
|
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal
|
||||||
cd /mnt/usbssds/apps/iwanttoheal
|
cd /mnt/usbssds/apps/iwanttoheal
|
||||||
sudo git clone https://git.whoagland.com/phenom/i-want-to-heal.git app
|
sudo git config --global --add safe.directory /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal.git
|
||||||
|
sudo git clone /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal.git app
|
||||||
```
|
```
|
||||||
|
|
||||||
Because the clone was run with `sudo`, give the normal TrueNAS user ownership:
|
Because the clone was run with `sudo`, give the normal TrueNAS user ownership:
|
||||||
@@ -101,6 +110,25 @@ cd /mnt/usbssds/apps/iwanttoheal/app
|
|||||||
git pull
|
git pull
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If setting up a different app hosted by the same Gitea instance, first find its
|
||||||
|
bare repository path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo find /mnt -type d -name "REPO-NAME.git" -prune -print 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
Then substitute that path in the `safe.directory` and `git clone` commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo git config --global --add safe.directory /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/OWNER/REPO-NAME.git
|
||||||
|
sudo git clone /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/OWNER/REPO-NAME.git app
|
||||||
|
sudo chown -R truenas_admin:truenas_admin app
|
||||||
|
```
|
||||||
|
|
||||||
|
`safe.directory` is a Git trust exception for the Gitea-owned bare repository.
|
||||||
|
It is needed because Gitea's repository files are owned by the app/container
|
||||||
|
user, not by `truenas_admin`.
|
||||||
|
|
||||||
If Git fails with `chmod ... Operation not permitted`, do not use a media or SMB
|
If Git fails with `chmod ... Operation not permitted`, do not use a media or SMB
|
||||||
dataset for the repo. Git needs normal file locking and chmod behavior. Create or
|
dataset for the repo. Git needs normal file locking and chmod behavior. Create or
|
||||||
use a dedicated apps dataset and clone under `/mnt/usbssds/apps/...`.
|
use a dedicated apps dataset and clone under `/mnt/usbssds/apps/...`.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 107
|
versionCode 118
|
||||||
versionName "1.1.27"
|
versionName "1.1.37"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ npm run release:cli -- --release-only 1.1.26
|
|||||||
|
|
||||||
## Step 5: Update TrueNAS
|
## Step 5: Update TrueNAS
|
||||||
|
|
||||||
|
The TrueNAS app checkout should be cloned from the local Gitea bare repository
|
||||||
|
path when Gitea runs on the same TrueNAS host. After that setup, normal updates
|
||||||
|
still use `git pull`; the remote is local filesystem storage instead of HTTPS.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd /mnt/usbssds/apps/iwanttoheal/app
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
git pull
|
git pull
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,779 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { createServer } from 'vite'
|
||||||
|
|
||||||
|
const DEFAULT_WORKERS = Math.max(1, Math.min(8, Number.parseInt(process.env.IWT2_SIM_WORKERS ?? '8', 10)))
|
||||||
|
const DEFAULT_MAX_SECONDS = Math.max(30, Number.parseFloat(process.env.IWT2_SIM_SECONDS ?? '180'))
|
||||||
|
const DEFAULT_DT = 1 / 30
|
||||||
|
const DEFAULT_TICKS_PER_WORKER_SECOND = 45000
|
||||||
|
const ESTIMATE_STARTUP_SECONDS = 0.8
|
||||||
|
const PVP_BOSS_HEALTH_MULTIPLIER = 0.7
|
||||||
|
const BOSS_HEALTH_PER_STAGE = 0.1
|
||||||
|
const ARENA_BOUNDS = { width: 960, height: 540 }
|
||||||
|
const HEALER_STYLES = ['dawnweaver', 'lifebinder', 'runesage']
|
||||||
|
const BOSS_IDS = [
|
||||||
|
'bulldrome',
|
||||||
|
'yian-kut-ku',
|
||||||
|
'great-jaggi',
|
||||||
|
'khezu',
|
||||||
|
'rathian',
|
||||||
|
'barroth',
|
||||||
|
'tobi-kadachi',
|
||||||
|
'rimebastion',
|
||||||
|
'ember-mantis-duelist',
|
||||||
|
'cinderback-ricochet',
|
||||||
|
'obsidian-ram-golem',
|
||||||
|
'stormcoil-wyrm',
|
||||||
|
'venom-orchid-hydra',
|
||||||
|
'sandglass-scorpion',
|
||||||
|
'crystal-bat-matriarch',
|
||||||
|
'hollowcrown-revenant',
|
||||||
|
]
|
||||||
|
const activeChildren = new Set()
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
for (const child of activeChildren) {
|
||||||
|
child.kill(signal)
|
||||||
|
}
|
||||||
|
process.exit(signal === 'SIGINT' ? 130 : 143)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
|
||||||
|
if (args.has('--help')) {
|
||||||
|
printHelp()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
bossCount: parsePositiveInt(args.get('--boss-count') ?? '2', 2),
|
||||||
|
bossDamagePercent: parsePositiveNumber(args.get('--boss-damage-percent') ?? '100', 100),
|
||||||
|
bossFilter: parseList(args.get('--bosses') ?? 'all'),
|
||||||
|
bossHpPercent: parsePositiveNumber(args.get('--boss-hp-percent') ?? '100', 100),
|
||||||
|
classes: parseList(args.get('--classes') ?? args.get('--class') ?? 'all'),
|
||||||
|
estimateOnly: args.has('--estimate-only'),
|
||||||
|
gearLevel: parseNonNegativeInt(args.get('--gear-level') ?? args.get('--gear') ?? '5', 5),
|
||||||
|
maxSeconds: Number.parseFloat(args.get('--seconds') ?? String(DEFAULT_MAX_SECONDS)),
|
||||||
|
repeats: parsePositiveInt(args.get('--repeats') ?? '1', 1),
|
||||||
|
requiredBosses: parseList(args.get('--required-bosses') ?? args.get('--include-bosses') ?? 'none'),
|
||||||
|
stages: parseStages(args.get('--stages') ?? '1,2'),
|
||||||
|
top: parsePositiveInt(args.get('--top') ?? '24', 24),
|
||||||
|
workers: Number.parseInt(args.get('--workers') ?? String(DEFAULT_WORKERS), 10),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.has('--worker')) {
|
||||||
|
await runWorker({
|
||||||
|
bossCount: config.bossCount,
|
||||||
|
bossDamagePercent: config.bossDamagePercent,
|
||||||
|
bossFilter: config.bossFilter,
|
||||||
|
bossHpPercent: config.bossHpPercent,
|
||||||
|
classes: config.classes,
|
||||||
|
gearLevel: config.gearLevel,
|
||||||
|
repeats: config.repeats,
|
||||||
|
requiredBosses: config.requiredBosses,
|
||||||
|
shardIndex: Number.parseInt(args.get('--shard-index') ?? '0', 10),
|
||||||
|
shardCount: Number.parseInt(args.get('--shard-count') ?? '1', 10),
|
||||||
|
maxSeconds: config.maxSeconds,
|
||||||
|
stages: config.stages,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await runMain(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMain({
|
||||||
|
bossCount,
|
||||||
|
bossDamagePercent,
|
||||||
|
bossFilter,
|
||||||
|
bossHpPercent,
|
||||||
|
classes,
|
||||||
|
estimateOnly,
|
||||||
|
gearLevel,
|
||||||
|
maxSeconds,
|
||||||
|
repeats,
|
||||||
|
requiredBosses,
|
||||||
|
stages,
|
||||||
|
top,
|
||||||
|
workers,
|
||||||
|
}) {
|
||||||
|
const safeWorkers = Math.max(1, Math.floor(workers))
|
||||||
|
const estimate = estimateRun({
|
||||||
|
bossCount,
|
||||||
|
bossFilter,
|
||||||
|
classes,
|
||||||
|
maxSeconds,
|
||||||
|
repeats,
|
||||||
|
requiredBosses,
|
||||||
|
stages,
|
||||||
|
workers: safeWorkers,
|
||||||
|
})
|
||||||
|
if (estimateOnly) {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
config: {
|
||||||
|
bossCount,
|
||||||
|
bosses: bossFilter,
|
||||||
|
classes,
|
||||||
|
repeats,
|
||||||
|
requiredBosses,
|
||||||
|
seconds: maxSeconds,
|
||||||
|
stages,
|
||||||
|
workers: safeWorkers,
|
||||||
|
},
|
||||||
|
estimate,
|
||||||
|
}, null, 2))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const childResults = await Promise.all(Array.from({ length: safeWorkers }, (_, shardIndex) => (
|
||||||
|
runChild({
|
||||||
|
bossCount,
|
||||||
|
bossDamagePercent,
|
||||||
|
bossFilter,
|
||||||
|
bossHpPercent,
|
||||||
|
classes,
|
||||||
|
gearLevel,
|
||||||
|
repeats,
|
||||||
|
requiredBosses,
|
||||||
|
shardIndex,
|
||||||
|
shardCount: safeWorkers,
|
||||||
|
maxSeconds,
|
||||||
|
stages,
|
||||||
|
})
|
||||||
|
)))
|
||||||
|
const results = childResults.flat()
|
||||||
|
const elapsedSeconds = round((Date.now() - startedAt) / 1000)
|
||||||
|
const summary = summarizeResults(results)
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
config: {
|
||||||
|
bossCount,
|
||||||
|
bossDamagePercent,
|
||||||
|
bossHpPercent,
|
||||||
|
bosses: bossFilter,
|
||||||
|
classes,
|
||||||
|
gearLevel,
|
||||||
|
elapsedSeconds,
|
||||||
|
estimate,
|
||||||
|
orderedTrials: results.length,
|
||||||
|
repeats,
|
||||||
|
requiredBosses,
|
||||||
|
seconds: maxSeconds,
|
||||||
|
stages,
|
||||||
|
workers: safeWorkers,
|
||||||
|
},
|
||||||
|
hardestCombos: summary.hardestCombos.slice(0, top),
|
||||||
|
hardestStageCombos: summary.hardestStageCombos.slice(0, top),
|
||||||
|
topMechanics: summary.topMechanics,
|
||||||
|
weakestSingleBosses: summary.weakestSingleBosses.slice(0, 16),
|
||||||
|
}, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
function runChild({
|
||||||
|
bossCount,
|
||||||
|
bossDamagePercent,
|
||||||
|
bossFilter,
|
||||||
|
bossHpPercent,
|
||||||
|
classes,
|
||||||
|
gearLevel,
|
||||||
|
repeats,
|
||||||
|
requiredBosses,
|
||||||
|
shardIndex,
|
||||||
|
shardCount,
|
||||||
|
maxSeconds,
|
||||||
|
stages,
|
||||||
|
}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(process.execPath, [
|
||||||
|
new URL(import.meta.url).pathname,
|
||||||
|
'--worker',
|
||||||
|
'--boss-count',
|
||||||
|
String(bossCount),
|
||||||
|
'--boss-damage-percent',
|
||||||
|
String(bossDamagePercent),
|
||||||
|
'--bosses',
|
||||||
|
bossFilter.join(','),
|
||||||
|
'--boss-hp-percent',
|
||||||
|
String(bossHpPercent),
|
||||||
|
'--classes',
|
||||||
|
classes.join(','),
|
||||||
|
'--gear-level',
|
||||||
|
String(gearLevel),
|
||||||
|
'--repeats',
|
||||||
|
String(repeats),
|
||||||
|
'--required-bosses',
|
||||||
|
requiredBosses.join(','),
|
||||||
|
'--shard-index',
|
||||||
|
String(shardIndex),
|
||||||
|
'--shard-count',
|
||||||
|
String(shardCount),
|
||||||
|
'--seconds',
|
||||||
|
String(maxSeconds),
|
||||||
|
'--stages',
|
||||||
|
stages.join(','),
|
||||||
|
], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: { ...process.env, VITE_CJS_IGNORE_WARNING: 'true' },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
activeChildren.add(child)
|
||||||
|
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdout += chunk
|
||||||
|
})
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr += chunk
|
||||||
|
})
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('close', (code) => {
|
||||||
|
activeChildren.delete(child)
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`worker ${shardIndex} exited ${code}\n${stderr}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout))
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`worker ${shardIndex} returned invalid JSON\n${stdout}\n${stderr}\n${error}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWorker({
|
||||||
|
bossCount,
|
||||||
|
bossDamagePercent,
|
||||||
|
bossFilter,
|
||||||
|
bossHpPercent,
|
||||||
|
classes,
|
||||||
|
gearLevel,
|
||||||
|
repeats,
|
||||||
|
requiredBosses,
|
||||||
|
shardIndex,
|
||||||
|
shardCount,
|
||||||
|
maxSeconds,
|
||||||
|
stages,
|
||||||
|
}) {
|
||||||
|
const server = await createServer({
|
||||||
|
appType: 'custom',
|
||||||
|
configFile: 'vite.config.ts',
|
||||||
|
logLevel: 'error',
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const modules = await loadSimModules(server)
|
||||||
|
const bossIds = filteredBossIds(Object.keys(modules.IWT2_BOSS_METADATA), bossFilter)
|
||||||
|
const requiredBossIds = filteredRequiredBossIds(bossIds, requiredBosses)
|
||||||
|
const healerStyles = filteredClasses(classes)
|
||||||
|
const orderedBossGroups = permutations(bossIds, bossCount)
|
||||||
|
.filter((group) => requiredBossIds.every((bossId) => group.includes(bossId)))
|
||||||
|
if (orderedBossGroups.length === 0) {
|
||||||
|
throw new Error(`No boss combinations generated. Boss count ${bossCount}; required bosses: ${requiredBossIds.join(', ') || 'none'}.`)
|
||||||
|
}
|
||||||
|
const tasks = []
|
||||||
|
for (let repeatIndex = 0; repeatIndex < repeats; repeatIndex += 1) {
|
||||||
|
for (const stage of stages) {
|
||||||
|
for (const healerStyle of healerStyles) {
|
||||||
|
for (const group of orderedBossGroups) {
|
||||||
|
tasks.push({
|
||||||
|
bossDamagePercent,
|
||||||
|
bossHpPercent,
|
||||||
|
bossIds: group,
|
||||||
|
gearLevel,
|
||||||
|
healerStyle,
|
||||||
|
repeatIndex,
|
||||||
|
stage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const results = []
|
||||||
|
for (let index = 0; index < tasks.length; index += 1) {
|
||||||
|
if (index % shardCount !== shardIndex) continue
|
||||||
|
results.push(simulateTrial(modules, tasks[index], maxSeconds))
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(results))
|
||||||
|
} finally {
|
||||||
|
await server.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSimModules(server) {
|
||||||
|
const sim = await server.ssrLoadModule('/src/modes/iwt2/sim/index.ts')
|
||||||
|
const bosses = await server.ssrLoadModule('/src/modes/iwt2/content/bosses.ts')
|
||||||
|
const healerAbilities = await server.ssrLoadModule('/src/modes/iwt2/content/healerAbilities.ts')
|
||||||
|
const gear = await server.ssrLoadModule('/src/modes/iwt2/content/pvpGearNormalization.ts')
|
||||||
|
const equipment = await server.ssrLoadModule('/src/modes/iwt2/sim/equipmentStats.ts')
|
||||||
|
const pressure = await server.ssrLoadModule('/src/modes/iwt2/sim/roguelikePressure.ts')
|
||||||
|
return {
|
||||||
|
...sim,
|
||||||
|
...bosses,
|
||||||
|
...healerAbilities,
|
||||||
|
...gear,
|
||||||
|
...equipment,
|
||||||
|
...pressure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateTrial(modules, task, maxSeconds) {
|
||||||
|
const gearProgress = modules.createIwt2PvpNormalizedGearProgress({
|
||||||
|
enabled: true,
|
||||||
|
gearLevel: task.gearLevel,
|
||||||
|
})
|
||||||
|
const bossHealthScale = (1 + Math.max(0, task.stage - 1) * BOSS_HEALTH_PER_STAGE)
|
||||||
|
* PVP_BOSS_HEALTH_MULTIPLIER
|
||||||
|
* (task.bossHpPercent / 100)
|
||||||
|
const damageScale = modules.roguelikeIncomingDamageScale(task.stage, 'dungeon') * (task.bossDamagePercent / 100)
|
||||||
|
let state = modules.createInitialIwt2ArenaState(
|
||||||
|
task.bossIds[0],
|
||||||
|
task.bossIds,
|
||||||
|
bossHealthScale,
|
||||||
|
damageScale,
|
||||||
|
modules.createRoguelikePressureState(task.stage, 'dungeon'),
|
||||||
|
ARENA_BOUNDS,
|
||||||
|
)
|
||||||
|
state = modules.applyIwt2PveGearStats(state, gearProgress)
|
||||||
|
const abilities = modules.applyIwt2PveGearToHealerAbilities(
|
||||||
|
modules.abilitiesForHealer(task.healerStyle),
|
||||||
|
gearProgress,
|
||||||
|
)
|
||||||
|
const cooldowns = {}
|
||||||
|
const eventCounts = {}
|
||||||
|
const mechanicDamage = {}
|
||||||
|
const sourceDamage = {}
|
||||||
|
const sourceDefeats = {}
|
||||||
|
let casts = 0
|
||||||
|
let maxActiveHazards = 0
|
||||||
|
let maxHostileAdds = 0
|
||||||
|
let minLivingHealthRatio = 1
|
||||||
|
let minLivingCount = state.party.length
|
||||||
|
let outcome = 'timeout'
|
||||||
|
|
||||||
|
for (let step = 0; step < Math.ceil(maxSeconds / DEFAULT_DT); step += 1) {
|
||||||
|
const healResult = maybeCastHeal(modules, state, abilities, cooldowns)
|
||||||
|
state = healResult.state
|
||||||
|
casts += healResult.cast ? 1 : 0
|
||||||
|
tickCooldowns(cooldowns, DEFAULT_DT)
|
||||||
|
const previousState = state
|
||||||
|
const previousEventId = state.nextEventId
|
||||||
|
state = modules.tickIwt2Arena(state, scriptedMovement(state, task.repeatIndex), DEFAULT_DT)
|
||||||
|
const newEvents = state.events.filter((event) => event.id >= previousEventId)
|
||||||
|
for (const event of newEvents) {
|
||||||
|
eventCounts[event.type] = (eventCounts[event.type] ?? 0) + 1
|
||||||
|
if (isDamageEvent(event) && typeof event.value === 'number') {
|
||||||
|
sourceDamage[event.sourceId] = (sourceDamage[event.sourceId] ?? 0) + event.value
|
||||||
|
const mechanic = mechanicLabelForEvent(event, previousState)
|
||||||
|
mechanicDamage[mechanic] = (mechanicDamage[mechanic] ?? 0) + event.value
|
||||||
|
} else if (event.type === 'entityDefeated' && typeof event.targetId === 'string' && event.targetId !== 'yian-kut-ku') {
|
||||||
|
sourceDefeats[event.sourceId] = (sourceDefeats[event.sourceId] ?? 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxActiveHazards = Math.max(maxActiveHazards, state.hazards.filter((hazard) => hazard.remainingSeconds > hazard.fadeSeconds).length)
|
||||||
|
maxHostileAdds = Math.max(maxHostileAdds, state.hostileAdds.filter((add) => add.health > 0).length)
|
||||||
|
const living = state.party.filter((member) => member.health > 0)
|
||||||
|
minLivingCount = Math.min(minLivingCount, living.length)
|
||||||
|
for (const member of living) {
|
||||||
|
minLivingHealthRatio = Math.min(minLivingHealthRatio, member.health / member.maxHealth)
|
||||||
|
}
|
||||||
|
if (state.bosses.every((boss) => boss.health <= 0)) {
|
||||||
|
outcome = 'victory'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (state.party.every((member) => member.health <= 0)) {
|
||||||
|
outcome = 'defeat'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingBossHealth = state.bosses.reduce((total, boss) => total + Math.max(0, boss.health), 0)
|
||||||
|
const maxBossHealth = state.bosses.reduce((total, boss) => total + boss.maxHealth, 0)
|
||||||
|
const partyHealth = state.party.reduce((total, member) => total + Math.max(0, member.health), 0)
|
||||||
|
const partyMaxHealth = state.party.reduce((total, member) => total + member.maxHealth, 0)
|
||||||
|
return {
|
||||||
|
bossIds: task.bossIds,
|
||||||
|
comboKey: comboKey(task.bossIds),
|
||||||
|
deaths: state.party.filter((member) => member.health <= 0).length,
|
||||||
|
eventCounts,
|
||||||
|
gearLevel: task.gearLevel,
|
||||||
|
healerStyle: task.healerStyle,
|
||||||
|
mechanicDamage,
|
||||||
|
maxActiveHazards,
|
||||||
|
maxHostileAdds,
|
||||||
|
minLivingCount,
|
||||||
|
minLivingHealthRatio: round(minLivingHealthRatio),
|
||||||
|
outcome,
|
||||||
|
partyHealthRatio: round(partyHealth / partyMaxHealth),
|
||||||
|
remainingBossHealthRatio: round(remainingBossHealth / maxBossHealth),
|
||||||
|
repeatIndex: task.repeatIndex,
|
||||||
|
sourceDamage,
|
||||||
|
sourceDefeats,
|
||||||
|
seconds: round(state.time),
|
||||||
|
stage: task.stage,
|
||||||
|
casts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeCastHeal(modules, state, abilities, cooldowns) {
|
||||||
|
const healer = state.party.find((member) => member.id === 'player-healer')
|
||||||
|
if (!healer || healer.health <= 0) return { cast: false, state }
|
||||||
|
const living = state.party.filter((member) => member.health > 0)
|
||||||
|
if (living.length === 0) return { cast: false, state }
|
||||||
|
const lowest = [...living].sort((a, b) => healthRatio(a) - healthRatio(b))[0]
|
||||||
|
const disabled = [...living]
|
||||||
|
.filter((member) => member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0)
|
||||||
|
.sort((a, b) => healthRatio(a) - healthRatio(b))[0]
|
||||||
|
const woundedCount = living.filter((member) => healthRatio(member) < 0.82).length
|
||||||
|
const candidates = [
|
||||||
|
woundedCount >= 3 ? ['group', lowest.id] : null,
|
||||||
|
disabled && healthRatio(disabled) < 0.95 ? ['cleanse', disabled.id] : null,
|
||||||
|
lowest && healthRatio(lowest) < 0.42 ? ['direct', lowest.id] : null,
|
||||||
|
lowest && healthRatio(lowest) < 0.78 && lowest.shield <= 0 ? ['shield', lowest.id] : null,
|
||||||
|
lowest && healthRatio(lowest) < 0.88 && !hasHot(lowest) ? ['hot', lowest.id] : null,
|
||||||
|
lowest && healthRatio(lowest) < 0.72 ? ['direct', lowest.id] : null,
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
for (const [kind, targetId] of candidates) {
|
||||||
|
const ability = abilities.find((candidate) => candidate.kind === kind && (cooldowns[candidate.id] ?? 0) <= 0)
|
||||||
|
if (!ability) continue
|
||||||
|
const result = modules.castIwt2HealerAbility(state, ability, targetId)
|
||||||
|
if (!result.cast) continue
|
||||||
|
cooldowns[ability.id] = ability.cooldownSeconds
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return { cast: false, state }
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptedMovement(state, repeatIndex = 0) {
|
||||||
|
const healer = state.party.find((member) => member.id === 'player-healer')
|
||||||
|
if (!healer || healer.health <= 0) return { moveX: 0, moveY: 0 }
|
||||||
|
const livingBosses = state.bosses.filter((boss) => boss.health > 0)
|
||||||
|
const nearestBoss = livingBosses
|
||||||
|
.map((boss) => ({ boss, distance: Math.hypot(boss.position.x - healer.position.x, boss.position.y - healer.position.y) }))
|
||||||
|
.sort((a, b) => a.distance - b.distance)[0]?.boss
|
||||||
|
const away = nearestBoss
|
||||||
|
? normalize({ x: healer.position.x - nearestBoss.position.x, y: healer.position.y - nearestBoss.position.y })
|
||||||
|
: { x: 0, y: 0 }
|
||||||
|
const orbitSeconds = Math.max(1, state.time) + repeatIndex * 1.731
|
||||||
|
const orbit = normalize({
|
||||||
|
x: Math.cos(orbitSeconds * 0.75),
|
||||||
|
y: Math.sin(orbitSeconds * 0.75) * 0.55,
|
||||||
|
})
|
||||||
|
const vector = normalize({
|
||||||
|
x: away.x * 0.6 + orbit.x * 0.4,
|
||||||
|
y: away.y * 0.6 + orbit.y * 0.4,
|
||||||
|
})
|
||||||
|
return { moveX: vector.x, moveY: vector.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeResults(results) {
|
||||||
|
const byCombo = new Map()
|
||||||
|
const byStageCombo = new Map()
|
||||||
|
const byBoss = new Map()
|
||||||
|
for (const result of results) {
|
||||||
|
pushGroup(byCombo, result.comboKey, result)
|
||||||
|
pushGroup(byStageCombo, `${result.stage}:${result.comboKey}`, result)
|
||||||
|
for (const bossId of new Set(result.bossIds)) {
|
||||||
|
pushGroup(byBoss, bossId, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hardestCombos: [...byCombo.entries()]
|
||||||
|
.map(([key, items]) => summarizeGroup(key, items))
|
||||||
|
.sort(compareDifficulty),
|
||||||
|
hardestStageCombos: [...byStageCombo.entries()]
|
||||||
|
.map(([key, items]) => summarizeGroup(key, items))
|
||||||
|
.sort(compareDifficulty),
|
||||||
|
topMechanics: topSources(results, 'mechanicDamage'),
|
||||||
|
weakestSingleBosses: [...byBoss.entries()]
|
||||||
|
.map(([key, items]) => summarizeGroup(key, items))
|
||||||
|
.sort(compareDifficulty),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeGroup(key, items) {
|
||||||
|
const failures = items.filter((item) => item.outcome !== 'victory').length
|
||||||
|
const wins = items.length - failures
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
trials: items.length,
|
||||||
|
failures,
|
||||||
|
winRate: round(wins / items.length),
|
||||||
|
avgDeaths: round(average(items.map((item) => item.deaths))),
|
||||||
|
avgSeconds: round(average(items.map((item) => item.seconds))),
|
||||||
|
avgRemainingBossHealth: round(average(items.map((item) => item.remainingBossHealthRatio))),
|
||||||
|
worstRemainingBossHealth: round(Math.max(...items.map((item) => item.remainingBossHealthRatio))),
|
||||||
|
worstMinLivingCount: Math.min(...items.map((item) => item.minLivingCount)),
|
||||||
|
outcomes: countBy(items.map((item) => item.outcome)),
|
||||||
|
topDamageSources: topSources(items, 'sourceDamage'),
|
||||||
|
topDefeatSources: topSources(items, 'sourceDefeats'),
|
||||||
|
topMechanics: topSources(items, 'mechanicDamage'),
|
||||||
|
maxActiveHazards: Math.max(...items.map((item) => item.maxActiveHazards)),
|
||||||
|
maxHostileAdds: Math.max(...items.map((item) => item.maxHostileAdds)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDifficulty(a, b) {
|
||||||
|
return (b.failures - a.failures)
|
||||||
|
|| (a.winRate - b.winRate)
|
||||||
|
|| (b.avgDeaths - a.avgDeaths)
|
||||||
|
|| (b.avgRemainingBossHealth - a.avgRemainingBossHealth)
|
||||||
|
|| (b.avgSeconds - a.avgSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushGroup(map, key, item) {
|
||||||
|
const items = map.get(key)
|
||||||
|
if (items) items.push(item)
|
||||||
|
else map.set(key, [item])
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDamageEvent(event) {
|
||||||
|
return event.type === 'partyDamaged'
|
||||||
|
|| event.type === 'bossChargeHit'
|
||||||
|
|| event.type === 'bossProjectileHit'
|
||||||
|
|| event.type === 'groundHazardTick'
|
||||||
|
}
|
||||||
|
|
||||||
|
function mechanicLabelForEvent(event, state) {
|
||||||
|
const sourceId = String(event.sourceId)
|
||||||
|
if (sourceId === 'roguelike-pressure') return 'Roguelike pressure'
|
||||||
|
if (sourceId.startsWith('yian-bird')) {
|
||||||
|
return event.type === 'groundHazardTick'
|
||||||
|
? 'yian-kut-ku: bird fire puddle'
|
||||||
|
: 'yian-kut-ku: bird add'
|
||||||
|
}
|
||||||
|
if (event.type === 'bossProjectileHit') return `${sourceId}: projectile`
|
||||||
|
if (event.type === 'groundHazardTick') return `${sourceId}: ground hazard`
|
||||||
|
if (event.type === 'bossChargeHit') return `${sourceId}: charge / dash`
|
||||||
|
const boss = state.bosses?.find((entry) => entry.id === sourceId)
|
||||||
|
if (!boss) return `${sourceId}: ${event.type}`
|
||||||
|
return `${sourceId}: ${phaseMechanicLabel(boss.attackPhase)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseMechanicLabel(phase) {
|
||||||
|
if (phase.includes('Windup') || phase.includes('Recover')) {
|
||||||
|
return phase
|
||||||
|
.replace(/Windup$/, '')
|
||||||
|
.replace(/Recover$/, '')
|
||||||
|
.replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`)
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
if (phase.includes('Pouncing')) return 'pounce'
|
||||||
|
if (phase.includes('Charging')) return 'charge / dash'
|
||||||
|
if (phase.includes('Ricocheting')) return 'ricochet'
|
||||||
|
if (phase.includes('Burrowing')) return 'burrow'
|
||||||
|
if (phase === 'fireballRecover' || phase === 'fireballWindup') return 'fireball'
|
||||||
|
if (phase === 'idle') return 'melee / contact'
|
||||||
|
return phase.replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(rawArgs) {
|
||||||
|
const parsed = new Map()
|
||||||
|
for (let index = 0; index < rawArgs.length; index += 1) {
|
||||||
|
const arg = rawArgs[index]
|
||||||
|
if (!arg.startsWith('--')) continue
|
||||||
|
const inlineValueIndex = arg.indexOf('=')
|
||||||
|
if (inlineValueIndex >= 0) {
|
||||||
|
parsed.set(arg.slice(0, inlineValueIndex), arg.slice(inlineValueIndex + 1))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const next = rawArgs[index + 1]
|
||||||
|
if (next && !next.startsWith('--')) {
|
||||||
|
parsed.set(arg, next)
|
||||||
|
index += 1
|
||||||
|
} else {
|
||||||
|
parsed.set(arg, 'true')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`IWT2 PvP roguelike boss simulator
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
node scripts/iwt2-pvp-roguelike-boss-sim.mjs [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--classes all|dawnweaver,lifebinder,runesage
|
||||||
|
--gear-level 0|5|N
|
||||||
|
--boss-count 2|3|N
|
||||||
|
--bosses all|bulldrome,yian-kut-ku,...
|
||||||
|
--required-bosses none|yian-kut-ku,...
|
||||||
|
--repeats 1
|
||||||
|
--estimate-only
|
||||||
|
--boss-hp-percent 100
|
||||||
|
--boss-damage-percent 100
|
||||||
|
--stages 1,2
|
||||||
|
--seconds 180
|
||||||
|
--workers 8
|
||||||
|
--top 24
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --classes dawnweaver --gear-level 0
|
||||||
|
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --classes all --gear-level 5 --boss-count 3
|
||||||
|
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --boss-hp-percent 125 --boss-damage-percent 110
|
||||||
|
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --bosses yian-kut-ku,rathian,rimebastion --boss-count 2
|
||||||
|
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --required-bosses yian-kut-ku --repeats 10
|
||||||
|
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --required-bosses yian-kut-ku --estimate-only
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateRun({
|
||||||
|
bossCount,
|
||||||
|
bossFilter,
|
||||||
|
classes,
|
||||||
|
maxSeconds,
|
||||||
|
repeats,
|
||||||
|
requiredBosses,
|
||||||
|
stages,
|
||||||
|
workers,
|
||||||
|
}) {
|
||||||
|
const bossIds = filteredBossIds(BOSS_IDS, bossFilter)
|
||||||
|
const requiredBossIds = filteredRequiredBossIds(bossIds, requiredBosses)
|
||||||
|
const healerStyles = filteredClasses(classes)
|
||||||
|
const orderedBossGroups = permutations(bossIds, bossCount)
|
||||||
|
.filter((group) => requiredBossIds.every((bossId) => group.includes(bossId)))
|
||||||
|
const trials = orderedBossGroups.length * healerStyles.length * stages.length * repeats
|
||||||
|
const maxTicks = trials * maxSeconds * (1 / DEFAULT_DT)
|
||||||
|
const estimatedWallSeconds = ESTIMATE_STARTUP_SECONDS + (maxTicks / (Math.max(1, workers) * DEFAULT_TICKS_PER_WORKER_SECOND))
|
||||||
|
return {
|
||||||
|
bossPoolSize: bossIds.length,
|
||||||
|
classCount: healerStyles.length,
|
||||||
|
orderedBossGroupCount: orderedBossGroups.length,
|
||||||
|
requiredBosses: requiredBossIds,
|
||||||
|
stageCount: stages.length,
|
||||||
|
repeats,
|
||||||
|
trials,
|
||||||
|
maxSimulatedSeconds: round(trials * maxSeconds),
|
||||||
|
maxTicks: Math.round(maxTicks),
|
||||||
|
estimatedWallSeconds: round(estimatedWallSeconds),
|
||||||
|
note: 'Rough wall-time estimate; victories or defeats can end trials early.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredBossIds(allBossIds, filter) {
|
||||||
|
if (filter.length === 0 || filter.includes('all')) return allBossIds
|
||||||
|
const known = new Set(allBossIds)
|
||||||
|
const selected = filter.filter((bossId) => known.has(bossId))
|
||||||
|
if (selected.length === 0) {
|
||||||
|
throw new Error(`No valid bosses selected. Known bosses: ${allBossIds.join(', ')}`)
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredClasses(classes) {
|
||||||
|
if (classes.length === 0 || classes.includes('all')) return HEALER_STYLES
|
||||||
|
const selected = classes.filter((healerStyle) => HEALER_STYLES.includes(healerStyle))
|
||||||
|
if (selected.length === 0) {
|
||||||
|
throw new Error(`No valid classes selected. Known classes: ${HEALER_STYLES.join(', ')}`)
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredRequiredBossIds(bossIds, requiredBosses) {
|
||||||
|
if (requiredBosses.length === 0 || requiredBosses.includes('none') || requiredBosses.includes('all')) return []
|
||||||
|
const bossSet = new Set(bossIds)
|
||||||
|
const selected = requiredBosses.filter((bossId) => bossSet.has(bossId))
|
||||||
|
if (selected.length === 0) {
|
||||||
|
throw new Error(`No required bosses are in the selected boss pool. Required: ${requiredBosses.join(', ')}. Pool: ${bossIds.join(', ')}`)
|
||||||
|
}
|
||||||
|
return [...new Set(selected)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function permutations(items, count) {
|
||||||
|
const safeCount = Math.max(1, Math.min(items.length, Math.floor(count)))
|
||||||
|
const results = []
|
||||||
|
const walk = (current, remaining) => {
|
||||||
|
if (current.length === safeCount) {
|
||||||
|
results.push(current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (let index = 0; index < remaining.length; index += 1) {
|
||||||
|
const next = remaining[index]
|
||||||
|
walk([...current, next], remaining.filter((_, remainingIndex) => remainingIndex !== index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk([], items)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseList(value) {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStages(value) {
|
||||||
|
return value.split(',').map((stage) => Number.parseInt(stage, 10)).filter(Number.isFinite)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value, fallback) {
|
||||||
|
const parsed = Number.parseInt(value, 10)
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNonNegativeInt(value, fallback) {
|
||||||
|
const parsed = Number.parseInt(value, 10)
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveNumber(value, fallback) {
|
||||||
|
const parsed = Number.parseFloat(value)
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function comboKey(bossIds) {
|
||||||
|
return [...bossIds].sort().join(' + ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasHot(member) {
|
||||||
|
return member.hotEffects.some((effect) => effect.remainingSeconds > 1.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthRatio(member) {
|
||||||
|
return member.health / member.maxHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickCooldowns(cooldowns, dt) {
|
||||||
|
for (const [id, remaining] of Object.entries(cooldowns)) {
|
||||||
|
const next = Math.max(0, remaining - dt)
|
||||||
|
if (next > 0) cooldowns[id] = next
|
||||||
|
else delete cooldowns[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countBy(items) {
|
||||||
|
return items.reduce((counts, item) => {
|
||||||
|
counts[item] = (counts[item] ?? 0) + 1
|
||||||
|
return counts
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function topSources(items, key) {
|
||||||
|
const totals = {}
|
||||||
|
for (const item of items) {
|
||||||
|
for (const [sourceId, value] of Object.entries(item[key] ?? {})) {
|
||||||
|
totals[sourceId] = (totals[sourceId] ?? 0) + value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.entries(totals)
|
||||||
|
.sort((first, second) => second[1] - first[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([sourceId, value]) => ({ sourceId, value: round(value) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function average(values) {
|
||||||
|
return values.reduce((total, value) => total + value, 0) / Math.max(1, values.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(vector) {
|
||||||
|
const magnitude = Math.hypot(vector.x, vector.y)
|
||||||
|
if (magnitude <= 0.0001) return { x: 0, y: 0 }
|
||||||
|
return { x: vector.x / magnitude, y: vector.y / magnitude }
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value) {
|
||||||
|
return Math.round(value * 1000) / 1000
|
||||||
|
}
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
import http from 'node:http'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
|
||||||
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||||
|
const SIM_SCRIPT = resolve(SCRIPT_DIR, 'iwt2-pvp-roguelike-boss-sim.mjs')
|
||||||
|
const PORT = Number.parseInt(process.env.IWT2_SIM_GUI_PORT ?? '8787', 10)
|
||||||
|
|
||||||
|
const HEALER_CLASSES = ['dawnweaver', 'lifebinder', 'runesage']
|
||||||
|
const BOSS_IDS = [
|
||||||
|
'bulldrome',
|
||||||
|
'yian-kut-ku',
|
||||||
|
'great-jaggi',
|
||||||
|
'khezu',
|
||||||
|
'rathian',
|
||||||
|
'barroth',
|
||||||
|
'tobi-kadachi',
|
||||||
|
'rimebastion',
|
||||||
|
'ember-mantis-duelist',
|
||||||
|
'cinderback-ricochet',
|
||||||
|
'obsidian-ram-golem',
|
||||||
|
'stormcoil-wyrm',
|
||||||
|
'venom-orchid-hydra',
|
||||||
|
'sandglass-scorpion',
|
||||||
|
'crystal-bat-matriarch',
|
||||||
|
'hollowcrown-revenant',
|
||||||
|
]
|
||||||
|
|
||||||
|
let activeRun = null
|
||||||
|
|
||||||
|
const server = http.createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `localhost:${PORT}`}`)
|
||||||
|
if (request.method === 'GET' && url.pathname === '/') {
|
||||||
|
sendHtml(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && url.pathname === '/config') {
|
||||||
|
sendJson(response, { bosses: BOSS_IDS, classes: HEALER_CLASSES })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && url.pathname === '/run') {
|
||||||
|
const body = await readJson(request)
|
||||||
|
startRun(body, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && url.pathname === '/stop') {
|
||||||
|
stopRun()
|
||||||
|
sendJson(response, { ok: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.writeHead(404)
|
||||||
|
response.end('not found')
|
||||||
|
})
|
||||||
|
|
||||||
|
server.on('error', (error) => {
|
||||||
|
if (error.code === 'EADDRINUSE') {
|
||||||
|
console.error(`Port ${PORT} is already in use. Try: IWT2_SIM_GUI_PORT=${PORT + 1} node scripts/iwt2-pvp-sim-gui-server.mjs`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`IWT2 PvP sim GUI: http://localhost:${PORT}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
function startRun(options, response) {
|
||||||
|
if (activeRun) {
|
||||||
|
response.writeHead(409, { 'content-type': 'application/json' })
|
||||||
|
response.end(JSON.stringify({ error: 'A simulation is already running.' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = [
|
||||||
|
process.execPath,
|
||||||
|
SIM_SCRIPT,
|
||||||
|
'--classes',
|
||||||
|
listOrAll(options.classes, HEALER_CLASSES),
|
||||||
|
'--gear-level',
|
||||||
|
scalar(options.gearLevel, '5'),
|
||||||
|
'--boss-count',
|
||||||
|
scalar(options.bossCount, '2'),
|
||||||
|
'--bosses',
|
||||||
|
listOrAll(options.bosses, BOSS_IDS),
|
||||||
|
'--boss-hp-percent',
|
||||||
|
scalar(options.bossHpPercent, '100'),
|
||||||
|
'--boss-damage-percent',
|
||||||
|
scalar(options.bossDamagePercent, '100'),
|
||||||
|
'--stages',
|
||||||
|
scalar(options.stages, '1,2'),
|
||||||
|
'--seconds',
|
||||||
|
scalar(options.seconds, '180'),
|
||||||
|
'--workers',
|
||||||
|
scalar(options.workers, '8'),
|
||||||
|
'--repeats',
|
||||||
|
scalar(options.repeats, '1'),
|
||||||
|
'--required-bosses',
|
||||||
|
listOrNone(options.requiredBosses, BOSS_IDS),
|
||||||
|
'--top',
|
||||||
|
scalar(options.top, '24'),
|
||||||
|
]
|
||||||
|
|
||||||
|
response.writeHead(200, {
|
||||||
|
'cache-control': 'no-cache',
|
||||||
|
'connection': 'keep-alive',
|
||||||
|
'content-type': 'text/event-stream',
|
||||||
|
})
|
||||||
|
writeEvent(response, 'status', { command: command.join(' '), status: 'started' })
|
||||||
|
|
||||||
|
const child = spawn(command[0], command.slice(1), {
|
||||||
|
cwd: ROOT,
|
||||||
|
env: { ...process.env, VITE_CJS_IGNORE_WARNING: 'true' },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
activeRun = child
|
||||||
|
let output = ''
|
||||||
|
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
const text = chunk.toString()
|
||||||
|
output += text
|
||||||
|
writeEvent(response, 'chunk', { text })
|
||||||
|
})
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
const text = chunk.toString()
|
||||||
|
output += text
|
||||||
|
writeEvent(response, 'chunk', { text })
|
||||||
|
})
|
||||||
|
child.on('close', (code) => {
|
||||||
|
activeRun = null
|
||||||
|
writeEvent(response, 'done', {
|
||||||
|
code,
|
||||||
|
json: extractJson(output),
|
||||||
|
})
|
||||||
|
response.end()
|
||||||
|
})
|
||||||
|
child.on('error', (error) => {
|
||||||
|
activeRun = null
|
||||||
|
writeEvent(response, 'error', { error: String(error) })
|
||||||
|
response.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRun() {
|
||||||
|
if (!activeRun) return
|
||||||
|
activeRun.kill('SIGTERM')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendHtml(response) {
|
||||||
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||||
|
response.end(`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>IWT2 PvP Sim GUI</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111419; color: #eef2f6; }
|
||||||
|
body { margin: 0; min-height: 100vh; background: #111419; }
|
||||||
|
main { display: grid; grid-template-columns: 340px minmax(0, 1fr); min-height: 100vh; }
|
||||||
|
aside { border-right: 1px solid #2a3039; padding: 18px; background: #171b22; overflow: auto; }
|
||||||
|
section { padding: 18px; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
h1 { font-size: 18px; margin: 0 0 16px; }
|
||||||
|
h2 { font-size: 13px; margin: 18px 0 8px; color: #b7c2d0; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
label { display: flex; align-items: center; gap: 8px; margin: 7px 0; font-size: 13px; }
|
||||||
|
input[type="text"], input[type="number"] { width: 100%; box-sizing: border-box; background: #0f1217; color: #eef2f6; border: 1px solid #333b47; border-radius: 6px; padding: 8px 9px; }
|
||||||
|
input[type="checkbox"] { accent-color: #61dafb; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.bosses { max-height: 300px; overflow: auto; border: 1px solid #2a3039; border-radius: 6px; padding: 8px; background: #111419; }
|
||||||
|
.buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 16px; }
|
||||||
|
.estimate { display: grid; gap: 6px; background: #111419; border: 1px solid #2a3039; border-radius: 6px; color: #d9e3ee; font-size: 13px; margin-top: 16px; padding: 10px; }
|
||||||
|
.estimate strong { font-size: 18px; }
|
||||||
|
.estimate span { color: #96a3b5; }
|
||||||
|
button { border: 1px solid #3a4656; color: #eef2f6; background: #202733; border-radius: 6px; padding: 9px 10px; cursor: pointer; }
|
||||||
|
button.primary { background: #245a7a; border-color: #327aa3; }
|
||||||
|
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
pre { margin: 0; padding: 12px; overflow: auto; background: #0b0e12; border: 1px solid #2a3039; border-radius: 6px; min-height: 180px; white-space: pre; }
|
||||||
|
#dashboard { display: grid; gap: 12px; }
|
||||||
|
.empty { border: 1px dashed #3a4656; border-radius: 6px; color: #96a3b5; padding: 22px; text-align: center; }
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
||||||
|
.metric { background: #171b22; border: 1px solid #2a3039; border-radius: 6px; padding: 12px; }
|
||||||
|
.metric span { color: #96a3b5; display: block; font-size: 12px; margin-bottom: 6px; }
|
||||||
|
.metric strong { display: block; font-size: 24px; }
|
||||||
|
.panel { background: #171b22; border: 1px solid #2a3039; border-radius: 6px; overflow: hidden; }
|
||||||
|
.panel h2 { margin: 0; padding: 12px; border-bottom: 1px solid #2a3039; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
th, td { border-bottom: 1px solid #242a33; padding: 9px 10px; text-align: left; vertical-align: top; }
|
||||||
|
th { background: #111419; color: #b7c2d0; font-weight: 600; position: sticky; top: 0; }
|
||||||
|
tr:last-child td { border-bottom: 0; }
|
||||||
|
.number { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.pill { display: inline-block; border: 1px solid #3a4656; border-radius: 999px; color: #d9e3ee; background: #202733; padding: 2px 7px; margin: 0 4px 4px 0; font-size: 12px; }
|
||||||
|
.risk-high { color: #ff8d7a; }
|
||||||
|
.risk-mid { color: #ffd166; }
|
||||||
|
.risk-low { color: #7fe3a1; }
|
||||||
|
.bars { display: grid; gap: 9px; padding: 12px; }
|
||||||
|
.bar-row { display: grid; grid-template-columns: minmax(170px, 260px) 1fr 90px; gap: 10px; align-items: center; font-size: 13px; }
|
||||||
|
.bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d9e3ee; }
|
||||||
|
.bar-track { height: 12px; background: #0f1217; border: 1px solid #2a3039; border-radius: 999px; overflow: hidden; }
|
||||||
|
.bar-fill { height: 100%; background: linear-gradient(90deg, #61dafb, #7fe3a1); border-radius: inherit; }
|
||||||
|
.bar-value { color: #b7c2d0; font-variant-numeric: tabular-nums; text-align: right; }
|
||||||
|
details { border: 1px solid #2a3039; border-radius: 6px; overflow: hidden; }
|
||||||
|
summary { cursor: pointer; padding: 10px 12px; background: #171b22; color: #b7c2d0; }
|
||||||
|
#raw { min-height: 220px; border-radius: 0; border-left: 0; border-right: 0; border-bottom: 0; }
|
||||||
|
.status { color: #b7c2d0; font-size: 13px; }
|
||||||
|
@media (max-width: 1100px) { .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 820px) { main { grid-template-columns: 1fr; } aside { border-right: 0; border-bottom: 1px solid #2a3039; } .cards { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<aside>
|
||||||
|
<h1>IWT2 PvP Sim</h1>
|
||||||
|
<h2>Scalars</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<label>Gear <input id="gearLevel" type="number" value="5" min="0"></label>
|
||||||
|
<label>Boss count <input id="bossCount" type="number" value="2" min="1"></label>
|
||||||
|
<label>Boss HP % <input id="bossHpPercent" type="number" value="100" min="1"></label>
|
||||||
|
<label>Boss dmg % <input id="bossDamagePercent" type="number" value="100" min="1"></label>
|
||||||
|
<label>Stages <input id="stages" type="text" value="1,2"></label>
|
||||||
|
<label>Seconds <input id="seconds" type="number" value="180" min="1"></label>
|
||||||
|
<label>Workers <input id="workers" type="number" value="8" min="1"></label>
|
||||||
|
<label>Repeats <input id="repeats" type="number" value="1" min="1"></label>
|
||||||
|
<label>Top <input id="top" type="number" value="24" min="1"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Classes</h2>
|
||||||
|
<label><input id="allClasses" type="checkbox" checked> All classes</label>
|
||||||
|
<div id="classes"></div>
|
||||||
|
|
||||||
|
<h2>Bosses</h2>
|
||||||
|
<label><input id="allBosses" type="checkbox" checked> All bosses</label>
|
||||||
|
<div id="bosses" class="bosses"></div>
|
||||||
|
|
||||||
|
<h2>Required Bosses</h2>
|
||||||
|
<div id="requiredBosses" class="bosses"></div>
|
||||||
|
|
||||||
|
<div id="estimate" class="estimate"></div>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<button id="run" class="primary">Run</button>
|
||||||
|
<button id="stop" disabled>Stop</button>
|
||||||
|
<button id="copy">Copy Command</button>
|
||||||
|
<button id="save">Save JSON</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section>
|
||||||
|
<div id="status" class="status">Idle</div>
|
||||||
|
<div id="dashboard"><div class="empty">Run a simulation to see dashboard results.</div></div>
|
||||||
|
<details open>
|
||||||
|
<summary>Raw output</summary>
|
||||||
|
<pre id="raw"></pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const state = { lastJson: null, running: false, config: null };
|
||||||
|
const ids = ["gearLevel","bossCount","bossHpPercent","bossDamagePercent","stages","seconds","workers","repeats","top"];
|
||||||
|
const el = Object.fromEntries(ids.map(id => [id, document.getElementById(id)]));
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
const raw = document.getElementById("raw");
|
||||||
|
const dashboard = document.getElementById("dashboard");
|
||||||
|
const estimateBox = document.getElementById("estimate");
|
||||||
|
const run = document.getElementById("run");
|
||||||
|
const stop = document.getElementById("stop");
|
||||||
|
|
||||||
|
fetch("/config").then(r => r.json()).then(config => {
|
||||||
|
state.config = config;
|
||||||
|
renderChecks("classes", config.classes, true);
|
||||||
|
renderChecks("bosses", config.bosses, true);
|
||||||
|
renderChecks("requiredBosses", config.bosses, false);
|
||||||
|
wireAll("allClasses", "classes");
|
||||||
|
wireAll("allBosses", "bosses");
|
||||||
|
wireEstimateUpdates();
|
||||||
|
updateEstimate();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderChecks(containerId, values, checked) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
container.innerHTML = values.map(value => '<label><input type="checkbox" value="' + value + '"' + (checked ? ' checked' : '') + '> ' + value + '</label>').join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireAll(masterId, containerId) {
|
||||||
|
const master = document.getElementById(masterId);
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
master.addEventListener("change", () => {
|
||||||
|
container.querySelectorAll("input").forEach(input => input.checked = master.checked);
|
||||||
|
updateEstimate();
|
||||||
|
});
|
||||||
|
container.addEventListener("change", () => {
|
||||||
|
master.checked = [...container.querySelectorAll("input")].every(input => input.checked);
|
||||||
|
updateEstimate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selected(containerId, masterId) {
|
||||||
|
if (document.getElementById(masterId).checked) return ["all"];
|
||||||
|
const values = [...document.getElementById(containerId).querySelectorAll("input:checked")].map(input => input.value);
|
||||||
|
return values.length ? values : ["all"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedRequiredBosses() {
|
||||||
|
const values = [...document.getElementById("requiredBosses").querySelectorAll("input:checked")].map(input => input.value);
|
||||||
|
return values.length ? values : ["none"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function payload() {
|
||||||
|
const requiredBosses = selectedRequiredBosses();
|
||||||
|
let bosses = selected("bosses", "allBosses");
|
||||||
|
if (!bosses.includes("all") && !requiredBosses.includes("none")) {
|
||||||
|
bosses = [...new Set([...bosses, ...requiredBosses])];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bossCount: el.bossCount.value,
|
||||||
|
bossDamagePercent: el.bossDamagePercent.value,
|
||||||
|
bossHpPercent: el.bossHpPercent.value,
|
||||||
|
bosses,
|
||||||
|
classes: selected("classes", "allClasses"),
|
||||||
|
gearLevel: el.gearLevel.value,
|
||||||
|
repeats: el.repeats.value,
|
||||||
|
requiredBosses,
|
||||||
|
seconds: el.seconds.value,
|
||||||
|
stages: el.stages.value,
|
||||||
|
top: el.top.value,
|
||||||
|
workers: el.workers.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandFor(options) {
|
||||||
|
return ["node", "scripts/iwt2-pvp-roguelike-boss-sim.mjs",
|
||||||
|
"--classes", options.classes.join(","),
|
||||||
|
"--gear-level", options.gearLevel,
|
||||||
|
"--boss-count", options.bossCount,
|
||||||
|
"--bosses", options.bosses.join(","),
|
||||||
|
"--boss-hp-percent", options.bossHpPercent,
|
||||||
|
"--boss-damage-percent", options.bossDamagePercent,
|
||||||
|
"--stages", options.stages,
|
||||||
|
"--seconds", options.seconds,
|
||||||
|
"--workers", options.workers,
|
||||||
|
"--repeats", options.repeats,
|
||||||
|
"--required-bosses", options.requiredBosses.join(","),
|
||||||
|
"--top", options.top,
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireEstimateUpdates() {
|
||||||
|
document.querySelector("aside").addEventListener("input", updateEstimate);
|
||||||
|
document.querySelector("aside").addEventListener("change", updateEstimate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEstimate() {
|
||||||
|
if (!state.config) return;
|
||||||
|
const estimate = estimateFor(payload());
|
||||||
|
estimateBox.innerHTML = '<strong>' + number(estimate.trials) + ' tests</strong>' +
|
||||||
|
'<span>' + number(estimate.orderedBossGroupCount) + ' ordered boss groups x ' + number(estimate.classCount) + ' classes x ' + number(estimate.stageCount) + ' stages x ' + number(estimate.repeats) + ' repeats</span>' +
|
||||||
|
'<span>Max sim time: ' + duration(estimate.maxSimulatedSeconds) + ' / rough wall time: ' + duration(estimate.estimatedWallSeconds) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateFor(options) {
|
||||||
|
const bossPool = options.bosses.includes("all") ? state.config.bosses : options.bosses;
|
||||||
|
const requiredBosses = options.requiredBosses.includes("none")
|
||||||
|
? []
|
||||||
|
: options.requiredBosses.filter(bossId => bossPool.includes(bossId));
|
||||||
|
const bossCount = intValue(options.bossCount, 2);
|
||||||
|
const orderedBossGroupCount = countOrderedBossGroups(bossPool.length, bossCount, requiredBosses.length);
|
||||||
|
const classCount = options.classes.includes("all") ? state.config.classes.length : Math.max(1, options.classes.length);
|
||||||
|
const stageCount = Math.max(1, parseStages(options.stages).length);
|
||||||
|
const repeats = intValue(options.repeats, 1);
|
||||||
|
const seconds = numberValue(options.seconds, 180);
|
||||||
|
const workers = intValue(options.workers, 8);
|
||||||
|
const trials = orderedBossGroupCount * classCount * stageCount * repeats;
|
||||||
|
const maxSimulatedSeconds = trials * seconds;
|
||||||
|
const maxTicks = maxSimulatedSeconds * 30;
|
||||||
|
const estimatedWallSeconds = 0.8 + (maxTicks / (Math.max(1, workers) * 45000));
|
||||||
|
return { classCount, estimatedWallSeconds, maxSimulatedSeconds, orderedBossGroupCount, repeats, stageCount, trials };
|
||||||
|
}
|
||||||
|
|
||||||
|
function countOrderedBossGroups(poolSize, bossCount, requiredCount) {
|
||||||
|
const safeBossCount = Math.max(1, Math.min(poolSize, Math.floor(bossCount)));
|
||||||
|
if (requiredCount > safeBossCount || requiredCount > poolSize) return 0;
|
||||||
|
return combination(poolSize - requiredCount, safeBossCount - requiredCount) * permutation(safeBossCount, safeBossCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function combination(n, k) {
|
||||||
|
if (k < 0 || k > n) return 0;
|
||||||
|
const safeK = Math.min(k, n - k);
|
||||||
|
let result = 1;
|
||||||
|
for (let index = 1; index <= safeK; index += 1) {
|
||||||
|
result = (result * (n - safeK + index)) / index;
|
||||||
|
}
|
||||||
|
return Math.round(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function permutation(n, k) {
|
||||||
|
if (k < 0 || k > n) return 0;
|
||||||
|
let result = 1;
|
||||||
|
for (let value = n - k + 1; value <= n; value += 1) result *= value;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStages(value) {
|
||||||
|
return String(value).split(",").map(stage => Number.parseInt(stage.trim(), 10)).filter(Number.isFinite);
|
||||||
|
}
|
||||||
|
|
||||||
|
function intValue(value, fallback) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value, fallback) {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function duration(seconds) {
|
||||||
|
const safeSeconds = Math.max(0, Number(seconds) || 0);
|
||||||
|
if (safeSeconds < 60) return fixed(safeSeconds) + 's';
|
||||||
|
const minutes = Math.floor(safeSeconds / 60);
|
||||||
|
const remaining = Math.round(safeSeconds % 60);
|
||||||
|
if (minutes < 60) return minutes + 'm ' + remaining + 's';
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return hours + 'h ' + (minutes % 60) + 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function number(value) {
|
||||||
|
return Number(value ?? 0).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
run.addEventListener("click", async () => {
|
||||||
|
const options = payload();
|
||||||
|
const estimate = estimateFor(options);
|
||||||
|
raw.textContent = "$ " + commandFor(options) + "\\n" +
|
||||||
|
"# estimated tests: " + number(estimate.trials) + ", rough wall time: " + duration(estimate.estimatedWallSeconds) + "\\n\\n";
|
||||||
|
dashboard.innerHTML = '<div class="empty">Simulation running...</div>';
|
||||||
|
state.lastJson = null;
|
||||||
|
state.running = true;
|
||||||
|
run.disabled = true;
|
||||||
|
stop.disabled = false;
|
||||||
|
status.textContent = "Running " + number(estimate.trials) + " tests, rough estimate " + duration(estimate.estimatedWallSeconds) + "...";
|
||||||
|
const response = await fetch("/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(options) });
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
status.textContent = "Failed to start";
|
||||||
|
run.disabled = false;
|
||||||
|
stop.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parts = buffer.split("\\n\\n");
|
||||||
|
buffer = parts.pop() ?? "";
|
||||||
|
for (const part of parts) handleEvent(part);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleEvent(block) {
|
||||||
|
const lines = block.split("\\n");
|
||||||
|
const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim();
|
||||||
|
const data = JSON.parse(lines.find(line => line.startsWith("data:"))?.slice(5) ?? "{}");
|
||||||
|
if (event === "chunk") {
|
||||||
|
raw.textContent += data.text;
|
||||||
|
raw.scrollTop = raw.scrollHeight;
|
||||||
|
} else if (event === "status") {
|
||||||
|
status.textContent = data.status;
|
||||||
|
} else if (event === "done") {
|
||||||
|
state.running = false;
|
||||||
|
run.disabled = false;
|
||||||
|
stop.disabled = true;
|
||||||
|
status.textContent = "Finished: exit " + data.code;
|
||||||
|
state.lastJson = data.json;
|
||||||
|
dashboard.innerHTML = data.json ? renderDashboard(JSON.parse(data.json)) : '<div class="empty">No JSON result found.</div>';
|
||||||
|
} else if (event === "error") {
|
||||||
|
status.textContent = data.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboard(data) {
|
||||||
|
const combos = data.hardestCombos ?? [];
|
||||||
|
const stageCombos = data.hardestStageCombos ?? [];
|
||||||
|
const singleBosses = data.weakestSingleBosses ?? [];
|
||||||
|
const totalTrials = data.config?.orderedTrials ?? 0;
|
||||||
|
const totalFailures = combos.reduce((total, combo) => total + (combo.failures ?? 0), 0);
|
||||||
|
const worst = combos[0];
|
||||||
|
return [
|
||||||
|
'<div class="cards">',
|
||||||
|
metric('Trials', totalTrials),
|
||||||
|
metric('Elapsed', duration(data.config?.elapsedSeconds ?? 0)),
|
||||||
|
metric('Worst fail rate', worst ? percent(failRate(worst)) : 'n/a'),
|
||||||
|
metric('Gear / repeats', 'Gear +' + escapeHtml(data.config?.gearLevel ?? '?') + ' / ' + escapeHtml(data.config?.repeats ?? '?') + 'x'),
|
||||||
|
'</div>',
|
||||||
|
totalFailures === 0 ? '<div class="empty risk-low">No failures found in returned hardest-combo rows. Review deaths and fight time for near-fails.</div>' : '',
|
||||||
|
panel('Top Damage By Boss Mechanic', mechanicBars(data.topMechanics ?? [])),
|
||||||
|
panel('Hardest Combos', comboTable(combos, true)),
|
||||||
|
panel('Hardest By Stage', comboTable(stageCombos, false)),
|
||||||
|
panel('Bosses Associated With Most Risk', comboTable(singleBosses, true)),
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function metric(label, value) {
|
||||||
|
return '<div class="metric"><span>' + escapeHtml(label) + '</span><strong>' + value + '</strong></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function panel(title, content) {
|
||||||
|
return '<div class="panel"><h2>' + escapeHtml(title) + '</h2>' + content + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function comboTable(rows, showDamage) {
|
||||||
|
if (!rows.length) return '<div class="empty">No rows.</div>';
|
||||||
|
return '<table><thead><tr>' +
|
||||||
|
'<th>Combo</th><th class="number">Fail</th><th class="number">Win</th><th class="number">Deaths</th><th class="number">Time</th><th class="number">Boss HP Left</th><th>Top Mechanic</th><th>Top Source</th>' +
|
||||||
|
'</tr></thead><tbody>' +
|
||||||
|
rows.map(row => '<tr>' +
|
||||||
|
'<td><strong>' + escapeHtml(row.key) + '</strong><br>' + outcomePills(row.outcomes) + '</td>' +
|
||||||
|
'<td class="number ' + riskClass(failRate(row)) + '">' + escapeHtml(row.failures ?? 0) + '/' + escapeHtml(row.trials ?? 0) + '</td>' +
|
||||||
|
'<td class="number">' + percent(row.winRate ?? 0) + '</td>' +
|
||||||
|
'<td class="number">' + fixed(row.avgDeaths) + '</td>' +
|
||||||
|
'<td class="number">' + fixed(row.avgSeconds) + 's</td>' +
|
||||||
|
'<td class="number">' + percent(row.avgRemainingBossHealth ?? 0) + '</td>' +
|
||||||
|
'<td>' + damagePills(row.topMechanics) + '</td>' +
|
||||||
|
'<td>' + (showDamage ? damagePills(row.topDamageSources) : damagePills(row.topDamageSources)) + '</td>' +
|
||||||
|
'</tr>').join('') +
|
||||||
|
'</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mechanicBars(sources) {
|
||||||
|
if (!sources.length) return '<div class="empty">No mechanic damage recorded.</div>';
|
||||||
|
const max = Math.max(...sources.map(source => Number(source.value) || 0), 1);
|
||||||
|
return '<div class="bars">' + sources.slice(0, 12).map(source => {
|
||||||
|
const value = Number(source.value) || 0;
|
||||||
|
const width = Math.max(2, (value / max) * 100);
|
||||||
|
return '<div class="bar-row">' +
|
||||||
|
'<div class="bar-label" title="' + escapeHtml(source.sourceId) + '">' + escapeHtml(source.sourceId) + '</div>' +
|
||||||
|
'<div class="bar-track"><div class="bar-fill" style="width:' + width + '%"></div></div>' +
|
||||||
|
'<div class="bar-value">' + fixed(value) + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomePills(outcomes) {
|
||||||
|
if (!outcomes) return '';
|
||||||
|
return Object.entries(outcomes)
|
||||||
|
.map(([name, count]) => '<span class="pill">' + escapeHtml(name) + ': ' + escapeHtml(count) + '</span>')
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function damagePills(sources) {
|
||||||
|
if (!sources || sources.length === 0) return '<span class="pill">none</span>';
|
||||||
|
return sources.slice(0, 3)
|
||||||
|
.map(source => '<span class="pill">' + escapeHtml(source.sourceId) + ': ' + fixed(source.value) + '</span>')
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function failRate(row) {
|
||||||
|
return row.trials ? (row.failures ?? 0) / row.trials : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskClass(rate) {
|
||||||
|
if (rate >= 0.5) return 'risk-high';
|
||||||
|
if (rate > 0) return 'risk-mid';
|
||||||
|
return 'risk-low';
|
||||||
|
}
|
||||||
|
|
||||||
|
function percent(value) {
|
||||||
|
return fixed(value * 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixed(value) {
|
||||||
|
const number = Number(value ?? 0);
|
||||||
|
return Number.isInteger(number) ? String(number) : number.toFixed(3).replace(/0+$/, '').replace(/\\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
stop.addEventListener("click", () => {
|
||||||
|
fetch("/stop", { method: "POST" });
|
||||||
|
status.textContent = "Stopping...";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("copy").addEventListener("click", () => {
|
||||||
|
navigator.clipboard.writeText(commandFor(payload()));
|
||||||
|
status.textContent = "Command copied";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("save").addEventListener("click", () => {
|
||||||
|
if (!state.lastJson) return;
|
||||||
|
const blob = new Blob([state.lastJson], { type: "application/json" });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = "iwt2-pvp-roguelike-sim.json";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(request) {
|
||||||
|
return new Promise((resolveRequest, rejectRequest) => {
|
||||||
|
let body = ''
|
||||||
|
request.on('data', (chunk) => {
|
||||||
|
body += chunk.toString()
|
||||||
|
})
|
||||||
|
request.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolveRequest(body ? JSON.parse(body) : {})
|
||||||
|
} catch (error) {
|
||||||
|
rejectRequest(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
request.on('error', rejectRequest)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(response, value) {
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json' })
|
||||||
|
response.end(JSON.stringify(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEvent(response, event, data) {
|
||||||
|
response.write(`event: ${event}\n`)
|
||||||
|
response.write(`data: ${JSON.stringify(data)}\n\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listOrAll(value, allowed) {
|
||||||
|
if (!Array.isArray(value) || value.length === 0 || value.includes('all')) return 'all'
|
||||||
|
const selected = value.filter((item) => allowed.includes(item))
|
||||||
|
return selected.length ? selected.join(',') : 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
function listOrNone(value, allowed) {
|
||||||
|
if (!Array.isArray(value) || value.length === 0 || value.includes('none')) return 'none'
|
||||||
|
const selected = value.filter((item) => allowed.includes(item))
|
||||||
|
return selected.length ? selected.join(',') : 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function scalar(value, fallback) {
|
||||||
|
return value === undefined || value === null || value === '' ? fallback : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJson(output) {
|
||||||
|
const start = output.indexOf('{')
|
||||||
|
const end = output.lastIndexOf('}')
|
||||||
|
if (start < 0 || end < start) return ''
|
||||||
|
const candidate = output.slice(start, end + 1)
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(candidate), null, 2)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
try:
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import filedialog, messagebox, ttk
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
print(
|
||||||
|
"This Python install does not include Tkinter.\n"
|
||||||
|
"Use the browser GUI instead:\n"
|
||||||
|
" node scripts/iwt2-pvp-sim-gui-server.mjs\n"
|
||||||
|
"Then open http://localhost:8787\n"
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SIM_SCRIPT = ROOT / "scripts" / "iwt2-pvp-roguelike-boss-sim.mjs"
|
||||||
|
|
||||||
|
HEALER_CLASSES = ["dawnweaver", "lifebinder", "runesage"]
|
||||||
|
BOSS_IDS = [
|
||||||
|
"bulldrome",
|
||||||
|
"yian-kut-ku",
|
||||||
|
"great-jaggi",
|
||||||
|
"khezu",
|
||||||
|
"rathian",
|
||||||
|
"barroth",
|
||||||
|
"tobi-kadachi",
|
||||||
|
"rimebastion",
|
||||||
|
"ember-mantis-duelist",
|
||||||
|
"cinderback-ricochet",
|
||||||
|
"obsidian-ram-golem",
|
||||||
|
"stormcoil-wyrm",
|
||||||
|
"venom-orchid-hydra",
|
||||||
|
"sandglass-scorpion",
|
||||||
|
"crystal-bat-matriarch",
|
||||||
|
"hollowcrown-revenant",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SimGui(tk.Tk):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.title("IWT2 PvP Roguelike Sim")
|
||||||
|
self.geometry("1120x780")
|
||||||
|
self.minsize(920, 640)
|
||||||
|
|
||||||
|
self.output_queue: queue.Queue[tuple[str, str | None]] = queue.Queue()
|
||||||
|
self.process: subprocess.Popen[str] | None = None
|
||||||
|
self.last_json = ""
|
||||||
|
|
||||||
|
self.class_vars = {name: tk.BooleanVar(value=True) for name in HEALER_CLASSES}
|
||||||
|
self.boss_vars = {boss_id: tk.BooleanVar(value=True) for boss_id in BOSS_IDS}
|
||||||
|
self.all_classes_var = tk.BooleanVar(value=True)
|
||||||
|
self.all_bosses_var = tk.BooleanVar(value=True)
|
||||||
|
|
||||||
|
self.gear_level_var = tk.StringVar(value="5")
|
||||||
|
self.boss_count_var = tk.StringVar(value="2")
|
||||||
|
self.hp_percent_var = tk.StringVar(value="100")
|
||||||
|
self.damage_percent_var = tk.StringVar(value="100")
|
||||||
|
self.stages_var = tk.StringVar(value="1,2")
|
||||||
|
self.seconds_var = tk.StringVar(value="180")
|
||||||
|
self.workers_var = tk.StringVar(value="8")
|
||||||
|
self.top_var = tk.StringVar(value="24")
|
||||||
|
self.status_var = tk.StringVar(value="Idle")
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self.after(100, self._drain_output_queue)
|
||||||
|
|
||||||
|
def _build_ui(self) -> None:
|
||||||
|
self.columnconfigure(0, weight=0)
|
||||||
|
self.columnconfigure(1, weight=1)
|
||||||
|
self.rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
controls = ttk.Frame(self, padding=12)
|
||||||
|
controls.grid(row=0, column=0, sticky="nsew")
|
||||||
|
|
||||||
|
output_frame = ttk.Frame(self, padding=(0, 12, 12, 12))
|
||||||
|
output_frame.grid(row=0, column=1, sticky="nsew")
|
||||||
|
output_frame.columnconfigure(0, weight=1)
|
||||||
|
output_frame.rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
self._build_controls(controls)
|
||||||
|
|
||||||
|
output_header = ttk.Frame(output_frame)
|
||||||
|
output_header.grid(row=0, column=0, sticky="ew", pady=(0, 8))
|
||||||
|
output_header.columnconfigure(0, weight=1)
|
||||||
|
ttk.Label(output_header, textvariable=self.status_var).grid(row=0, column=0, sticky="w")
|
||||||
|
ttk.Button(output_header, text="Save JSON", command=self._save_json).grid(row=0, column=1, padx=(8, 0))
|
||||||
|
ttk.Button(output_header, text="Clear", command=self._clear_output).grid(row=0, column=2, padx=(8, 0))
|
||||||
|
|
||||||
|
self.output = tk.Text(output_frame, wrap="none", font=("Menlo", 11))
|
||||||
|
self.output.grid(row=1, column=0, sticky="nsew")
|
||||||
|
yscroll = ttk.Scrollbar(output_frame, orient="vertical", command=self.output.yview)
|
||||||
|
yscroll.grid(row=1, column=1, sticky="ns")
|
||||||
|
xscroll = ttk.Scrollbar(output_frame, orient="horizontal", command=self.output.xview)
|
||||||
|
xscroll.grid(row=2, column=0, sticky="ew")
|
||||||
|
self.output.configure(yscrollcommand=yscroll.set, xscrollcommand=xscroll.set)
|
||||||
|
|
||||||
|
def _build_controls(self, parent: ttk.Frame) -> None:
|
||||||
|
parent.columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
ttk.Label(parent, text="Simulation").grid(row=0, column=0, sticky="w")
|
||||||
|
scalar_grid = ttk.Frame(parent)
|
||||||
|
scalar_grid.grid(row=1, column=0, sticky="ew", pady=(8, 12))
|
||||||
|
for column in range(2):
|
||||||
|
scalar_grid.columnconfigure(column, weight=1)
|
||||||
|
|
||||||
|
fields = [
|
||||||
|
("Gear level", self.gear_level_var),
|
||||||
|
("Boss count", self.boss_count_var),
|
||||||
|
("Boss HP %", self.hp_percent_var),
|
||||||
|
("Boss damage %", self.damage_percent_var),
|
||||||
|
("Stages", self.stages_var),
|
||||||
|
("Seconds", self.seconds_var),
|
||||||
|
("Workers", self.workers_var),
|
||||||
|
("Top rows", self.top_var),
|
||||||
|
]
|
||||||
|
for index, (label, variable) in enumerate(fields):
|
||||||
|
row = index // 2
|
||||||
|
column = (index % 2) * 2
|
||||||
|
ttk.Label(scalar_grid, text=label).grid(row=row, column=column, sticky="w", padx=(0, 6), pady=3)
|
||||||
|
ttk.Entry(scalar_grid, textvariable=variable, width=10).grid(row=row, column=column + 1, sticky="ew", pady=3)
|
||||||
|
|
||||||
|
ttk.Checkbutton(
|
||||||
|
parent,
|
||||||
|
text="All classes",
|
||||||
|
variable=self.all_classes_var,
|
||||||
|
command=self._toggle_all_classes,
|
||||||
|
).grid(row=2, column=0, sticky="w")
|
||||||
|
class_frame = ttk.Frame(parent)
|
||||||
|
class_frame.grid(row=3, column=0, sticky="ew", pady=(4, 12))
|
||||||
|
for index, class_id in enumerate(HEALER_CLASSES):
|
||||||
|
ttk.Checkbutton(
|
||||||
|
class_frame,
|
||||||
|
text=class_id,
|
||||||
|
variable=self.class_vars[class_id],
|
||||||
|
command=self._sync_all_classes,
|
||||||
|
).grid(row=0, column=index, sticky="w", padx=(0, 10))
|
||||||
|
|
||||||
|
ttk.Checkbutton(
|
||||||
|
parent,
|
||||||
|
text="All bosses",
|
||||||
|
variable=self.all_bosses_var,
|
||||||
|
command=self._toggle_all_bosses,
|
||||||
|
).grid(row=4, column=0, sticky="w")
|
||||||
|
|
||||||
|
boss_canvas = tk.Canvas(parent, height=300, highlightthickness=0)
|
||||||
|
boss_scroll = ttk.Scrollbar(parent, orient="vertical", command=boss_canvas.yview)
|
||||||
|
boss_frame = ttk.Frame(boss_canvas)
|
||||||
|
boss_frame.bind(
|
||||||
|
"<Configure>",
|
||||||
|
lambda _event: boss_canvas.configure(scrollregion=boss_canvas.bbox("all")),
|
||||||
|
)
|
||||||
|
boss_canvas.create_window((0, 0), window=boss_frame, anchor="nw")
|
||||||
|
boss_canvas.configure(yscrollcommand=boss_scroll.set)
|
||||||
|
boss_canvas.grid(row=5, column=0, sticky="nsew", pady=(4, 12))
|
||||||
|
boss_scroll.grid(row=5, column=1, sticky="ns", pady=(4, 12))
|
||||||
|
|
||||||
|
for index, boss_id in enumerate(BOSS_IDS):
|
||||||
|
ttk.Checkbutton(
|
||||||
|
boss_frame,
|
||||||
|
text=boss_id,
|
||||||
|
variable=self.boss_vars[boss_id],
|
||||||
|
command=self._sync_all_bosses,
|
||||||
|
).grid(row=index, column=0, sticky="w", pady=2)
|
||||||
|
|
||||||
|
button_row = ttk.Frame(parent)
|
||||||
|
button_row.grid(row=6, column=0, sticky="ew")
|
||||||
|
button_row.columnconfigure(0, weight=1)
|
||||||
|
self.run_button = ttk.Button(button_row, text="Run Simulation", command=self._start_sim)
|
||||||
|
self.run_button.grid(row=0, column=0, sticky="ew")
|
||||||
|
self.stop_button = ttk.Button(button_row, text="Stop", command=self._stop_sim, state="disabled")
|
||||||
|
self.stop_button.grid(row=0, column=1, padx=(8, 0))
|
||||||
|
|
||||||
|
ttk.Button(parent, text="Copy Command", command=self._copy_command).grid(row=7, column=0, sticky="ew", pady=(8, 0))
|
||||||
|
|
||||||
|
def _toggle_all_classes(self) -> None:
|
||||||
|
value = self.all_classes_var.get()
|
||||||
|
for variable in self.class_vars.values():
|
||||||
|
variable.set(value)
|
||||||
|
|
||||||
|
def _sync_all_classes(self) -> None:
|
||||||
|
self.all_classes_var.set(all(variable.get() for variable in self.class_vars.values()))
|
||||||
|
|
||||||
|
def _toggle_all_bosses(self) -> None:
|
||||||
|
value = self.all_bosses_var.get()
|
||||||
|
for variable in self.boss_vars.values():
|
||||||
|
variable.set(value)
|
||||||
|
|
||||||
|
def _sync_all_bosses(self) -> None:
|
||||||
|
self.all_bosses_var.set(all(variable.get() for variable in self.boss_vars.values()))
|
||||||
|
|
||||||
|
def _selected_classes(self) -> str:
|
||||||
|
if self.all_classes_var.get():
|
||||||
|
return "all"
|
||||||
|
selected = [name for name, variable in self.class_vars.items() if variable.get()]
|
||||||
|
return ",".join(selected) if selected else "all"
|
||||||
|
|
||||||
|
def _selected_bosses(self) -> str:
|
||||||
|
if self.all_bosses_var.get():
|
||||||
|
return "all"
|
||||||
|
selected = [boss_id for boss_id, variable in self.boss_vars.items() if variable.get()]
|
||||||
|
return ",".join(selected) if selected else "all"
|
||||||
|
|
||||||
|
def _command(self) -> list[str]:
|
||||||
|
return [
|
||||||
|
"node",
|
||||||
|
str(SIM_SCRIPT),
|
||||||
|
"--classes",
|
||||||
|
self._selected_classes(),
|
||||||
|
"--gear-level",
|
||||||
|
self.gear_level_var.get().strip() or "5",
|
||||||
|
"--boss-count",
|
||||||
|
self.boss_count_var.get().strip() or "2",
|
||||||
|
"--bosses",
|
||||||
|
self._selected_bosses(),
|
||||||
|
"--boss-hp-percent",
|
||||||
|
self.hp_percent_var.get().strip() or "100",
|
||||||
|
"--boss-damage-percent",
|
||||||
|
self.damage_percent_var.get().strip() or "100",
|
||||||
|
"--stages",
|
||||||
|
self.stages_var.get().strip() or "1,2",
|
||||||
|
"--seconds",
|
||||||
|
self.seconds_var.get().strip() or "180",
|
||||||
|
"--workers",
|
||||||
|
self.workers_var.get().strip() or "8",
|
||||||
|
"--top",
|
||||||
|
self.top_var.get().strip() or "24",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _start_sim(self) -> None:
|
||||||
|
if self.process is not None:
|
||||||
|
return
|
||||||
|
command = self._command()
|
||||||
|
self._clear_output()
|
||||||
|
self._append_output("$ " + " ".join(command) + "\n\n")
|
||||||
|
self.status_var.set("Running...")
|
||||||
|
self.run_button.configure(state="disabled")
|
||||||
|
self.stop_button.configure(state="normal")
|
||||||
|
|
||||||
|
thread = threading.Thread(target=self._run_subprocess, args=(command,), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _run_subprocess(self, command: list[str]) -> None:
|
||||||
|
try:
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
cwd=ROOT,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
assert self.process.stdout is not None
|
||||||
|
chunks: list[str] = []
|
||||||
|
for line in self.process.stdout:
|
||||||
|
chunks.append(line)
|
||||||
|
self.output_queue.put(("line", line))
|
||||||
|
exit_code = self.process.wait()
|
||||||
|
output = "".join(chunks)
|
||||||
|
self.output_queue.put(("done", json.dumps({"exitCode": exit_code, "output": output})))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.output_queue.put(("error", str(exc)))
|
||||||
|
|
||||||
|
def _drain_output_queue(self) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
kind, payload = self.output_queue.get_nowait()
|
||||||
|
if kind == "line" and payload is not None:
|
||||||
|
self._append_output(payload)
|
||||||
|
elif kind == "done" and payload is not None:
|
||||||
|
result = json.loads(payload)
|
||||||
|
self.last_json = self._extract_json(result["output"])
|
||||||
|
self.status_var.set(f"Finished: exit {result['exitCode']}")
|
||||||
|
self.process = None
|
||||||
|
self.run_button.configure(state="normal")
|
||||||
|
self.stop_button.configure(state="disabled")
|
||||||
|
elif kind == "error" and payload is not None:
|
||||||
|
self.status_var.set("Failed")
|
||||||
|
self._append_output(f"\nERROR: {payload}\n")
|
||||||
|
self.process = None
|
||||||
|
self.run_button.configure(state="normal")
|
||||||
|
self.stop_button.configure(state="disabled")
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
self.after(100, self._drain_output_queue)
|
||||||
|
|
||||||
|
def _stop_sim(self) -> None:
|
||||||
|
if self.process is None:
|
||||||
|
return
|
||||||
|
self.process.terminate()
|
||||||
|
self.status_var.set("Stopping...")
|
||||||
|
|
||||||
|
def _clear_output(self) -> None:
|
||||||
|
self.output.delete("1.0", "end")
|
||||||
|
self.last_json = ""
|
||||||
|
|
||||||
|
def _append_output(self, text: str) -> None:
|
||||||
|
self.output.insert("end", text)
|
||||||
|
self.output.see("end")
|
||||||
|
|
||||||
|
def _copy_command(self) -> None:
|
||||||
|
command = " ".join(self._command())
|
||||||
|
self.clipboard_clear()
|
||||||
|
self.clipboard_append(command)
|
||||||
|
self.status_var.set("Command copied")
|
||||||
|
|
||||||
|
def _save_json(self) -> None:
|
||||||
|
output = self.last_json or self._extract_json(self.output.get("1.0", "end"))
|
||||||
|
if not output:
|
||||||
|
messagebox.showinfo("No JSON", "Run a simulation first.")
|
||||||
|
return
|
||||||
|
path = filedialog.asksaveasfilename(
|
||||||
|
defaultextension=".json",
|
||||||
|
filetypes=[("JSON", "*.json"), ("All files", "*.*")],
|
||||||
|
initialfile="iwt2-pvp-roguelike-sim.json",
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
Path(path).write_text(output, encoding="utf-8")
|
||||||
|
self.status_var.set(f"Saved {path}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_json(text: str) -> str:
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}")
|
||||||
|
if start < 0 or end < start:
|
||||||
|
return ""
|
||||||
|
candidate = text[start : end + 1]
|
||||||
|
try:
|
||||||
|
parsed = json.loads(candidate)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return ""
|
||||||
|
return json.dumps(parsed, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
SimGui().mainloop()
|
||||||
+59
-15
@@ -212,6 +212,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-arena-layout {
|
.iwt2-arena-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 172px minmax(0, 1fr);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
.iwt2-arena-stage {
|
.iwt2-arena-stage {
|
||||||
background: #090c10;
|
background: #090c10;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
grid-column: 2;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -317,6 +320,22 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-bar em {
|
||||||
|
color: #fff7df;
|
||||||
|
font-size: 0.56rem;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
left: 0;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 1px 0 #050608;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-bar.boss span {
|
.iwt2-bar.boss span {
|
||||||
background: #dc5162;
|
background: #dc5162;
|
||||||
}
|
}
|
||||||
@@ -337,17 +356,18 @@
|
|||||||
.iwt2-party-list {
|
.iwt2-party-list {
|
||||||
background: rgba(15, 19, 26, 0.88);
|
background: rgba(15, 19, 26, 0.88);
|
||||||
border: 0;
|
border: 0;
|
||||||
left: 28px;
|
bottom: 0;
|
||||||
max-height: calc(100% - 112px);
|
left: 0;
|
||||||
|
max-height: none;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 28px;
|
top: 0;
|
||||||
width: 172px;
|
width: 172px;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-content: start;
|
align-content: center;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1099,7 +1119,8 @@
|
|||||||
.iwt2-gear-class,
|
.iwt2-gear-class,
|
||||||
.iwt2-gear-slot,
|
.iwt2-gear-slot,
|
||||||
.iwt2-infusion-row,
|
.iwt2-infusion-row,
|
||||||
.iwt2-gear-upgrade-button {
|
.iwt2-gear-upgrade-button,
|
||||||
|
.iwt2-gear-back {
|
||||||
background: #151922;
|
background: #151922;
|
||||||
border: 2px solid #08090d;
|
border: 2px solid #08090d;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -1177,7 +1198,8 @@
|
|||||||
.iwt2-gear-class.game-selected,
|
.iwt2-gear-class.game-selected,
|
||||||
.iwt2-gear-slot.game-selected,
|
.iwt2-gear-slot.game-selected,
|
||||||
.iwt2-infusion-row.game-selected,
|
.iwt2-infusion-row.game-selected,
|
||||||
.iwt2-gear-upgrade-button.game-selected {
|
.iwt2-gear-upgrade-button.game-selected,
|
||||||
|
.iwt2-gear-back.game-selected {
|
||||||
outline-color: #e5b95f;
|
outline-color: #e5b95f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1187,6 +1209,12 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-back {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-gear-upgrade-button:disabled,
|
.iwt2-gear-upgrade-button:disabled,
|
||||||
.iwt2-infusion-row:disabled {
|
.iwt2-infusion-row:disabled {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -1401,6 +1429,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1000px) and (max-height: 620px) {
|
@media (max-width: 1000px) and (max-height: 620px) {
|
||||||
|
.iwt2-arena-layout {
|
||||||
|
grid-template-columns: 154px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.game-shell.iwt2-shell {
|
.game-shell.iwt2-shell {
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
width: min(100%, calc(100% - 20px));
|
width: min(100%, calc(100% - 20px));
|
||||||
@@ -1640,6 +1672,11 @@
|
|||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iwt2-gear-back {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.iwt2-gear-detail {
|
.iwt2-gear-detail {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
@@ -1735,20 +1772,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-list {
|
.iwt2-party-list {
|
||||||
gap: 5px;
|
align-content: center;
|
||||||
left: 10px;
|
gap: 7px;
|
||||||
max-height: calc(100% - 84px);
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
max-height: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
top: 10px;
|
top: 0;
|
||||||
width: 154px;
|
width: 154px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row {
|
.iwt2-party-row {
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
grid-template-columns: 22px minmax(0, 1fr);
|
grid-template-columns: 22px minmax(0, 1fr);
|
||||||
height: 62px;
|
height: 70px;
|
||||||
min-height: 62px;
|
min-height: 70px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1782,8 +1821,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-row .iwt2-bar {
|
.iwt2-party-row .iwt2-bar {
|
||||||
height: 7px;
|
height: 12px;
|
||||||
margin-top: 2px;
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iwt2-party-row .iwt2-bar.mana {
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iwt2-party-effects {
|
.iwt2-party-effects {
|
||||||
@@ -1803,6 +1846,7 @@
|
|||||||
|
|
||||||
.iwt2-ability-bar {
|
.iwt2-ability-bar {
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
|
display: none;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
grid-template-columns: repeat(6, 36px);
|
grid-template-columns: repeat(6, 36px);
|
||||||
max-width: 256px;
|
max-width: 256px;
|
||||||
|
|||||||
+18
-2
@@ -435,6 +435,10 @@ function hasDedicatedGameNavigation() {
|
|||||||
).some(isVisible)
|
).some(isVisible)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDedicatedNavigationAction(action: InputAction) {
|
||||||
|
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
|
||||||
|
}
|
||||||
|
|
||||||
function dispatchGameAction(action: InputAction, device: InputDevice) {
|
function dispatchGameAction(action: InputAction, device: InputDevice) {
|
||||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
||||||
detail: { action, device },
|
detail: { action, device },
|
||||||
@@ -621,7 +625,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
setLastDevice(device)
|
setLastDevice(device)
|
||||||
document.documentElement.dataset.inputDevice = device
|
document.documentElement.dataset.inputDevice = device
|
||||||
|
|
||||||
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
|
if (
|
||||||
|
device === 'controller'
|
||||||
|
&& dedicatedNavAction
|
||||||
|
&& !keyboardInputRef.current
|
||||||
|
&& hasDedicatedGameNavigation()
|
||||||
|
) {
|
||||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||||
dispatchGameAction(action, device)
|
dispatchGameAction(action, device)
|
||||||
return
|
return
|
||||||
@@ -709,6 +718,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
'navigateRight',
|
'navigateRight',
|
||||||
'confirm',
|
'confirm',
|
||||||
'back',
|
'back',
|
||||||
|
'pause',
|
||||||
] satisfies InputAction[]
|
] satisfies InputAction[]
|
||||||
const directTargetActions = [
|
const directTargetActions = [
|
||||||
'targetParty1',
|
'targetParty1',
|
||||||
@@ -738,7 +748,13 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
'navigateLeft',
|
'navigateLeft',
|
||||||
'navigateRight',
|
'navigateRight',
|
||||||
] satisfies InputAction[]
|
] satisfies InputAction[]
|
||||||
const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
|
const dedicatedNavigationActive = hasDedicatedGameNavigation() && (!combatActive || uiOverlay)
|
||||||
|
const action = dedicatedNavigationActive
|
||||||
|
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
|
||||||
|
?? (DPAD_NAV_ACTIONS[token] && isDedicatedNavigationAction(DPAD_NAV_ACTIONS[token])
|
||||||
|
? DPAD_NAV_ACTIONS[token]
|
||||||
|
: undefined)
|
||||||
|
: DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
|
||||||
? DPAD_NAV_ACTIONS[token]
|
? DPAD_NAV_ACTIONS[token]
|
||||||
: uiOverlay
|
: uiOverlay
|
||||||
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
|
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ import {
|
|||||||
type Iwt2RoguelikeSelfBuffId,
|
type Iwt2RoguelikeSelfBuffId,
|
||||||
type Iwt2RoguelikeVariant,
|
type Iwt2RoguelikeVariant,
|
||||||
} from './content/roguelike'
|
} from './content/roguelike'
|
||||||
|
import {
|
||||||
|
createIwt2WeightedRoguelikeBossPair,
|
||||||
|
createUniformIwt2RoguelikeBossPair,
|
||||||
|
enabledIwt2RoguelikeBossPool,
|
||||||
|
IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED,
|
||||||
|
IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS,
|
||||||
|
IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED,
|
||||||
|
IWT2_PVP_STADIUM_ENABLED_BOSS_IDS,
|
||||||
|
IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
||||||
|
IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
||||||
|
IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED,
|
||||||
|
} from './content/roguelikeBossProgression'
|
||||||
|
|
||||||
type Iwt2Screen =
|
type Iwt2Screen =
|
||||||
| 'menu'
|
| 'menu'
|
||||||
@@ -64,9 +76,11 @@ type Iwt2Screen =
|
|||||||
const IWT2_MENU_COLUMNS = 2
|
const IWT2_MENU_COLUMNS = 2
|
||||||
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||||
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
|
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
|
||||||
|
const IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD = 5
|
||||||
|
|
||||||
type Iwt2RoguelikeRunState = {
|
type Iwt2RoguelikeRunState = {
|
||||||
bossIds: Iwt2BossId[]
|
bossIds: Iwt2BossId[]
|
||||||
|
bossesDefeated: number
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
contentType: Iwt2RoguelikeContentType
|
contentType: Iwt2RoguelikeContentType
|
||||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||||
@@ -244,10 +258,13 @@ export function IWantToHeal2App({
|
|||||||
buffs: roguelikeRun.buffs,
|
buffs: roguelikeRun.buffs,
|
||||||
contentType: roguelikeRun.contentType,
|
contentType: roguelikeRun.contentType,
|
||||||
debuffs: roguelikeRun.debuffs,
|
debuffs: roguelikeRun.debuffs,
|
||||||
|
greenCoinThreshold: IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD,
|
||||||
|
bossesDefeated: roguelikeRun.bossesDefeated,
|
||||||
onVictory: () => {
|
onVictory: () => {
|
||||||
setRoguelikeRun((current) => current
|
setRoguelikeRun((current) => current
|
||||||
? {
|
? {
|
||||||
...current,
|
...current,
|
||||||
|
bossesDefeated: current.bossesDefeated + current.bossIds.length,
|
||||||
...buildRoguelikeChoices(save, current.variant),
|
...buildRoguelikeChoices(save, current.variant),
|
||||||
}
|
}
|
||||||
: current)
|
: current)
|
||||||
@@ -522,7 +539,8 @@ function createRoguelikeRun(
|
|||||||
contentType: Iwt2RoguelikeContentType,
|
contentType: Iwt2RoguelikeContentType,
|
||||||
): Iwt2RoguelikeRunState {
|
): Iwt2RoguelikeRunState {
|
||||||
return {
|
return {
|
||||||
bossIds: createRandomRoguelikeBossPair(),
|
bossIds: createRoguelikeBossPair(variant, contentType, 1, 0),
|
||||||
|
bossesDefeated: 0,
|
||||||
buffs: [],
|
buffs: [],
|
||||||
contentType,
|
contentType,
|
||||||
debuffs: [],
|
debuffs: [],
|
||||||
@@ -590,15 +608,45 @@ function applyRoguelikeChoice(
|
|||||||
return {
|
return {
|
||||||
...run,
|
...run,
|
||||||
...nextBase,
|
...nextBase,
|
||||||
bossIds: createRandomRoguelikeBossPair(),
|
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1, run.bossesDefeated),
|
||||||
...buildRoguelikeChoices(save, run.variant),
|
...buildRoguelikeChoices(save, run.variant),
|
||||||
stage: run.stage + 1,
|
stage: run.stage + 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRandomRoguelikeBossPair(): Iwt2BossId[] {
|
function createRoguelikeBossPair(
|
||||||
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
variant: Iwt2RoguelikeVariant,
|
||||||
return chooseRunChoices(pool, 2)
|
contentType: Iwt2RoguelikeContentType,
|
||||||
|
stage: number,
|
||||||
|
bossesDefeated: number,
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
const weightedProgressionEnabled = variant === 'pve'
|
||||||
|
? IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
|
: contentType === 'stadium'
|
||||||
|
? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
|
: IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
|
const bossPool = roguelikeBossPoolFor(variant, contentType)
|
||||||
|
const count = bossesDefeated >= IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD ? 3 : 2
|
||||||
|
|
||||||
|
if (weightedProgressionEnabled) {
|
||||||
|
return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool, count })
|
||||||
|
}
|
||||||
|
return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool, count })
|
||||||
|
}
|
||||||
|
|
||||||
|
function roguelikeBossPoolFor(
|
||||||
|
variant: Iwt2RoguelikeVariant,
|
||||||
|
contentType: Iwt2RoguelikeContentType,
|
||||||
|
): Iwt2BossId[] | undefined {
|
||||||
|
if (variant !== 'pvp') return undefined
|
||||||
|
if (contentType === 'stadium') {
|
||||||
|
return IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED
|
||||||
|
? enabledIwt2RoguelikeBossPool(IWT2_PVP_STADIUM_ENABLED_BOSS_IDS)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
return IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED
|
||||||
|
? enabledIwt2RoguelikeBossPool(IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
|
function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
export function ArenaBar({
|
export function ArenaBar({
|
||||||
className = '',
|
className = '',
|
||||||
current,
|
current,
|
||||||
|
label,
|
||||||
max,
|
max,
|
||||||
shield = 0,
|
shield = 0,
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
current: number
|
current: number
|
||||||
|
label?: string
|
||||||
max: number
|
max: number
|
||||||
shield?: number
|
shield?: number
|
||||||
}) {
|
}) {
|
||||||
@@ -15,6 +17,7 @@ export function ArenaBar({
|
|||||||
<div className={`iwt2-bar ${className}`}>
|
<div className={`iwt2-bar ${className}`}>
|
||||||
<span style={{ width: `${percent}%` }} />
|
<span style={{ width: `${percent}%` }} />
|
||||||
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
|
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
|
||||||
|
{label && <em>{label}</em>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,8 @@ export function PartyFrames({
|
|||||||
<div>
|
<div>
|
||||||
<div className="iwt2-party-row-title">
|
<div className="iwt2-party-row-title">
|
||||||
<strong>{meta.name}</strong>
|
<strong>{meta.name}</strong>
|
||||||
<small>{Math.ceil(member.health)}</small>
|
|
||||||
</div>
|
</div>
|
||||||
<ArenaBar className="hp" current={member.health} max={member.maxHealth} shield={member.shield} />
|
<ArenaBar className="hp" current={member.health} label={`${Math.ceil(member.maxHealth)}`} max={member.maxHealth} shield={member.shield} />
|
||||||
{member.maxMana > 0 && (
|
{member.maxMana > 0 && (
|
||||||
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
|
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -220,11 +220,11 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
|||||||
firePuddleRadius: 42,
|
firePuddleRadius: 42,
|
||||||
firePuddleDamage: 15,
|
firePuddleDamage: 15,
|
||||||
firePuddleSeconds: 8,
|
firePuddleSeconds: 8,
|
||||||
birdWaveThresholds: [0.9, 0.4],
|
birdWaveThresholds: [0.5],
|
||||||
birdFlightCooldown: 10,
|
birdFlightCooldown: 10,
|
||||||
birdFlightWindup: 0.85,
|
birdFlightWindup: 0.85,
|
||||||
birdFlightSpeed: 360,
|
birdFlightSpeed: 360,
|
||||||
birdHealth: 100,
|
birdHealth: 55,
|
||||||
birdRadius: 16,
|
birdRadius: 16,
|
||||||
birdContactDamage: 26,
|
birdContactDamage: 26,
|
||||||
birdStunSeconds: 0.75,
|
birdStunSeconds: 0.75,
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
|||||||
url: '/iwt2/classes/arena/layers/warrior-weapon-axe.png',
|
url: '/iwt2/classes/arena/layers/warrior-weapon-axe.png',
|
||||||
drawOrder: 'front',
|
drawOrder: 'front',
|
||||||
heightScale: 1.72,
|
heightScale: 1.72,
|
||||||
offsetXScale: 0.48,
|
offsetXScale: 0.38,
|
||||||
offsetYScale: -2.05,
|
offsetYScale: -0.88,
|
||||||
originX: 0.18,
|
originX: 0.18,
|
||||||
originY: 0.5,
|
originY: 0.5,
|
||||||
attackMotion: {
|
attackMotion: {
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { Iwt2BossId } from './bosses'
|
|||||||
import type { Iwt2PlayerClassId } from './classes'
|
import type { Iwt2PlayerClassId } from './classes'
|
||||||
import { iwt2BossCoinRewardFor } from './bossRewards'
|
import { iwt2BossCoinRewardFor } from './bossRewards'
|
||||||
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
import type { Iwt2RoguelikeSelfBuffId } from './roguelike'
|
||||||
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
|
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
|
||||||
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
|
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
|
||||||
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet'
|
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet'
|
||||||
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5
|
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
|
||||||
|
export type Iwt2PassiveInfusionId = Exclude<Iwt2RoguelikeSelfBuffId, 'revive-party-members'>
|
||||||
export type Iwt2GearStatId =
|
export type Iwt2GearStatId =
|
||||||
| 'maxHealth'
|
| 'maxHealth'
|
||||||
| 'moveSpeed'
|
| 'moveSpeed'
|
||||||
@@ -24,6 +26,7 @@ export type Iwt2GearSlotProgress = {
|
|||||||
export type Iwt2ClassGearProgress = {
|
export type Iwt2ClassGearProgress = {
|
||||||
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
|
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
|
||||||
infusionAbilityId: Iwt2InfusionAbilityId | null
|
infusionAbilityId: Iwt2InfusionAbilityId | null
|
||||||
|
passiveInfusionId: Iwt2PassiveInfusionId | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
|
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
|
||||||
@@ -43,6 +46,9 @@ export type Iwt2GearSlotRecipe = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet']
|
export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet']
|
||||||
|
export const IWT2_MAX_GEAR_LEVEL: Iwt2GearLevel = 10
|
||||||
|
export const IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL: Iwt2GearLevel = 5
|
||||||
|
export const IWT2_PASSIVE_INFUSION_MIN_GEAR_LEVEL: Iwt2GearLevel = 10
|
||||||
|
|
||||||
export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
|
export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
|
||||||
weapon: 'Weapon',
|
weapon: 'Weapon',
|
||||||
@@ -120,7 +126,13 @@ export function createDefaultIwt2GearProgress(): Iwt2GearProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean {
|
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean {
|
||||||
return Object.values(progress.slots).some((slot) => slot.level >= 5)
|
return Object.values(progress.slots).some((slot) => slot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIwt2PassiveInfusionUnlocked(progress: Iwt2GearProgress): boolean {
|
||||||
|
return Object.values(progress).some((classProgress) => (
|
||||||
|
Object.values(classProgress.slots).some((slot) => slot.level >= IWT2_PASSIVE_INFUSION_MIN_GEAR_LEVEL)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function iwt2GearUpgradeCosts(
|
export function iwt2GearUpgradeCosts(
|
||||||
@@ -128,7 +140,7 @@ export function iwt2GearUpgradeCosts(
|
|||||||
slotId: Iwt2GearSlotId,
|
slotId: Iwt2GearSlotId,
|
||||||
currentLevel: Iwt2GearLevel,
|
currentLevel: Iwt2GearLevel,
|
||||||
): Iwt2GearUpgradeCost[] {
|
): Iwt2GearUpgradeCost[] {
|
||||||
if (currentLevel >= 5) return []
|
if (currentLevel >= IWT2_MAX_GEAR_LEVEL) return []
|
||||||
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
||||||
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
|
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
|
||||||
const slug = upgradeDifficultySlug(nextLevel)
|
const slug = upgradeDifficultySlug(nextLevel)
|
||||||
@@ -147,10 +159,15 @@ export function iwt2GearUpgradeCosts(
|
|||||||
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
|
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
|
||||||
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
|
||||||
]
|
]
|
||||||
return [
|
if (nextLevel === 5) return [
|
||||||
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
|
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
|
||||||
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 },
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 },
|
||||||
]
|
]
|
||||||
|
const overcap = nextLevel - 5
|
||||||
|
return [
|
||||||
|
{ itemId: primary.id, itemName: primary.name, quantity: 5 + overcap },
|
||||||
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 + overcap },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function iwt2InfusionCosts(
|
export function iwt2InfusionCosts(
|
||||||
@@ -178,6 +195,7 @@ function createDefaultClassGearProgress(): Iwt2ClassGearProgress {
|
|||||||
feet: { level: 0 },
|
feet: { level: 0 },
|
||||||
},
|
},
|
||||||
infusionAbilityId: null,
|
infusionAbilityId: null,
|
||||||
|
passiveInfusionId: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +211,7 @@ function slotRecipe(
|
|||||||
|
|
||||||
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
|
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
|
||||||
if (level <= 2) return 'initiate'
|
if (level <= 2) return 'initiate'
|
||||||
|
if (level >= 6) return 'veteran'
|
||||||
if (level === 3) return 'veteran'
|
if (level === 3) return 'veteran'
|
||||||
if (level === 4) return 'champion'
|
if (level === 4) return 'champion'
|
||||||
return 'mythic'
|
return 'mythic'
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { IWT2_PARTY_ORDER } from './classes'
|
||||||
|
import {
|
||||||
|
IWT2_GEAR_SLOTS,
|
||||||
|
type Iwt2GearLevel,
|
||||||
|
type Iwt2GearProgress,
|
||||||
|
} from './gear'
|
||||||
|
|
||||||
|
export type Iwt2PvpGearNormalizationConfig = {
|
||||||
|
enabled: boolean
|
||||||
|
gearLevel: Iwt2GearLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IWT2_PVP_NORMALIZED_GEAR_LEVEL: Iwt2GearLevel = 5
|
||||||
|
|
||||||
|
export const IWT2_PVP_GEAR_NORMALIZATION: Iwt2PvpGearNormalizationConfig = {
|
||||||
|
enabled: true,
|
||||||
|
gearLevel: IWT2_PVP_NORMALIZED_GEAR_LEVEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIwt2PvpNormalizedGearProgress(
|
||||||
|
config = IWT2_PVP_GEAR_NORMALIZATION,
|
||||||
|
): Iwt2GearProgress | undefined {
|
||||||
|
if (!config.enabled) return undefined
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
IWT2_PARTY_ORDER.map((classId) => [
|
||||||
|
classId,
|
||||||
|
{
|
||||||
|
slots: Object.fromEntries(
|
||||||
|
IWT2_GEAR_SLOTS.map((slotId) => [slotId, { level: config.gearLevel }]),
|
||||||
|
),
|
||||||
|
infusionAbilityId: null,
|
||||||
|
passiveInfusionId: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
) as Iwt2GearProgress
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './bosses'
|
||||||
|
|
||||||
|
export const IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED = true
|
||||||
|
export const IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED = true
|
||||||
|
export const IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED = true
|
||||||
|
export const IWT2_PVP_ROGUELIKE_BOSS_ROSTER_LIMIT_ENABLED = true
|
||||||
|
export const IWT2_PVP_STADIUM_BOSS_ROSTER_LIMIT_ENABLED = true
|
||||||
|
|
||||||
|
export const IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS: readonly Iwt2BossId[] = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
||||||
|
|
||||||
|
export const IWT2_PVP_STADIUM_ENABLED_BOSS_IDS: readonly Iwt2BossId[] = IWT2_PVP_ROGUELIKE_ENABLED_BOSS_IDS
|
||||||
|
|
||||||
|
type Iwt2RoguelikeBossTier = 'early' | 'mid' | 'late'
|
||||||
|
|
||||||
|
type TierWeight = {
|
||||||
|
tier: Iwt2RoguelikeBossTier
|
||||||
|
weight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Iwt2RoguelikeBossPoolOptions = {
|
||||||
|
bossPool?: readonly Iwt2BossId[]
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = {
|
||||||
|
early: ['bulldrome', 'yian-kut-ku', 'great-jaggi', 'rathian', 'stormcoil-wyrm'],
|
||||||
|
mid: ['khezu', 'barroth', 'tobi-kadachi', 'ember-mantis-duelist', 'crystal-bat-matriarch', 'hollowcrown-revenant'],
|
||||||
|
late: ['rimebastion', 'cinderback-ricochet', 'obsidian-ram-golem', 'venom-orchid-hydra', 'sandglass-scorpion'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const IWT2_ROGUELIKE_BOSS_THREAT: Record<Iwt2RoguelikeBossTier, number> = {
|
||||||
|
early: 1,
|
||||||
|
mid: 2,
|
||||||
|
late: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
const IWT2_ROGUELIKE_BOSS_TIER_BY_ID: Record<Iwt2BossId, Iwt2RoguelikeBossTier> = {
|
||||||
|
bulldrome: 'early',
|
||||||
|
'yian-kut-ku': 'early',
|
||||||
|
'great-jaggi': 'early',
|
||||||
|
rathian: 'early',
|
||||||
|
'stormcoil-wyrm': 'early',
|
||||||
|
khezu: 'mid',
|
||||||
|
barroth: 'mid',
|
||||||
|
'tobi-kadachi': 'mid',
|
||||||
|
'ember-mantis-duelist': 'mid',
|
||||||
|
'crystal-bat-matriarch': 'mid',
|
||||||
|
'hollowcrown-revenant': 'mid',
|
||||||
|
rimebastion: 'late',
|
||||||
|
'cinderback-ricochet': 'late',
|
||||||
|
'obsidian-ram-golem': 'late',
|
||||||
|
'venom-orchid-hydra': 'late',
|
||||||
|
'sandglass-scorpion': 'late',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIwt2PveRoguelikeBossPair(
|
||||||
|
stage: number,
|
||||||
|
random: () => number = Math.random,
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
return createIwt2WeightedRoguelikeBossPair(stage, random)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIwt2WeightedRoguelikeBossPair(
|
||||||
|
stage: number,
|
||||||
|
random: () => number = Math.random,
|
||||||
|
options: Iwt2RoguelikeBossPoolOptions = {},
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
const choices: Iwt2BossId[] = []
|
||||||
|
const maxThreat = maxThreatForStage(stage)
|
||||||
|
const bossPool = normalizeBossPool(options.bossPool)
|
||||||
|
const count = normalizeBossCount(options.count)
|
||||||
|
|
||||||
|
while (choices.length < count) {
|
||||||
|
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random, bossPool)
|
||||||
|
if (!next) break
|
||||||
|
choices.push(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return choices.length === count
|
||||||
|
? choices
|
||||||
|
: createUniformIwt2RoguelikeBossPair(random, { bossPool, count })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUniformIwt2RoguelikeBossPair(
|
||||||
|
random: () => number = Math.random,
|
||||||
|
options: Iwt2RoguelikeBossPoolOptions = {},
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
const pool = normalizeBossPool(options.bossPool)
|
||||||
|
const count = normalizeBossCount(options.count)
|
||||||
|
const choices: Iwt2BossId[] = []
|
||||||
|
while (pool.length > 0 && choices.length < count) {
|
||||||
|
const index = randomIndex(pool.length, random)
|
||||||
|
const [choice] = pool.splice(index, 1)
|
||||||
|
if (choice) choices.push(choice)
|
||||||
|
}
|
||||||
|
return choices
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enabledIwt2RoguelikeBossPool(
|
||||||
|
bossIds: readonly Iwt2BossId[],
|
||||||
|
): Iwt2BossId[] {
|
||||||
|
return normalizeBossPool(bossIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseWeightedBossForStage(
|
||||||
|
stage: number,
|
||||||
|
selected: readonly Iwt2BossId[],
|
||||||
|
maxThreat: number,
|
||||||
|
random: () => number,
|
||||||
|
bossPool: readonly Iwt2BossId[],
|
||||||
|
): Iwt2BossId | undefined {
|
||||||
|
const selectedSet = new Set(selected)
|
||||||
|
const bossPoolSet = new Set(bossPool)
|
||||||
|
const selectedThreat = selected.reduce((total, bossId) => total + threatForBoss(bossId), 0)
|
||||||
|
const weightedTiers = tierWeightsForStage(stage)
|
||||||
|
.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
bosses: IWT2_ROGUELIKE_BOSS_TIERS[entry.tier].filter((bossId) => (
|
||||||
|
bossPoolSet.has(bossId)
|
||||||
|
&& !selectedSet.has(bossId)
|
||||||
|
&& selectedThreat + threatForBoss(bossId) <= maxThreat
|
||||||
|
)),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.weight > 0 && entry.bosses.length > 0)
|
||||||
|
|
||||||
|
const totalWeight = weightedTiers.reduce((total, entry) => total + entry.weight, 0)
|
||||||
|
if (totalWeight <= 0) return undefined
|
||||||
|
|
||||||
|
let roll = safeRandom(random) * totalWeight
|
||||||
|
for (const entry of weightedTiers) {
|
||||||
|
roll -= entry.weight
|
||||||
|
if (roll <= 0) {
|
||||||
|
return entry.bosses[randomIndex(entry.bosses.length, random)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastEntry = weightedTiers[weightedTiers.length - 1]
|
||||||
|
return lastEntry?.bosses[randomIndex(lastEntry.bosses.length, random)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBossPool(bossPool: readonly Iwt2BossId[] | undefined): Iwt2BossId[] {
|
||||||
|
const knownBosses = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
||||||
|
if (!bossPool) return knownBosses
|
||||||
|
|
||||||
|
const knownBossSet = new Set(knownBosses)
|
||||||
|
const normalized: Iwt2BossId[] = []
|
||||||
|
for (const bossId of bossPool) {
|
||||||
|
if (knownBossSet.has(bossId) && !normalized.includes(bossId)) {
|
||||||
|
normalized.push(bossId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.length > 0 ? normalized : knownBosses
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierWeightsForStage(stage: number): TierWeight[] {
|
||||||
|
const safeStage = Math.max(1, Math.floor(stage))
|
||||||
|
if (safeStage <= 2) {
|
||||||
|
return [
|
||||||
|
{ tier: 'early', weight: 85 },
|
||||||
|
{ tier: 'mid', weight: 15 },
|
||||||
|
{ tier: 'late', weight: 0 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (safeStage === 3) {
|
||||||
|
return [
|
||||||
|
{ tier: 'early', weight: 60 },
|
||||||
|
{ tier: 'mid', weight: 35 },
|
||||||
|
{ tier: 'late', weight: 5 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (safeStage <= 5) {
|
||||||
|
return [
|
||||||
|
{ tier: 'early', weight: 25 },
|
||||||
|
{ tier: 'mid', weight: 60 },
|
||||||
|
{ tier: 'late', weight: 15 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (safeStage === 6) {
|
||||||
|
return [
|
||||||
|
{ tier: 'early', weight: 10 },
|
||||||
|
{ tier: 'mid', weight: 50 },
|
||||||
|
{ tier: 'late', weight: 40 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ tier: 'early', weight: 5 },
|
||||||
|
{ tier: 'mid', weight: 30 },
|
||||||
|
{ tier: 'late', weight: 65 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxThreatForStage(stage: number): number {
|
||||||
|
const safeStage = Math.max(1, Math.floor(stage))
|
||||||
|
if (safeStage <= 2) return 3
|
||||||
|
if (safeStage === 3) return 4
|
||||||
|
if (safeStage <= 5) return 5
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
|
||||||
|
function threatForBoss(bossId: Iwt2BossId): number {
|
||||||
|
return IWT2_ROGUELIKE_BOSS_THREAT[IWT2_ROGUELIKE_BOSS_TIER_BY_ID[bossId]]
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomIndex(length: number, random: () => number): number {
|
||||||
|
return Math.min(length - 1, Math.floor(safeRandom(random) * length))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBossCount(count: number | undefined): number {
|
||||||
|
return Math.max(1, Math.min(3, Math.floor(count ?? 2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRandom(random: () => number): number {
|
||||||
|
const value = random()
|
||||||
|
return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ type PhaserArenaProps = {
|
|||||||
|
|
||||||
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
|
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const onStepRef = useRef(onStep)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onStepRef.current = onStep
|
||||||
|
}, [onStep])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hostRef.current) return undefined
|
if (!hostRef.current) return undefined
|
||||||
@@ -21,7 +26,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
|
|||||||
getMovement: () => movementRef.current,
|
getMovement: () => movementRef.current,
|
||||||
getSelectedPartyId: () => selectedPartyIdRef.current,
|
getSelectedPartyId: () => selectedPartyIdRef.current,
|
||||||
getState: () => stateRef.current,
|
getState: () => stateRef.current,
|
||||||
step: onStep,
|
step: (movement, dtSeconds) => onStepRef.current(movement, dtSeconds),
|
||||||
})
|
})
|
||||||
const game = new Phaser.Game({
|
const game = new Phaser.Game({
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
@@ -40,7 +45,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
|
|||||||
return () => {
|
return () => {
|
||||||
game.destroy(true)
|
game.destroy(true)
|
||||||
}
|
}
|
||||||
}, [movementRef, onStep, selectedPartyIdRef, stateRef])
|
}, [movementRef, selectedPartyIdRef, stateRef])
|
||||||
|
|
||||||
return <div className="iwt2-phaser-host" ref={hostRef} />
|
return <div className="iwt2-phaser-host" ref={hostRef} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import {
|
|||||||
import type { Iwt2BossId } from '../content/bosses'
|
import type { Iwt2BossId } from '../content/bosses'
|
||||||
import {
|
import {
|
||||||
createDefaultIwt2GearProgress,
|
createDefaultIwt2GearProgress,
|
||||||
|
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||||
IWT2_GEAR_SLOTS,
|
IWT2_GEAR_SLOTS,
|
||||||
|
IWT2_MAX_GEAR_LEVEL,
|
||||||
iwt2GearUpgradeCosts,
|
iwt2GearUpgradeCosts,
|
||||||
iwt2InfusionCosts,
|
iwt2InfusionCosts,
|
||||||
isIwt2InfusionUnlocked,
|
isIwt2InfusionUnlocked,
|
||||||
|
isIwt2PassiveInfusionUnlocked,
|
||||||
type Iwt2GearLevel,
|
type Iwt2GearLevel,
|
||||||
type Iwt2GearProgress,
|
type Iwt2GearProgress,
|
||||||
type Iwt2GearSlotId,
|
type Iwt2GearSlotId,
|
||||||
|
type Iwt2PassiveInfusionId,
|
||||||
} from '../content/gear'
|
} from '../content/gear'
|
||||||
import {
|
import {
|
||||||
IWT2_INFUSION_ABILITIES,
|
IWT2_INFUSION_ABILITIES,
|
||||||
@@ -263,7 +267,7 @@ export function upgradeIwt2GearSlot(
|
|||||||
): Iwt2Save {
|
): Iwt2Save {
|
||||||
const classProgress = save.gearProgress[classId]
|
const classProgress = save.gearProgress[classId]
|
||||||
const slot = classProgress.slots[slotId]
|
const slot = classProgress.slots[slotId]
|
||||||
if (slot.level >= 5) throw new Error('Gear slot already at +5.')
|
if (slot.level >= IWT2_MAX_GEAR_LEVEL) throw new Error(`Gear slot already at +${IWT2_MAX_GEAR_LEVEL}.`)
|
||||||
const costs = iwt2GearUpgradeCosts(classId, slotId, slot.level)
|
const costs = iwt2GearUpgradeCosts(classId, slotId, slot.level)
|
||||||
const inventory = spendInventoryCosts(save.inventory, costs)
|
const inventory = spendInventoryCosts(save.inventory, costs)
|
||||||
return {
|
return {
|
||||||
@@ -294,8 +298,10 @@ export function setIwt2InfusionAbility(
|
|||||||
const classProgress = save.gearProgress[classId]
|
const classProgress = save.gearProgress[classId]
|
||||||
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
||||||
if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.')
|
if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.')
|
||||||
if (!isIwt2InfusionUnlocked(classProgress)) throw new Error('Upgrade any gear slot to +5 first.')
|
if (!isIwt2InfusionUnlocked(classProgress)) throw new Error(`Upgrade any gear slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} first.`)
|
||||||
if (classProgress.slots[slotId].level < 5) throw new Error('Select a +5 gear slot to anchor the infusion cost.')
|
if (classProgress.slots[slotId].level < IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) {
|
||||||
|
throw new Error(`Select a +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} gear slot to anchor the infusion cost.`)
|
||||||
|
}
|
||||||
if (classProgress.infusionAbilityId === abilityId) return save
|
if (classProgress.infusionAbilityId === abilityId) return save
|
||||||
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
|
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
|
||||||
return {
|
return {
|
||||||
@@ -312,6 +318,27 @@ export function setIwt2InfusionAbility(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setIwt2PassiveInfusion(
|
||||||
|
save: Iwt2Save,
|
||||||
|
passiveInfusionId: Iwt2PassiveInfusionId,
|
||||||
|
): Iwt2Save {
|
||||||
|
if (!isIwt2PassiveInfusionUnlocked(save.gearProgress)) {
|
||||||
|
throw new Error(`Upgrade any gear slot to +${IWT2_MAX_GEAR_LEVEL} first.`)
|
||||||
|
}
|
||||||
|
if (save.gearProgress.healer.passiveInfusionId === passiveInfusionId) return save
|
||||||
|
return {
|
||||||
|
...save,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
gearProgress: {
|
||||||
|
...save.gearProgress,
|
||||||
|
healer: {
|
||||||
|
...save.gearProgress.healer,
|
||||||
|
passiveInfusionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean {
|
export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean {
|
||||||
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
|
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
|
||||||
}
|
}
|
||||||
@@ -450,6 +477,7 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
|
|||||||
const classProgress = rawClassProgress as {
|
const classProgress = rawClassProgress as {
|
||||||
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
|
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
|
||||||
infusionAbilityId?: unknown
|
infusionAbilityId?: unknown
|
||||||
|
passiveInfusionId?: unknown
|
||||||
}
|
}
|
||||||
for (const slotId of IWT2_GEAR_SLOTS) {
|
for (const slotId of IWT2_GEAR_SLOTS) {
|
||||||
next[classId].slots[slotId] = {
|
next[classId].slots[slotId] = {
|
||||||
@@ -461,6 +489,10 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
|
|||||||
&& isIwt2InfusionUnlocked(next[classId])
|
&& isIwt2InfusionUnlocked(next[classId])
|
||||||
? infusionAbilityId as Iwt2InfusionAbilityId
|
? infusionAbilityId as Iwt2InfusionAbilityId
|
||||||
: null
|
: null
|
||||||
|
next[classId].passiveInfusionId = classId === 'healer'
|
||||||
|
&& isIwt2PassiveInfusionUnlocked(next)
|
||||||
|
? asPassiveInfusionId(classProgress.passiveInfusionId)
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
@@ -470,10 +502,15 @@ function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function asGearLevel(value: unknown): Iwt2GearLevel {
|
function asGearLevel(value: unknown): Iwt2GearLevel {
|
||||||
const level = Math.max(0, Math.min(5, Math.floor(Number(value) || 0)))
|
const level = Math.max(0, Math.min(IWT2_MAX_GEAR_LEVEL, Math.floor(Number(value) || 0)))
|
||||||
return level as Iwt2GearLevel
|
return level as Iwt2GearLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asPassiveInfusionId(value: unknown): Iwt2PassiveInfusionId | null {
|
||||||
|
if (typeof value !== 'string' || value === 'revive-party-members') return null
|
||||||
|
return value as Iwt2PassiveInfusionId
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
|
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
const byId = new Map<string, Iwt2InventoryItem>()
|
const byId = new Map<string, Iwt2InventoryItem>()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
tickIwt2Arena,
|
tickIwt2Arena,
|
||||||
type Iwt2ArenaState,
|
type Iwt2ArenaState,
|
||||||
} from '../sim/arenaState'
|
} from '../sim/arenaState'
|
||||||
import type { Iwt2EntityId } from '../sim'
|
import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim'
|
||||||
import { castIwt2HealerAbility } from '../sim'
|
import { castIwt2HealerAbility } from '../sim'
|
||||||
import { PhaserArena } from '../render/PhaserArena'
|
import { PhaserArena } from '../render/PhaserArena'
|
||||||
import {
|
import {
|
||||||
@@ -45,25 +45,32 @@ import {
|
|||||||
IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
|
IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
|
||||||
IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
|
IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
|
||||||
} from '../content/roguelike'
|
} from '../content/roguelike'
|
||||||
|
import { createIwt2PvpNormalizedGearProgress } from '../content/pvpGearNormalization'
|
||||||
|
|
||||||
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
|
||||||
|
type PvpResultReason = 'opponent-defeated' | null
|
||||||
type OverlayAction = 'primary' | 'requeue' | 'menu'
|
type OverlayAction = 'primary' | 'requeue' | 'menu'
|
||||||
type OverlayNavEntry = {
|
type OverlayNavEntry = {
|
||||||
action: OverlayAction
|
action: OverlayAction
|
||||||
row: number
|
row: number
|
||||||
|
column: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
{ action: 'primary', row: 0 },
|
{ action: 'primary', row: 0, column: 0 },
|
||||||
{ action: 'menu', row: 1 },
|
{ action: 'menu', row: 1, column: 0 },
|
||||||
]
|
]
|
||||||
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
|
||||||
{ action: 'primary', row: 0 },
|
{ action: 'primary', row: 0, column: 0 },
|
||||||
{ action: 'requeue', row: 1 },
|
{ action: 'requeue', row: 0, column: 1 },
|
||||||
{ action: 'menu', row: 2 },
|
{ action: 'menu', row: 0, column: 2 },
|
||||||
]
|
]
|
||||||
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
|
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
|
||||||
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
|
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
|
||||||
|
const IWT2_TOP_PARTY_RAIL_WIDTH = 172
|
||||||
|
const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154
|
||||||
|
const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000
|
||||||
|
const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620
|
||||||
|
|
||||||
type BossArenaScreenProps = {
|
type BossArenaScreenProps = {
|
||||||
bossId: Iwt2BossId
|
bossId: Iwt2BossId
|
||||||
@@ -76,9 +83,11 @@ type BossArenaScreenProps = {
|
|||||||
onPvpRequeue?: () => void
|
onPvpRequeue?: () => void
|
||||||
onSaveUpdated: (save: Iwt2Save) => void
|
onSaveUpdated: (save: Iwt2Save) => void
|
||||||
roguelikeRun?: {
|
roguelikeRun?: {
|
||||||
|
bossesDefeated: number
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
contentType: Iwt2RoguelikeContentType
|
contentType: Iwt2RoguelikeContentType
|
||||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||||
|
greenCoinThreshold: number
|
||||||
onVictory: () => void
|
onVictory: () => void
|
||||||
stage: number
|
stage: number
|
||||||
variant: Iwt2RoguelikeVariant
|
variant: Iwt2RoguelikeVariant
|
||||||
@@ -88,7 +97,12 @@ type BossArenaScreenProps = {
|
|||||||
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onMainMenu, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
|
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onMainMenu, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
|
||||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||||
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
|
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
|
||||||
const pveGearActive = roguelikeRun?.variant !== 'pvp'
|
const pvpNormalizedGearProgress = useMemo(() => createIwt2PvpNormalizedGearProgress(), [])
|
||||||
|
const activeGearProgress = pvpRoguelike
|
||||||
|
? pvpNormalizedGearProgress
|
||||||
|
: roguelikeRun?.variant !== 'pvp'
|
||||||
|
? save.gearProgress
|
||||||
|
: undefined
|
||||||
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
|
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
|
||||||
const difficultyHealthScale = difficulty?.healthMultiplier ?? 1
|
const difficultyHealthScale = difficulty?.healthMultiplier ?? 1
|
||||||
const difficultyDamageScale = difficulty?.damageMultiplier ?? 1
|
const difficultyDamageScale = difficulty?.damageMultiplier ?? 1
|
||||||
@@ -103,6 +117,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1
|
const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1
|
||||||
const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale
|
const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale
|
||||||
const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale
|
const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale
|
||||||
|
const arenaBounds = useMemo(() => createTopScreenArenaBounds(), [])
|
||||||
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createArenaState(
|
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createArenaState(
|
||||||
bossId,
|
bossId,
|
||||||
bossIds,
|
bossIds,
|
||||||
@@ -110,21 +125,26 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
combinedDamageScale,
|
combinedDamageScale,
|
||||||
roguelikeBuffs,
|
roguelikeBuffs,
|
||||||
createPressureState(roguelikeStage, roguelikeContentType),
|
createPressureState(roguelikeStage, roguelikeContentType),
|
||||||
pveGearActive ? save.gearProgress : undefined,
|
activeGearProgress,
|
||||||
|
arenaBounds,
|
||||||
))
|
))
|
||||||
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
|
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
|
||||||
pvpRoguelike
|
pvpRoguelike
|
||||||
? createInitialIwt2ArenaState(
|
? createArenaState(
|
||||||
bossId,
|
bossId,
|
||||||
bossIds,
|
bossIds,
|
||||||
combinedBossHealthScale,
|
combinedBossHealthScale,
|
||||||
combinedDamageScale,
|
combinedDamageScale,
|
||||||
|
EMPTY_ROGUELIKE_BUFFS,
|
||||||
createPressureState(roguelikeStage, roguelikeContentType),
|
createPressureState(roguelikeStage, roguelikeContentType),
|
||||||
|
activeGearProgress,
|
||||||
|
arenaBounds,
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
))
|
))
|
||||||
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
|
||||||
const [status, setStatus] = useState<ArenaStatus>('playing')
|
const [status, setStatus] = useState<ArenaStatus>('playing')
|
||||||
|
const [pvpResultReason, setPvpResultReason] = useState<PvpResultReason>(null)
|
||||||
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
|
||||||
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
|
||||||
const [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
|
const [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
|
||||||
@@ -182,15 +202,19 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
combinedDamageScale,
|
combinedDamageScale,
|
||||||
roguelikeBuffs,
|
roguelikeBuffs,
|
||||||
pressureState,
|
pressureState,
|
||||||
pveGearActive ? save.gearProgress : undefined,
|
activeGearProgress,
|
||||||
|
arenaBounds,
|
||||||
)
|
)
|
||||||
const nextOpponentState = pvpRoguelike
|
const nextOpponentState = pvpRoguelike
|
||||||
? createInitialIwt2ArenaState(
|
? createArenaState(
|
||||||
bossId,
|
bossId,
|
||||||
bossIds,
|
bossIds,
|
||||||
combinedBossHealthScale,
|
combinedBossHealthScale,
|
||||||
combinedDamageScale,
|
combinedDamageScale,
|
||||||
|
EMPTY_ROGUELIKE_BUFFS,
|
||||||
pressureState,
|
pressureState,
|
||||||
|
activeGearProgress,
|
||||||
|
arenaBounds,
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
recordedKillIdsRef.current = new Set()
|
recordedKillIdsRef.current = new Set()
|
||||||
@@ -204,16 +228,19 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
setDropAwards([])
|
setDropAwards([])
|
||||||
setPetAwards([])
|
setPetAwards([])
|
||||||
setSelectedOverlayAction('primary')
|
setSelectedOverlayAction('primary')
|
||||||
|
setPvpResultReason(null)
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
}, [bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress])
|
}, [activeGearProgress, arenaBounds, bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage])
|
||||||
|
|
||||||
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
|
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>, nextPvpResultReason: PvpResultReason = null) => {
|
||||||
setSelectedOverlayAction('primary')
|
setSelectedOverlayAction('primary')
|
||||||
|
setPvpResultReason(nextPvpResultReason)
|
||||||
statusRef.current = nextStatus
|
statusRef.current = nextStatus
|
||||||
setStatus(nextStatus)
|
setStatus(nextStatus)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const resumeArena = useCallback(() => {
|
const resumeArena = useCallback(() => {
|
||||||
|
setPvpResultReason(null)
|
||||||
statusRef.current = 'playing'
|
statusRef.current = 'playing'
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
}, [])
|
}, [])
|
||||||
@@ -222,18 +249,21 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
() => {
|
() => {
|
||||||
const baseAbilities = abilitiesForHealer(
|
const baseAbilities = abilitiesForHealer(
|
||||||
save.character.healerStyle,
|
save.character.healerStyle,
|
||||||
pveGearActive ? save.gearProgress.healer.infusionAbilityId : null,
|
activeGearProgress?.healer.infusionAbilityId ?? null,
|
||||||
)
|
)
|
||||||
const gearAbilities = pveGearActive
|
const gearAbilities = activeGearProgress
|
||||||
? applyIwt2PveGearToHealerAbilities(baseAbilities, save.gearProgress)
|
? applyIwt2PveGearToHealerAbilities(baseAbilities, activeGearProgress)
|
||||||
: baseAbilities
|
: baseAbilities
|
||||||
|
const passiveInfusionBuffs = activeGearProgress?.healer.passiveInfusionId
|
||||||
|
? [activeGearProgress.healer.passiveInfusionId]
|
||||||
|
: []
|
||||||
return applyRoguelikeModifiers(
|
return applyRoguelikeModifiers(
|
||||||
gearAbilities,
|
gearAbilities,
|
||||||
roguelikeRun?.buffs ?? [],
|
[...passiveInfusionBuffs, ...(roguelikeRun?.buffs ?? [])],
|
||||||
roguelikeRun?.debuffs ?? [],
|
roguelikeRun?.debuffs ?? [],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[pveGearActive, roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle, save.gearProgress],
|
[activeGearProgress, roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle],
|
||||||
)
|
)
|
||||||
|
|
||||||
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
|
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
|
||||||
@@ -259,12 +289,18 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
const active = entries.find((entry) => entry.action === current) ?? entries[0]
|
const active = entries.find((entry) => entry.action === current) ?? entries[0]
|
||||||
const candidates = entries.filter((entry) => {
|
const candidates = entries.filter((entry) => {
|
||||||
if (entry.action === current) return false
|
if (entry.action === current) return false
|
||||||
if (action === 'navigateUp') return entry.row < active.row
|
if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column
|
||||||
if (action === 'navigateDown') return entry.row > active.row
|
if (action === 'navigateRight') return entry.row === active.row && entry.column > active.column
|
||||||
|
if (action === 'navigateUp') return entry.column === active.column && entry.row < active.row
|
||||||
|
if (action === 'navigateDown') return entry.column === active.column && entry.row > active.row
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
if (candidates.length === 0) return current
|
if (candidates.length === 0) return current
|
||||||
candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row))
|
candidates.sort((a, b) => {
|
||||||
|
const aDistance = Math.abs(a.row - active.row) + Math.abs(a.column - active.column)
|
||||||
|
const bDistance = Math.abs(b.row - active.row) + Math.abs(b.column - active.column)
|
||||||
|
return aDistance - bDistance
|
||||||
|
})
|
||||||
return candidates[0]?.action ?? current
|
return candidates[0]?.action ?? current
|
||||||
})
|
})
|
||||||
}, [pvpRoguelike])
|
}, [pvpRoguelike])
|
||||||
@@ -305,7 +341,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'back') {
|
if (action === 'back') {
|
||||||
if (statusRef.current === 'paused') resumeArena()
|
if (device === 'pc' && statusRef.current === 'paused') resumeArena()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,6 +373,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action.startsWith('targetParty')) {
|
if (action.startsWith('targetParty')) {
|
||||||
|
if (statusRef.current !== 'playing') return
|
||||||
const index = Number(action.replace('targetParty', '')) - 1
|
const index = Number(action.replace('targetParty', '')) - 1
|
||||||
const target = stateRef.current.party[index]
|
const target = stateRef.current.party[index]
|
||||||
if (target) setSelectedPartyId(target.id)
|
if (target) setSelectedPartyId(target.id)
|
||||||
@@ -375,7 +412,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
for (const defeatedBoss of newlyDefeatedBosses) {
|
for (const defeatedBoss of newlyDefeatedBosses) {
|
||||||
nextRecordedIds.add(defeatedBoss.bossId)
|
nextRecordedIds.add(defeatedBoss.bossId)
|
||||||
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
|
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
|
||||||
difficultySlug,
|
difficultySlug: rewardDifficultySlugForKill(roguelikeRun, newDropAwards.length, difficultySlug),
|
||||||
experienceMultiplier,
|
experienceMultiplier,
|
||||||
})
|
})
|
||||||
updatedSave = reward.save
|
updatedSave = reward.save
|
||||||
@@ -389,15 +426,21 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
onSaveUpdated(updatedSave)
|
onSaveUpdated(updatedSave)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.bosses.every((boss) => boss.health <= 0)) {
|
const playerCleared = next.bosses.every((boss) => boss.health <= 0)
|
||||||
if (pvpRoguelike && roguelikeRun) {
|
const playerDefeated = next.party.every((member) => member.health <= 0)
|
||||||
statusRef.current = 'victory'
|
const opponentCleared = Boolean(nextOpponentState?.bosses.every((boss) => boss.health <= 0))
|
||||||
setStatus('victory')
|
const opponentDefeated = Boolean(nextOpponentState?.party.every((member) => member.health <= 0))
|
||||||
roguelikeRun.onVictory()
|
if (!pvpRoguelike && playerCleared) {
|
||||||
} else {
|
showOverlay('victory')
|
||||||
showOverlay('victory')
|
} else if (pvpRoguelike && playerDefeated) {
|
||||||
}
|
showOverlay('defeat')
|
||||||
} else if (next.party.every((member) => member.health <= 0)) {
|
} else if (pvpRoguelike && playerCleared && opponentDefeated) {
|
||||||
|
showOverlay('victory', 'opponent-defeated')
|
||||||
|
} else if (pvpRoguelike && playerCleared && opponentCleared && roguelikeRun) {
|
||||||
|
statusRef.current = 'victory'
|
||||||
|
setStatus('victory')
|
||||||
|
roguelikeRun.onVictory()
|
||||||
|
} else if (!pvpRoguelike && playerDefeated) {
|
||||||
showOverlay('defeat')
|
showOverlay('defeat')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +476,9 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
? 'Rematch'
|
? 'Rematch'
|
||||||
: 'Restart'
|
: 'Restart'
|
||||||
const overlayTitle = status === 'victory'
|
const overlayTitle = status === 'victory'
|
||||||
? `${bossTitle} Down`
|
? pvpResultReason === 'opponent-defeated'
|
||||||
|
? 'CPU Rival Falls'
|
||||||
|
: `${bossTitle} Down`
|
||||||
: status === 'defeat'
|
: status === 'defeat'
|
||||||
? 'Party Defeated'
|
? 'Party Defeated'
|
||||||
: 'Arena Paused'
|
: 'Arena Paused'
|
||||||
@@ -643,6 +688,17 @@ function roguelikeBossHealthScale(stage: number): number {
|
|||||||
return 1 + Math.max(0, stage - 1) * 0.1
|
return 1 + Math.max(0, stage - 1) * 0.1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rewardDifficultySlugForKill(
|
||||||
|
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined,
|
||||||
|
defeatedEarlierThisArena: number,
|
||||||
|
fallbackSlug: string,
|
||||||
|
): string {
|
||||||
|
if (!roguelikeRun) return fallbackSlug
|
||||||
|
return roguelikeRun.bossesDefeated + defeatedEarlierThisArena >= roguelikeRun.greenCoinThreshold
|
||||||
|
? 'veteran'
|
||||||
|
: fallbackSlug
|
||||||
|
}
|
||||||
|
|
||||||
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
|
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
|
||||||
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
|
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
|
||||||
return DEFAULT_OVERLAY_NAV_ENTRIES
|
return DEFAULT_OVERLAY_NAV_ENTRIES
|
||||||
@@ -701,6 +757,7 @@ function createArenaState(
|
|||||||
buffs: Iwt2RoguelikeSelfBuffId[],
|
buffs: Iwt2RoguelikeSelfBuffId[],
|
||||||
roguelikePressure: ReturnType<typeof createPressureState>,
|
roguelikePressure: ReturnType<typeof createPressureState>,
|
||||||
gearProgress?: Iwt2Save['gearProgress'],
|
gearProgress?: Iwt2Save['gearProgress'],
|
||||||
|
bounds?: Iwt2ArenaBounds,
|
||||||
): Iwt2ArenaState {
|
): Iwt2ArenaState {
|
||||||
const baseState = createInitialIwt2ArenaState(
|
const baseState = createInitialIwt2ArenaState(
|
||||||
bossId,
|
bossId,
|
||||||
@@ -708,6 +765,7 @@ function createArenaState(
|
|||||||
bossHealthScale,
|
bossHealthScale,
|
||||||
partyDamageTakenScale,
|
partyDamageTakenScale,
|
||||||
roguelikePressure,
|
roguelikePressure,
|
||||||
|
bounds,
|
||||||
)
|
)
|
||||||
const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState
|
const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState
|
||||||
const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs)
|
const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs)
|
||||||
@@ -723,6 +781,17 @@ function createArenaState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTopScreenArenaBounds(): Iwt2ArenaBounds {
|
||||||
|
if (typeof window === 'undefined') return { width: 960, height: 540 }
|
||||||
|
const thorTopLayout = window.innerWidth <= IWT2_THOR_TOP_BREAKPOINT_WIDTH
|
||||||
|
&& window.innerHeight <= IWT2_THOR_TOP_BREAKPOINT_HEIGHT
|
||||||
|
const railWidth = thorTopLayout ? IWT2_THOR_TOP_PARTY_RAIL_WIDTH : IWT2_TOP_PARTY_RAIL_WIDTH
|
||||||
|
return {
|
||||||
|
width: Math.max(320, Math.round(window.innerWidth - railWidth)),
|
||||||
|
height: Math.max(240, Math.round(window.innerHeight)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createPressureState(
|
function createPressureState(
|
||||||
stage: number | undefined,
|
stage: number | undefined,
|
||||||
contentType: Iwt2RoguelikeContentType | undefined,
|
contentType: Iwt2RoguelikeContentType | undefined,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
createDefaultIwt2Save,
|
createDefaultIwt2Save,
|
||||||
loadIwt2OnlineSave,
|
loadIwt2OnlineSave,
|
||||||
setIwt2InfusionAbility,
|
setIwt2InfusionAbility,
|
||||||
|
setIwt2PassiveInfusion,
|
||||||
updateIwt2CharacterSettings,
|
updateIwt2CharacterSettings,
|
||||||
upgradeIwt2GearSlot,
|
upgradeIwt2GearSlot,
|
||||||
canAffordIwt2Costs,
|
canAffordIwt2Costs,
|
||||||
@@ -40,6 +41,8 @@ import {
|
|||||||
IWT2_HEALER_ORDER,
|
IWT2_HEALER_ORDER,
|
||||||
} from '../content/healerAbilities'
|
} from '../content/healerAbilities'
|
||||||
import {
|
import {
|
||||||
|
buildIwt2SelfBuffChoices,
|
||||||
|
IWT2_REVIVE_PARTY_CHOICE,
|
||||||
type Iwt2RoguelikeChoice,
|
type Iwt2RoguelikeChoice,
|
||||||
type Iwt2RoguelikeContentType,
|
type Iwt2RoguelikeContentType,
|
||||||
type Iwt2RoguelikeOpponentDebuffId,
|
type Iwt2RoguelikeOpponentDebuffId,
|
||||||
@@ -51,9 +54,13 @@ import {
|
|||||||
IWT2_GEAR_SLOT_RECIPES,
|
IWT2_GEAR_SLOT_RECIPES,
|
||||||
IWT2_GEAR_SLOTS,
|
IWT2_GEAR_SLOTS,
|
||||||
IWT2_GEAR_STAT_LABELS,
|
IWT2_GEAR_STAT_LABELS,
|
||||||
|
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||||
|
IWT2_MAX_GEAR_LEVEL,
|
||||||
iwt2GearUpgradeCosts,
|
iwt2GearUpgradeCosts,
|
||||||
iwt2InfusionCosts,
|
iwt2InfusionCosts,
|
||||||
isIwt2InfusionUnlocked,
|
isIwt2InfusionUnlocked,
|
||||||
|
isIwt2PassiveInfusionUnlocked,
|
||||||
|
type Iwt2PassiveInfusionId,
|
||||||
type Iwt2GearStatId,
|
type Iwt2GearStatId,
|
||||||
type Iwt2GearSlotId,
|
type Iwt2GearSlotId,
|
||||||
} from '../content/gear'
|
} from '../content/gear'
|
||||||
@@ -144,6 +151,7 @@ type Iwt2GearNavEntry =
|
|||||||
| { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId }
|
| { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId }
|
||||||
| { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean }
|
| { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean }
|
||||||
| { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, disabled: boolean }
|
| { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, disabled: boolean }
|
||||||
|
| { kind: 'passiveInfusion', key: string, row: number, column: number, passiveId: Iwt2PassiveInfusionId, disabled: boolean }
|
||||||
|
|
||||||
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
|
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
|
||||||
const IWT2_NAME_MAX_LENGTH = 18
|
const IWT2_NAME_MAX_LENGTH = 18
|
||||||
@@ -1256,7 +1264,7 @@ export function Iwt2RoguelikeUpgradeScreen({
|
|||||||
setSelectedIndex((current) => moveUpgradeSelection(entries, current, action))
|
setSelectedIndex((current) => moveUpgradeSelection(entries, current, action))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'back' || action === 'pause') return
|
if (action === 'back') onBack()
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1547,33 +1555,10 @@ export function Iwt2HunterProfileScreen({
|
|||||||
|
|
||||||
function moveSelection(action: InputAction) {
|
function moveSelection(action: InputAction) {
|
||||||
if (!action.startsWith('navigate') || navEntries.length === 0) return
|
if (!action.startsWith('navigate') || navEntries.length === 0) return
|
||||||
setSelectedIndex((current) => {
|
const nextIndex = moveHunterProfileSelection(navEntries, selectedIndex, action)
|
||||||
const bounded = Math.min(current, navEntries.length - 1)
|
const nextEntry = navEntries[nextIndex]
|
||||||
const active = navEntries[bounded]
|
setSelectedIndex(nextIndex)
|
||||||
if (!active) return 0
|
if (nextEntry?.kind === 'item') setFocusedItemKey(nextEntry.itemKey)
|
||||||
const candidates = navEntries
|
|
||||||
.map((entry, index) => ({ entry, index }))
|
|
||||||
.filter(({ index }) => index !== bounded)
|
|
||||||
.filter(({ entry }) => {
|
|
||||||
if (action === 'navigateLeft') return entry.column < active.column
|
|
||||||
if (action === 'navigateRight') return entry.column > active.column
|
|
||||||
if (action === 'navigateUp') return entry.row < active.row
|
|
||||||
return entry.row > active.row
|
|
||||||
})
|
|
||||||
if (candidates.length === 0) return bounded
|
|
||||||
candidates.sort((a, b) => {
|
|
||||||
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
|
|
||||||
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
|
|
||||||
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
|
||||||
? Math.abs(a.entry.row - active.row)
|
|
||||||
: Math.abs(a.entry.column - active.column)
|
|
||||||
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
|
||||||
? Math.abs(b.entry.row - active.row)
|
|
||||||
: Math.abs(b.entry.column - active.column)
|
|
||||||
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
|
||||||
})
|
|
||||||
return candidates[0]?.index ?? bounded
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEntry(entry: Iwt2HunterProfileNavEntry | undefined) {
|
function openEntry(entry: Iwt2HunterProfileNavEntry | undefined) {
|
||||||
@@ -1863,6 +1848,7 @@ function Iwt2ProfileNameEditor({
|
|||||||
<label>
|
<label>
|
||||||
<span>Profile Name</span>
|
<span>Profile Name</span>
|
||||||
<input
|
<input
|
||||||
|
data-controller-nav="skip"
|
||||||
maxLength={IWT2_NAME_MAX_LENGTH}
|
maxLength={IWT2_NAME_MAX_LENGTH}
|
||||||
onChange={(event) => setDraft(event.target.value.slice(0, IWT2_NAME_MAX_LENGTH))}
|
onChange={(event) => setDraft(event.target.value.slice(0, IWT2_NAME_MAX_LENGTH))}
|
||||||
value={draft}
|
value={draft}
|
||||||
@@ -1901,38 +1887,42 @@ export function Iwt2CloudSaveScreen({
|
|||||||
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
|
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
|
||||||
const [syncingOnlineSave, setSyncingOnlineSave] = useState(false)
|
const [syncingOnlineSave, setSyncingOnlineSave] = useState(false)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
|
const statusMessage = message || (!onlineBackupsAvailable ? 'Online save unavailable in offline mode.' : '')
|
||||||
|
|
||||||
|
const checkOnlineSave = useCallback(async (isCancelled: () => boolean) => {
|
||||||
|
setSyncingOnlineSave(true)
|
||||||
|
setMessage('Checking online save...')
|
||||||
|
try {
|
||||||
|
const result = await loadIwt2OnlineSave()
|
||||||
|
if (isCancelled()) return
|
||||||
|
setOnlineSave(result.save)
|
||||||
|
setMessage(result.save ? 'Online save loaded.' : 'No online save yet.')
|
||||||
|
} catch (reason) {
|
||||||
|
if (isCancelled()) return
|
||||||
|
setOnlineSave(null)
|
||||||
|
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled()) setSyncingOnlineSave(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
if (!onlineBackupsAvailable) {
|
if (!onlineBackupsAvailable) {
|
||||||
setOnlineSave(null)
|
|
||||||
setMessage('Online save unavailable in offline mode.')
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSyncingOnlineSave(true)
|
const timer = window.setTimeout(() => {
|
||||||
setMessage('Checking online save...')
|
void checkOnlineSave(() => cancelled)
|
||||||
loadIwt2OnlineSave()
|
}, 0)
|
||||||
.then((result) => {
|
|
||||||
if (cancelled) return
|
|
||||||
setOnlineSave(result.save)
|
|
||||||
setMessage(result.save ? 'Online save loaded.' : 'No online save yet.')
|
|
||||||
})
|
|
||||||
.catch((reason) => {
|
|
||||||
if (cancelled) return
|
|
||||||
setOnlineSave(null)
|
|
||||||
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setSyncingOnlineSave(false)
|
|
||||||
})
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
window.clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [onlineBackupsAvailable])
|
}, [checkOnlineSave, onlineBackupsAvailable])
|
||||||
|
|
||||||
const useLocalSave = useCallback(async () => {
|
const handleUseLocalSave = useCallback(async () => {
|
||||||
if (!onlineBackupsAvailable) {
|
if (!onlineBackupsAvailable) {
|
||||||
setMessage('Using local save. Sign in online to update the server copy.')
|
setMessage('Using local save. Sign in online to update the server copy.')
|
||||||
return
|
return
|
||||||
@@ -1950,13 +1940,13 @@ export function Iwt2CloudSaveScreen({
|
|||||||
}
|
}
|
||||||
}, [onlineBackupsAvailable, save])
|
}, [onlineBackupsAvailable, save])
|
||||||
|
|
||||||
const useOnlineSave = useCallback(() => {
|
const handleUseOnlineSave = useCallback(() => {
|
||||||
if (!onlineSave) return
|
if (!onlineBackupsAvailable || !onlineSave) return
|
||||||
onSaveUpdated(onlineSave)
|
onSaveUpdated(onlineSave)
|
||||||
setMessage('Local save now uses online progress.')
|
setMessage('Local save now uses online progress.')
|
||||||
}, [onlineSave, onSaveUpdated])
|
}, [onlineBackupsAvailable, onlineSave, onSaveUpdated])
|
||||||
|
|
||||||
const useNewSave = useCallback(() => {
|
const handleUseNewSave = useCallback(() => {
|
||||||
onSaveUpdated(createDefaultIwt2Save())
|
onSaveUpdated(createDefaultIwt2Save())
|
||||||
setMessage('Started a new local IWT2 save.')
|
setMessage('Started a new local IWT2 save.')
|
||||||
}, [onSaveUpdated])
|
}, [onSaveUpdated])
|
||||||
@@ -1969,7 +1959,7 @@ export function Iwt2CloudSaveScreen({
|
|||||||
value: onlineBackupsAvailable ? 'Upload' : 'Current',
|
value: onlineBackupsAvailable ? 'Upload' : 'Current',
|
||||||
disabled: syncingOnlineSave,
|
disabled: syncingOnlineSave,
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
void useLocalSave()
|
void handleUseLocalSave()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1977,30 +1967,30 @@ export function Iwt2CloudSaveScreen({
|
|||||||
label: 'Use Online Save',
|
label: 'Use Online Save',
|
||||||
detail: syncingOnlineSave ? 'Checking online save...' : formatIwt2SaveSummary(onlineSave),
|
detail: syncingOnlineSave ? 'Checking online save...' : formatIwt2SaveSummary(onlineSave),
|
||||||
value: onlineSave ? 'Download' : 'Empty',
|
value: onlineSave ? 'Download' : 'Empty',
|
||||||
disabled: syncingOnlineSave || !onlineSave,
|
disabled: syncingOnlineSave || !onlineBackupsAvailable || !onlineSave,
|
||||||
onConfirm: useOnlineSave,
|
onConfirm: handleUseOnlineSave,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'new-save',
|
key: 'new-save',
|
||||||
label: 'Use New Save File',
|
label: 'Use New Save File',
|
||||||
detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.',
|
detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.',
|
||||||
value: 'New',
|
value: 'New',
|
||||||
onConfirm: useNewSave,
|
onConfirm: handleUseNewSave,
|
||||||
},
|
},
|
||||||
], onBack), [
|
], onBack), [
|
||||||
|
handleUseLocalSave,
|
||||||
|
handleUseNewSave,
|
||||||
|
handleUseOnlineSave,
|
||||||
onlineBackupsAvailable,
|
onlineBackupsAvailable,
|
||||||
onlineSave,
|
onlineSave,
|
||||||
onBack,
|
onBack,
|
||||||
save,
|
save,
|
||||||
syncingOnlineSave,
|
syncingOnlineSave,
|
||||||
useLocalSave,
|
|
||||||
useNewSave,
|
|
||||||
useOnlineSave,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Iwt2ScreenShell title="Backup Slot" onBack={onBack}>
|
<Iwt2ScreenShell title="Backup Slot" onBack={onBack}>
|
||||||
{message && <p className="iwt2-screen-note">{message}</p>}
|
{statusMessage && <p className="iwt2-screen-note">{statusMessage}</p>}
|
||||||
<Iwt2ActionList actions={actions} />
|
<Iwt2ActionList actions={actions} />
|
||||||
</Iwt2ScreenShell>
|
</Iwt2ScreenShell>
|
||||||
)
|
)
|
||||||
@@ -2023,16 +2013,22 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
const selectedSlot = classProgress.slots[selectedSlotId]
|
const selectedSlot = classProgress.slots[selectedSlotId]
|
||||||
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
|
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
|
||||||
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId)
|
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId)
|
||||||
const nextLevel = Math.min(5, selectedSlot.level + 1)
|
const nextLevel = Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)
|
||||||
const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
|
const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
|
||||||
const selectedClassName = gearClassDisplayName(selectedClassId, save)
|
const selectedClassName = gearClassDisplayName(selectedClassId, save)
|
||||||
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
|
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
|
||||||
const canUpgrade = selectedSlot.level < 5 && canAffordIwt2Costs(save, upgradeCosts)
|
const canUpgrade = selectedSlot.level < IWT2_MAX_GEAR_LEVEL && canAffordIwt2Costs(save, upgradeCosts)
|
||||||
const infusionUnlocked = isIwt2InfusionUnlocked(classProgress)
|
const infusionUnlocked = isIwt2InfusionUnlocked(classProgress)
|
||||||
const infusionAnchorSlot = selectedSlot.level >= 5
|
const passiveInfusionUnlocked = isIwt2PassiveInfusionUnlocked(save.gearProgress)
|
||||||
|
const infusionAnchorSlot = selectedSlot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL
|
||||||
? selectedSlotId
|
? selectedSlotId
|
||||||
: IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= 5) ?? selectedSlotId
|
: IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) ?? selectedSlotId
|
||||||
const infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId)
|
const infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId)
|
||||||
|
const passiveInfusionChoices = useMemo<Array<Iwt2RoguelikeChoice<Iwt2PassiveInfusionId>>>(() => {
|
||||||
|
if (selectedClassId !== 'healer') return []
|
||||||
|
return buildIwt2SelfBuffChoices(abilitiesForHealer(save.character.healerStyle))
|
||||||
|
.filter((choice): choice is Iwt2RoguelikeChoice<Iwt2PassiveInfusionId> => choice.id !== IWT2_REVIVE_PARTY_CHOICE.id)
|
||||||
|
}, [save.character.healerStyle, selectedClassId])
|
||||||
const navEntries = useMemo<Iwt2GearNavEntry[]>(() => {
|
const navEntries = useMemo<Iwt2GearNavEntry[]>(() => {
|
||||||
const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }]
|
const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }]
|
||||||
IWT2_PARTY_ORDER.forEach((classId, index) => {
|
IWT2_PARTY_ORDER.forEach((classId, index) => {
|
||||||
@@ -2054,8 +2050,19 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs),
|
disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
passiveInfusionChoices.forEach((choice, index) => {
|
||||||
|
const selected = classProgress.passiveInfusionId === choice.id
|
||||||
|
entries.push({
|
||||||
|
kind: 'passiveInfusion',
|
||||||
|
key: `passive:${choice.id}`,
|
||||||
|
row: infusionAbilities.length + index + 1,
|
||||||
|
column: 2,
|
||||||
|
passiveId: choice.id,
|
||||||
|
disabled: selected || !passiveInfusionUnlocked,
|
||||||
|
})
|
||||||
|
})
|
||||||
return entries
|
return entries
|
||||||
}, [canUpgrade, classProgress.infusionAbilityId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, save, selectedClassId])
|
}, [canUpgrade, classProgress.infusionAbilityId, classProgress.passiveInfusionId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, passiveInfusionChoices, passiveInfusionUnlocked, save, selectedClassId])
|
||||||
|
|
||||||
const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0]
|
const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0]
|
||||||
|
|
||||||
@@ -2107,6 +2114,18 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : 'Infusion failed.')
|
setMessage(error instanceof Error ? error.message : 'Infusion failed.')
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (entry.kind === 'passiveInfusion') {
|
||||||
|
if (entry.disabled) return
|
||||||
|
try {
|
||||||
|
const nextSave = setIwt2PassiveInfusion(save, entry.passiveId)
|
||||||
|
onSaveUpdated(nextSave)
|
||||||
|
const passiveName = passiveInfusionChoices.find((choice) => choice.id === entry.passiveId)?.name ?? 'Passive'
|
||||||
|
setMessage(`${passiveName} set as passive infusion.`)
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : 'Passive infusion failed.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2114,12 +2133,23 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<section className="content-screen iwt2-screen-shell iwt2-gear-screen" data-game-nav-active="true">
|
<section className="content-screen iwt2-screen-shell iwt2-gear-screen" data-game-nav-active="true">
|
||||||
<div className="iwt2-gear-layout">
|
<div className="iwt2-gear-layout">
|
||||||
<section className="iwt2-gear-column">
|
<section className="iwt2-gear-column">
|
||||||
|
<button
|
||||||
|
className={`back-button iwt2-gear-back ${activeEntry?.kind === 'back' ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={activeEntry?.kind === 'back' ? 'true' : undefined}
|
||||||
|
onClick={onBack}
|
||||||
|
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'back')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
<div className="iwt2-gear-class-list">
|
<div className="iwt2-gear-class-list">
|
||||||
{IWT2_PARTY_ORDER.map((classId) => {
|
{IWT2_PARTY_ORDER.map((classId) => {
|
||||||
const metadata = IWT2_CLASS_METADATA[classId]
|
const metadata = IWT2_CLASS_METADATA[classId]
|
||||||
const selected = selectedClassId === classId
|
const selected = selectedClassId === classId
|
||||||
const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId
|
const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId
|
||||||
const classInfusion = save.gearProgress[classId].infusionAbilityId
|
const classInfusion = save.gearProgress[classId].infusionAbilityId
|
||||||
|
const passiveInfusion = classId === 'healer' ? save.gearProgress.healer.passiveInfusionId : null
|
||||||
const highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level))
|
const highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level))
|
||||||
const className = gearClassDisplayName(classId, save)
|
const className = gearClassDisplayName(classId, save)
|
||||||
const classSubtitle = gearClassSubtitle(classId, save)
|
const classSubtitle = gearClassSubtitle(classId, save)
|
||||||
@@ -2137,7 +2167,7 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<div>
|
<div>
|
||||||
<strong>{className}</strong>
|
<strong>{className}</strong>
|
||||||
<small>{classSubtitle}</small>
|
<small>{classSubtitle}</small>
|
||||||
<small>Top +{highest}{classInfusion ? ' | Slot 6 set' : ''}</small>
|
<small>Top +{highest}{classInfusion ? ' | Active set' : ''}{passiveInfusion ? ' | Passive set' : ''}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -2180,7 +2210,7 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')}
|
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Upgrade to +{Math.min(5, selectedSlot.level + 1)}
|
Upgrade to +{Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -2196,8 +2226,8 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<small>{selectedBonus.text}</small>
|
<small>{selectedBonus.text}</small>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>{selectedSlot.level >= 5 ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
|
<strong>{selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
|
||||||
<small>{selectedSlot.level >= 5 ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
|
<small>{selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="iwt2-gear-cost-list">
|
<div className="iwt2-gear-cost-list">
|
||||||
@@ -2235,12 +2265,40 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<div>
|
<div>
|
||||||
<strong>{ability.name}</strong>
|
<strong>{ability.name}</strong>
|
||||||
<small>{ability.description}</small>
|
<small>{ability.description}</small>
|
||||||
<small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : 'Unlock: any slot to +5'}</small>
|
<small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : `Unlock: any ${selectedClassName} slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL}`}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{selectedClassId === 'healer' && (
|
||||||
|
<div className="iwt2-infusion-list">
|
||||||
|
{passiveInfusionChoices.map((choice) => {
|
||||||
|
const selected = classProgress.passiveInfusionId === choice.id
|
||||||
|
const focused = activeEntry?.kind === 'passiveInfusion' && activeEntry.passiveId === choice.id
|
||||||
|
const disabled = selected || !passiveInfusionUnlocked
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`iwt2-infusion-row ${selected ? 'active' : ''} ${focused ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={focused ? 'true' : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
key={choice.id}
|
||||||
|
onClick={() => activateEntry({ kind: 'passiveInfusion', key: `passive:${choice.id}`, row: 0, column: 2, passiveId: choice.id, disabled })}
|
||||||
|
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, `passive:${choice.id}`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>P</span>
|
||||||
|
<div>
|
||||||
|
<strong>{choice.name}</strong>
|
||||||
|
<small>{choice.description}</small>
|
||||||
|
<small>{selected ? 'Selected passive' : passiveInfusionUnlocked ? 'Passive infusion' : `Unlock: any gear slot to +${IWT2_MAX_GEAR_LEVEL}`}</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<footer className="iwt2-gear-message">{message}</footer>
|
<footer className="iwt2-gear-message">{message}</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -2284,7 +2342,9 @@ function gearBonus(label: string, value: string): { label: string, text: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
function infusionAnchorText(level: number): string {
|
function infusionAnchorText(level: number): string {
|
||||||
return level >= 5 ? 'Infusion anchor available.' : 'No further bonus.'
|
if (level >= IWT2_MAX_GEAR_LEVEL) return 'Active and passive infusion anchors available.'
|
||||||
|
if (level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) return 'Active infusion anchor available.'
|
||||||
|
return 'No infusion anchor.'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPercent(value: number): string {
|
function formatPercent(value: number): string {
|
||||||
@@ -2372,6 +2432,39 @@ function activeUpgradeEntry(entries: Iwt2UpgradeNavEntry[], index: number) {
|
|||||||
return entries[Math.min(index, entries.length - 1)]
|
return entries[Math.min(index, entries.length - 1)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveHunterProfileSelection(
|
||||||
|
entries: Iwt2HunterProfileNavEntry[],
|
||||||
|
current: number,
|
||||||
|
action: InputAction,
|
||||||
|
) {
|
||||||
|
if (!action.startsWith('navigate') || entries.length === 0) return current
|
||||||
|
const bounded = Math.min(current, entries.length - 1)
|
||||||
|
const active = entries[bounded]
|
||||||
|
if (!active) return 0
|
||||||
|
const candidates = entries
|
||||||
|
.map((entry, index) => ({ entry, index }))
|
||||||
|
.filter(({ index }) => index !== bounded)
|
||||||
|
.filter(({ entry }) => {
|
||||||
|
if (action === 'navigateLeft') return entry.column < active.column
|
||||||
|
if (action === 'navigateRight') return entry.column > active.column
|
||||||
|
if (action === 'navigateUp') return entry.row < active.row
|
||||||
|
return entry.row > active.row
|
||||||
|
})
|
||||||
|
if (candidates.length === 0) return bounded
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
|
||||||
|
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
|
||||||
|
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(a.entry.row - active.row)
|
||||||
|
: Math.abs(a.entry.column - active.column)
|
||||||
|
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||||
|
? Math.abs(b.entry.row - active.row)
|
||||||
|
: Math.abs(b.entry.column - active.column)
|
||||||
|
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
||||||
|
})
|
||||||
|
return candidates[0]?.index ?? bounded
|
||||||
|
}
|
||||||
|
|
||||||
function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) {
|
function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) {
|
||||||
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
|
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-14
@@ -9,6 +9,7 @@ import type {
|
|||||||
Iwt2EntityId,
|
Iwt2EntityId,
|
||||||
Iwt2GroundHazardState,
|
Iwt2GroundHazardState,
|
||||||
Iwt2HostileAddState,
|
Iwt2HostileAddState,
|
||||||
|
Iwt2ArenaBounds,
|
||||||
Iwt2PartyAiRole,
|
Iwt2PartyAiRole,
|
||||||
Iwt2PartyEntityId,
|
Iwt2PartyEntityId,
|
||||||
Iwt2PartyEntityState,
|
Iwt2PartyEntityState,
|
||||||
@@ -66,14 +67,15 @@ export function createInitialIwt2ArenaState(
|
|||||||
bossHealthScale = 1,
|
bossHealthScale = 1,
|
||||||
partyDamageTakenScale = 1,
|
partyDamageTakenScale = 1,
|
||||||
roguelikePressure?: Iwt2RoguelikePressureState,
|
roguelikePressure?: Iwt2RoguelikePressureState,
|
||||||
|
bounds: Iwt2ArenaBounds = { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
|
||||||
): Iwt2ArenaState {
|
): Iwt2ArenaState {
|
||||||
const initialBossIds = bossIds?.length ? bossIds.slice(0, 2) : chooseInitialBossIds(bossId)
|
const initialBossIds = bossIds?.length ? [...bossIds] : chooseInitialBossIds(bossId)
|
||||||
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, bossHealthScale))
|
const bosses = initialBossIds.map((id, index) => createBossEntity(id, index, initialBossIds.length, bossHealthScale, bounds))
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
time: 0,
|
time: 0,
|
||||||
bounds: { width: DEFAULT_ARENA_WIDTH, height: DEFAULT_ARENA_HEIGHT },
|
bounds,
|
||||||
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale)),
|
party: INITIAL_PARTY.map((member) => createPartyMember(member, partyDamageTakenScale, bounds)),
|
||||||
projectiles: [],
|
projectiles: [],
|
||||||
hostileAdds: [],
|
hostileAdds: [],
|
||||||
hazards: [],
|
hazards: [],
|
||||||
@@ -95,9 +97,15 @@ function chooseInitialBossIds(primaryBossId: Iwt2BossId): Iwt2BossId[] {
|
|||||||
return [primaryBossId, random]
|
return [primaryBossId, random]
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number): Iwt2BossEntityState {
|
function createBossEntity(
|
||||||
|
bossId: Iwt2BossId,
|
||||||
|
index: number,
|
||||||
|
bossCount: number,
|
||||||
|
healthScale: number,
|
||||||
|
bounds: Iwt2ArenaBounds,
|
||||||
|
): Iwt2BossEntityState {
|
||||||
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
const bossMetadata = IWT2_BOSS_METADATA[bossId]
|
||||||
const position = initialBossPosition(index)
|
const position = scaleArenaPoint(initialBossPosition(index, bossCount), bounds)
|
||||||
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
|
const maxHealth = Math.max(1, Math.round(bossMetadata.maxHealth * Math.max(0.01, healthScale)))
|
||||||
return {
|
return {
|
||||||
id: bossId,
|
id: bossId,
|
||||||
@@ -123,9 +131,9 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
|
|||||||
chargeHitEntityIds: [],
|
chargeHitEntityIds: [],
|
||||||
slamApplied: false,
|
slamApplied: false,
|
||||||
wallContactSeconds: 0,
|
wallContactSeconds: 0,
|
||||||
relocateTarget: { x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) },
|
relocateTarget: scaleArenaPoint({ x: DEFAULT_ARENA_WIDTH * 0.58, y: DEFAULT_ARENA_HEIGHT * 0.5 + (index === 0 ? -64 : 64) }, bounds),
|
||||||
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
|
fireballCooldownRemaining: initialBossSecondaryCooldown(bossId) + index * 0.7,
|
||||||
fireballTarget: { x: 320, y: 250 },
|
fireballTarget: scaleArenaPoint({ x: 320, y: 250 }, bounds),
|
||||||
birdWaveThresholdsTriggered: [],
|
birdWaveThresholdsTriggered: [],
|
||||||
mechanicLanes: [],
|
mechanicLanes: [],
|
||||||
mechanicCircles: [],
|
mechanicCircles: [],
|
||||||
@@ -134,13 +142,27 @@ function createBossEntity(bossId: Iwt2BossId, index: number, healthScale: number
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialBossPosition(index: number) {
|
function initialBossPosition(index: number, bossCount = 2) {
|
||||||
|
if (bossCount > 2) {
|
||||||
|
const angle = -Math.PI * 0.5 + (Math.PI * (index + 0.5)) / bossCount
|
||||||
|
return {
|
||||||
|
x: 690 + Math.cos(angle) * 120,
|
||||||
|
y: DEFAULT_ARENA_HEIGHT * 0.5 + Math.sin(angle) * 165,
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
x: index === 0 ? 660 : 760,
|
x: index === 0 ? 660 : 760,
|
||||||
y: index === 0 ? 190 : 345,
|
y: index === 0 ? 190 : 345,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scaleArenaPoint(point: Iwt2Vec2, bounds: Iwt2ArenaBounds): Iwt2Vec2 {
|
||||||
|
return {
|
||||||
|
x: point.x * bounds.width / DEFAULT_ARENA_WIDTH,
|
||||||
|
y: point.y * bounds.height / DEFAULT_ARENA_HEIGHT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
|
function initialBossSpecialCooldown(bossId: Iwt2BossId): number {
|
||||||
if (bossId === 'bulldrome') return 2
|
if (bossId === 'bulldrome') return 2
|
||||||
if (bossId === 'great-jaggi') return 2.4
|
if (bossId === 'great-jaggi') return 2.4
|
||||||
@@ -324,7 +346,7 @@ function regeneratePartyMana(party: Iwt2PartyEntityState[], dt: number): Iwt2Par
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPartyMember(initial: InitialPartyMember, damageTakenScale: number): Iwt2PartyEntityState {
|
function createPartyMember(initial: InitialPartyMember, damageTakenScale: number, bounds: Iwt2ArenaBounds): Iwt2PartyEntityState {
|
||||||
const metadata = IWT2_CLASS_METADATA[initial.classId]
|
const metadata = IWT2_CLASS_METADATA[initial.classId]
|
||||||
const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1
|
const safeDamageTakenScale = Number.isFinite(damageTakenScale) ? Math.max(0.01, damageTakenScale) : 1
|
||||||
return {
|
return {
|
||||||
@@ -332,7 +354,7 @@ function createPartyMember(initial: InitialPartyMember, damageTakenScale: number
|
|||||||
kind: 'party',
|
kind: 'party',
|
||||||
classId: initial.classId,
|
classId: initial.classId,
|
||||||
aiRole: initial.aiRole,
|
aiRole: initial.aiRole,
|
||||||
position: { x: initial.x, y: initial.y },
|
position: scaleArenaPoint({ x: initial.x, y: initial.y }, bounds),
|
||||||
velocity: { x: 0, y: 0 },
|
velocity: { x: 0, y: 0 },
|
||||||
facing: { x: 1, y: 0 },
|
facing: { x: 1, y: 0 },
|
||||||
radius: metadata.radius,
|
radius: metadata.radius,
|
||||||
@@ -355,7 +377,7 @@ function createPartyMember(initial: InitialPartyMember, damageTakenScale: number
|
|||||||
attackReady: false,
|
attackReady: false,
|
||||||
status: createEmptyStatus(),
|
status: createEmptyStatus(),
|
||||||
hotEffects: [],
|
hotEffects: [],
|
||||||
preferredOffset: { ...initial.preferredOffset },
|
preferredOffset: scaleArenaPoint(initial.preferredOffset, bounds),
|
||||||
decisionSecondsRemaining: initial.decisionOffset,
|
decisionSecondsRemaining: initial.decisionOffset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -748,6 +770,7 @@ function advanceBossProjectile({
|
|||||||
radius: hitMember.radius + projectile.radius,
|
radius: hitMember.radius + projectile.radius,
|
||||||
}, {
|
}, {
|
||||||
damage: projectile.damage,
|
damage: projectile.damage,
|
||||||
|
damageEventType: 'bossProjectileHit',
|
||||||
sourceId: projectile.sourceId,
|
sourceId: projectile.sourceId,
|
||||||
time,
|
time,
|
||||||
})
|
})
|
||||||
@@ -765,13 +788,13 @@ function advanceBossProjectile({
|
|||||||
bounced = true
|
bounced = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bounced) {
|
if (hitMember) {
|
||||||
const puddle = addFirePuddle({
|
const puddle = addFirePuddle({
|
||||||
damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!,
|
damage: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleDamage!,
|
||||||
duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!,
|
duration: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleSeconds!,
|
||||||
hazards: nextHazards,
|
hazards: nextHazards,
|
||||||
nextHazardId: nextHazardIdValue,
|
nextHazardId: nextHazardIdValue,
|
||||||
position: hitMember?.position ?? position,
|
position: hitMember.position,
|
||||||
radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!,
|
radius: IWT2_BOSS_METADATA['yian-kut-ku'].firePuddleRadius!,
|
||||||
sourceId: projectile.sourceId,
|
sourceId: projectile.sourceId,
|
||||||
time,
|
time,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
Iwt2ArenaIndicator,
|
Iwt2ArenaIndicator,
|
||||||
Iwt2ArenaInput,
|
Iwt2ArenaInput,
|
||||||
|
Iwt2ArenaBounds,
|
||||||
Iwt2ArenaState as Iwt2CoreArenaState,
|
Iwt2ArenaState as Iwt2CoreArenaState,
|
||||||
Iwt2BossEntityState,
|
Iwt2BossEntityState,
|
||||||
Iwt2HostileAddState,
|
Iwt2HostileAddState,
|
||||||
@@ -61,8 +62,9 @@ export function createInitialIwt2ArenaState(
|
|||||||
bossHealthScale?: number,
|
bossHealthScale?: number,
|
||||||
partyDamageTakenScale?: number,
|
partyDamageTakenScale?: number,
|
||||||
roguelikePressure?: Iwt2RoguelikePressureState,
|
roguelikePressure?: Iwt2RoguelikePressureState,
|
||||||
|
bounds?: Iwt2ArenaBounds,
|
||||||
): Iwt2ArenaState {
|
): Iwt2ArenaState {
|
||||||
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure))
|
return decorateArenaState(createCoreIwt2ArenaState(bossId, bossIds, bossHealthScale, partyDamageTakenScale, roguelikePressure, bounds))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
export function tickIwt2Arena(state: Iwt2ArenaState, input: Iwt2ArenaInput, dt: number): Iwt2ArenaState {
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export function tickGroundHazards({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
damage: hazard.damage,
|
damage: hazard.damage,
|
||||||
|
damageEventType: 'groundHazardTick',
|
||||||
sourceId: hazard.sourceId,
|
sourceId: hazard.sourceId,
|
||||||
time,
|
time,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ const ROGUELIKE_PRESSURE_INITIAL_STAGE_DAMAGE = 2
|
|||||||
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
|
const ROGUELIKE_PRESSURE_SOURCE_ID = 'roguelike-pressure'
|
||||||
|
|
||||||
export function roguelikeIncomingDamageScale(
|
export function roguelikeIncomingDamageScale(
|
||||||
_stage: number,
|
stage: number,
|
||||||
_contentType: Iwt2RoguelikePressureContentType,
|
contentType: Iwt2RoguelikePressureContentType,
|
||||||
): number {
|
): number {
|
||||||
|
void stage
|
||||||
|
void contentType
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,8 @@ export type Iwt2ArenaEventType =
|
|||||||
| 'partyAttack'
|
| 'partyAttack'
|
||||||
| 'partyHealed'
|
| 'partyHealed'
|
||||||
| 'partyDamaged'
|
| 'partyDamaged'
|
||||||
|
| 'bossProjectileHit'
|
||||||
|
| 'groundHazardTick'
|
||||||
| 'partyStunned'
|
| 'partyStunned'
|
||||||
| 'bossChargeStart'
|
| 'bossChargeStart'
|
||||||
| 'bossChargeHit'
|
| 'bossChargeHit'
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const BIRD_COUNT = 3
|
|||||||
const BIRD_MELEE_RANGE = 24
|
const BIRD_MELEE_RANGE = 24
|
||||||
const BIRD_MELEE_COOLDOWN = 1.15
|
const BIRD_MELEE_COOLDOWN = 1.15
|
||||||
const BIRD_FLIGHT_DAMAGE = 26
|
const BIRD_FLIGHT_DAMAGE = 26
|
||||||
const YIAN_FIREBALL_BOUNCES = 8
|
const YIAN_FIREBALL_BOUNCES = 3
|
||||||
const YIAN_SAFE_WALL_MARGIN = 118
|
const YIAN_SAFE_WALL_MARGIN = 118
|
||||||
const YIAN_CENTER_CAST_DISTANCE = 36
|
const YIAN_CENTER_CAST_DISTANCE = 36
|
||||||
const YIAN_CENTER_CHARGE_SPEED = 430
|
const YIAN_CENTER_CHARGE_SPEED = 430
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 75 KiB |
Reference in New Issue
Block a user