Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
802dadc7f3 | ||
|
|
d61cee55ed | ||
|
|
679900e7f5 | ||
|
|
ff64c6ddcc | ||
|
|
a6cca2810c | ||
|
|
f48e6130b8 | ||
|
|
61de17d63e | ||
|
|
5629041879 | ||
|
|
ed3f43c93f | ||
|
|
d70941f43e | ||
|
|
de2f86e101 | ||
|
|
62dc51b42a | ||
|
|
defbf0ce10 | ||
|
|
d70f916f31 | ||
|
|
9708ba9e40 | ||
|
|
956c9e32f9 | ||
|
|
547044892d | ||
|
|
81d65bc381 |
@@ -15,6 +15,9 @@
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -34,10 +37,44 @@
|
||||
- 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 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.
|
||||
- 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 AI-controlled party members must be able to route around nearby ground effects and telegraphs using deterministic local steering/path scoring. Avoidance should consider hazard damage radius, telegraph danger, boss collision radius, nearby party members, wall clearance, and a center-safe bias when escaping danger.
|
||||
- IWT2 bosses must not be allowed to pin the tank, DPS, or themselves in arena corners. Any boss that chases, charges, pounces, relocates, or creates persistent hazards needs wall-disengage/relocation behavior and must avoid sustained wall contact.
|
||||
- IWT2 collision resolution must run after both party movement and boss movement so party-vs-boss and party-vs-party overlaps do not persist into damage, projectile, or hazard ticks. When resolving boss overlap near walls, prefer pushing party members toward arena center instead of clamping them deeper into the wall/corner.
|
||||
- Any new IWT2 boss or boss-pair change must be verified with forced simulation for that boss alone and in at least one two-boss pairing. Sim checks should look for sustained boss wall contact, live party overlap, party members stuck near hazards with near-zero movement, and deaths caused by corner pileups rather than intended mechanics.
|
||||
- First IWT2 boss conversion target: Bulldrome. Bulldrome should have normal melee tank damage, a charge across the arena that damages and knocks down/stuns hit players for 0.75 seconds, and every 3rd charge should be followed by an AoE ground slam around the boss that also damages and knocks down/stuns hit players for 0.75 seconds.
|
||||
- IWT2 starter party fantasy: player-controlled healer plus visible party members moving in the arena. Paladin tank holds aggro; ranger fires arrows; mage fires fireballs; rogue flanks; warrior fights in melee. Each class should have a distinct icon and color scheme.
|
||||
|
||||
## IWT2 Boss Asset Requirements
|
||||
|
||||
- IWT2 gameplay boss sprites should use side-view cel-shaded PNG art by default, not top-down token art.
|
||||
- Boss sprites should face right in source art. Renderer may flip sprites horizontally for left-facing movement; avoid 360-degree rotation for side-view sprites.
|
||||
- Use original creature designs only. Do not copy franchise art, silhouettes, armor layouts, or exact monster designs.
|
||||
- Generate full-body sprites with generous padding, crisp inked outlines, simple shaded color blocks, and strong silhouettes readable on the Thor main viewport.
|
||||
- Keep gameplay sprites transparent after processing, cropped to content plus padding, and sized appropriately for Thor. Prefer final PNGs around 512 px wide unless a smaller asset remains crisp.
|
||||
- Store IWT2 boss gameplay sprites under `public/iwt2/bosses/` using `<boss-id>-side-cel.png`.
|
||||
- In boss metadata, set `spriteUrl`, `spriteView: 'side'`, `spriteWidthScale`, `spriteHeightScale`, and `spriteYOffsetScale` so the rendered sprite preserves its source aspect ratio and reads well near party members.
|
||||
- Use a flat chroma-key source background for generated sprites, then remove it locally. Use `#00ff00` unless the boss has green/teal body colors; use `#ff00ff` for green/teal bosses. The key color must not appear in the boss.
|
||||
- Validate every final sprite has transparent corners and renders nonblank in the 960 x 540 Thor main viewport.
|
||||
|
||||
Base prompt for future IWT2 boss sprites:
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
|
||||
Primary request: side-view cel-shaded sprite of an original <boss fantasy> boss, facing right, full body visible
|
||||
Subject: <distinct silhouette, body plan, role/mechanic cues, readable combat posture>; original design, not copied from any franchise
|
||||
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
|
||||
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
|
||||
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
|
||||
Color palette: <boss palette and accent colors>
|
||||
Scene/backdrop: perfectly flat solid <#00ff00 or #ff00ff> chroma-key background for background removal
|
||||
Constraints: background must be one uniform <key color> with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use <key color> anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
|
||||
```
|
||||
|
||||
## Performance Requirements
|
||||
|
||||
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
|
||||
|
||||
@@ -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**.
|
||||
|
||||
Repository:
|
||||
Repository URL:
|
||||
|
||||
```text
|
||||
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:
|
||||
|
||||
```text
|
||||
@@ -67,12 +73,15 @@ TrueNAS paths:
|
||||
/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
|
||||
sudo mkdir -p /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:
|
||||
@@ -101,6 +110,25 @@ cd /mnt/usbssds/apps/iwanttoheal/app
|
||||
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
|
||||
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/...`.
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 98
|
||||
versionName "1.1.19"
|
||||
versionCode 116
|
||||
versionName "1.1.35"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -173,6 +173,13 @@ CREATE TABLE IF NOT EXISTS accounts (
|
||||
last_saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_iwt2_saves (
|
||||
account_id INTEGER PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
save_json TEXT NOT NULL,
|
||||
character_level INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_ip_allowances (
|
||||
ip_address TEXT PRIMARY KEY,
|
||||
max_accounts INTEGER NOT NULL CHECK (max_accounts >= 1),
|
||||
|
||||
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 358 KiB |
@@ -0,0 +1,183 @@
|
||||
# IWT2 Future Boss Concept Prompts
|
||||
|
||||
These are exploration-only concepts. They are not implemented encounters.
|
||||
|
||||
## Rimebastion
|
||||
|
||||
- Hook: Frost leviathan turtle-crab siege boss; plants jagged ice wall segments, boxes party into lanes, then shatters walls into cone shards.
|
||||
- Chroma key: `#00ff00`
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
|
||||
Primary request: side-view cel-shaded sprite of an original frost leviathan turtle-crab siege boss, facing right, full body visible
|
||||
Subject: massive turtle-crab hybrid with a low armored glacier shell, four heavy crab legs, broad claw-like siege forelimbs, frost-rimmed tusk mandibles, ice wall crystal growths along the shell edges, compact battering-ram posture, readable tanky wall-builder silhouette; original design, not copied from any franchise
|
||||
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
|
||||
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
|
||||
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
|
||||
Color palette: deep blue shell plates, pale cyan ice crystals, white frost edges, dark slate claws, cold violet shadow accents
|
||||
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for background removal
|
||||
Constraints: background must be one uniform #00ff00 with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #00ff00 anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
|
||||
```
|
||||
|
||||
## Ember Mantis Duelist
|
||||
|
||||
- Hook: Fast side-stepping mantis swordsman. Blade arms draw glowing line-slash telegraphs, then carve straight arena cuts that force party repositioning.
|
||||
- Chroma key: `#00ff00`
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
|
||||
Primary request: side-view cel-shaded sprite of an original ember mantis duelist boss, facing right, full body visible
|
||||
Subject: tall predatory mantis warrior with two long scythe-like blade arms, ember-cracked chitin plates, narrow duelist stance, swept horn-like antennae, glowing slash scars along forearms, agile line-slash mechanic cues; original design, not copied from any franchise
|
||||
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
|
||||
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
|
||||
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
|
||||
Color palette: charcoal black chitin, deep crimson armor plates, molten orange cracks, pale bone blade edges, small yellow ember accents
|
||||
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for background removal
|
||||
Constraints: background must be one uniform #00ff00 with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #00ff00 anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
|
||||
```
|
||||
|
||||
## Obsidian Ram Golem
|
||||
|
||||
- Hook: Living volcanic-stone ram construct. Charge cracks armor plates, quake sends fracture waves, shatter armor drops broken obsidian slabs as hazards.
|
||||
- Chroma key: `#00ff00`
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
|
||||
Primary request: side-view cel-shaded sprite of an original obsidian ram golem boss, facing right, full body visible
|
||||
Subject: massive quadruped ram-shaped stone construct with jagged obsidian armor plates, curled basalt horns, glowing ember cracks, heavy hooves, lowered charging posture, fractured shoulder plates suggesting shatter armor mechanics, quake-impact weight in stance; original design, not copied from any franchise
|
||||
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
|
||||
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
|
||||
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
|
||||
Color palette: black obsidian, charcoal basalt, dark gray stone, lava-orange crack glow, muted red-hot horn accents
|
||||
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for background removal
|
||||
Constraints: background must be one uniform #00ff00 with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #00ff00 anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
|
||||
```
|
||||
|
||||
## Stormcoil Wyrm
|
||||
|
||||
- Hook: Serpentine sky-eel boss. Loops body into charged rings, leaves arcing storm trails, chains lightning through clustered party members.
|
||||
- Chroma key: `#ff00ff`
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
|
||||
Primary request: side-view cel-shaded sprite of an original storm eel wyrm boss, facing right, full body visible
|
||||
Subject: long serpentine eel-wyrm with raised crested head, ribbon-like looping body forming two open coils, jagged fin spines, glowing storm nodes along its sides, forked whiskers acting as lightning conductors, readable chain-lightning mechanic cues, aggressive hovering combat posture; original design, not copied from any franchise
|
||||
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
|
||||
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
|
||||
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
|
||||
Color palette: deep navy body, pale silver belly, electric cyan and white lightning accents, violet storm-glow details
|
||||
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background for background removal
|
||||
Constraints: background must be one uniform #ff00ff with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #ff00ff anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
|
||||
```
|
||||
|
||||
## Venom Orchid Hydra
|
||||
|
||||
- Hook: Three blossom-serpent heads sweep staggered poison cones while buried roots snare lanes and blooming pods leave toxic zones.
|
||||
- Chroma key: `#ff00ff`
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
|
||||
Primary request: side-view cel-shaded sprite of an original venom orchid hydra boss, facing right, full body visible
|
||||
Subject: massive root-bodied hydra with three long orchid-serpent heads, poisonous blossom crowns, thorny vine necks, exposed twisting roots used for snares, swollen toxin pods, readable cone-attack posture with heads angled at different heights; original design, not copied from any franchise
|
||||
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
|
||||
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
|
||||
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
|
||||
Color palette: deep violet petals, pale bone-yellow stamens, dark bark roots, black-purple thorns, toxic lime-yellow venom accents; do not use magenta
|
||||
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background for background removal
|
||||
Constraints: background must be one uniform #ff00ff with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #ff00ff anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
|
||||
```
|
||||
|
||||
## Sandglass Scorpion
|
||||
|
||||
- Hook: Burrowing scorpion boss that leaves moving sand trails, plants delayed stinger eruptions, and flips hourglass sand zones between safe glass and sinking hazard.
|
||||
- Chroma key: `#00ff00`
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
|
||||
Primary request: side-view cel-shaded sprite of an original sandglass scorpion boss, facing right, full body visible
|
||||
Subject: massive desert scorpion with hourglass-shaped torso shell, glassy sand chambers in claws and tail, segmented legs built for burrowing, curved raised stinger with dripping golden sand, ridged shovel-like pincers, readable combat posture suggesting delayed underground strikes and arena sand zones; original design, not copied from any franchise
|
||||
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
|
||||
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
|
||||
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
|
||||
Color palette: warm ochre sand armor, dark obsidian joints, amber glass chambers, pale bone claws, bright gold sand accents
|
||||
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for background removal
|
||||
Constraints: background must be one uniform #00ff00 with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #00ff00 anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
|
||||
```
|
||||
|
||||
## Crystal Bat Matriarch
|
||||
|
||||
- Hook: Giant jeweled cave-bat queen. Sonic ring pulses force party spacing, mirror shards split/reflect danger lanes, swoop dives punish clustered players.
|
||||
- Chroma key: `#00ff00`
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
|
||||
Primary request: side-view cel-shaded sprite of an original crystal bat matriarch boss, facing right, full body visible
|
||||
Subject: towering bat queen with wide angular wings, faceted amethyst-and-obsidian crystal growths along crown, shoulders, wing fingers, and tail; open fanged mouth emitting visible pale sonic ring motifs around the head; several floating broken mirror shards orbiting near the wings; poised in a low forward swoop posture with claws extended; original creature design, not copied from any franchise
|
||||
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
|
||||
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
|
||||
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
|
||||
Color palette: deep violet body, black wing membranes, icy blue crystal highlights, silver mirror shards, pale cyan sonic accents
|
||||
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for background removal
|
||||
Constraints: background must be one uniform #00ff00 with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #00ff00 anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
|
||||
```
|
||||
|
||||
## Cinderback Ricochet
|
||||
|
||||
- Hook: Molten armadillo boss curls into plated lava wheel, ricochets off arena walls, leaves burning trails, then uncurls for armor-plate slam windows.
|
||||
- Chroma key: `#00ff00`
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
|
||||
Primary request: side-view cel-shaded sprite of an original molten armadillo boss, facing right, full body visible
|
||||
Subject: bulky low-slung armadillo-like beast with layered volcanic armor plates, glowing lava cracks, heavy clawed feet, curled rolling-combat silhouette cues, scorched tail, defensive plated brow, readable ricochet boss posture; original design, not copied from any franchise
|
||||
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
|
||||
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
|
||||
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
|
||||
Color palette: charcoal obsidian plates, ember orange lava seams, dark red underbody, pale hot-yellow highlights
|
||||
Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for background removal
|
||||
Constraints: background must be one uniform #00ff00 with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #00ff00 anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
|
||||
```
|
||||
|
||||
## Hollowcrown Revenant
|
||||
|
||||
- Hook: Spectral stag boss leaves cursed hoof zones and fading dash trails; ghost antlers flare before line charges.
|
||||
- Chroma key: `#ff00ff`
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
|
||||
Primary request: side-view cel-shaded sprite of an original spectral stag revenant boss, facing right, full body visible
|
||||
Subject: gaunt undead stag spirit with cracked bone mask, tall translucent ghost antlers, torn shadowy hide, glowing pale blue curse runes on ribs and hooves, wispy dash-trail ribbons streaming backward, combat-ready lowered stance; original design, not copied from any franchise
|
||||
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
|
||||
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
|
||||
Lighting/mood: clear high-contrast game lighting, eerie revenant glow, readable over dark arena grid
|
||||
Color palette: bone white, charcoal black, cold blue, pale cyan, muted violet accents
|
||||
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background for background removal
|
||||
Constraints: background must be one uniform #ff00ff with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #ff00ff anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
|
||||
```
|
||||
|
||||
## Tidejaw Behemoth
|
||||
|
||||
- Hook: Massive reef-backed crocodile boss. Blasts pressurized water jets, uses heavy tail sweeps to knock party members sideways, leaves spinning whirlpool puddles that pull players inward.
|
||||
- Chroma key: `#ff00ff`
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production game boss sprite for I Want To Heal 2 arena gameplay
|
||||
Primary request: side-view cel-shaded sprite of an original tide crocodile behemoth boss, facing right, full body visible
|
||||
Subject: enormous low-slung crocodilian sea monster with a broad armored skull, jagged coral-like back plates, heavy sweeping tail, webbed claws, pressure-jet water vents along shoulders and flanks, swirling puddle motifs around feet without shadows; original design, not copied from any franchise
|
||||
Style/medium: clean 2D cel-shaded action-game sprite, crisp inked outline, simple shaded color blocks, readable at small size
|
||||
Composition/framing: centered single character, side-view with slight 3/4 depth, full body, generous padding, no cropping
|
||||
Lighting/mood: clear high-contrast game lighting, readable over dark arena grid
|
||||
Color palette: deep swamp green body, dark navy shell plates, pale bone claws and teeth, bright cyan water accents, coral-orange warning marks
|
||||
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background for background removal
|
||||
Constraints: background must be one uniform #ff00ff with no shadows, gradients, texture, floor plane, reflection, or lighting variation; do not use #ff00ff anywhere in the boss; no cast shadow; no text; no watermark; no UI; no logos
|
||||
```
|
||||
|
After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 411 KiB |
@@ -96,8 +96,18 @@ curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$REL
|
||||
-F "attachment=@$APK"
|
||||
```
|
||||
|
||||
If the CLI release fails after creating the Gitea release but before uploading the APK asset, retry only the release upload:
|
||||
|
||||
```sh
|
||||
npm run release:cli -- --release-only 1.1.26
|
||||
```
|
||||
|
||||
## 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
|
||||
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||
git pull
|
||||
|
||||
|
After Width: | Height: | Size: 173 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Mirehorn boss sprite">
|
||||
<path d="M20 80c6-31 35-49 68-42 31 6 48 27 43 52-5 26-34 41-68 34-30-6-48-24-43-44Z" fill="#8d714d"/>
|
||||
<path d="M44 49c16-15 49-17 72-1-28 0-53 6-76 20Z" fill="#d6bd80"/>
|
||||
<path d="M56 29h48l17 24-23 17H55L35 52Z" fill="#6a5137"/>
|
||||
<path d="M63 37h33l10 14-12 9H62L50 51Z" fill="#a48a61"/>
|
||||
<path d="M35 69 8 54l18 42 33 2Z" fill="#6a5137"/>
|
||||
<path d="m102 68 33-13-14 42-34 2Z" fill="#6a5137"/>
|
||||
<path d="M67 65c14 0 26 10 26 23s-12 23-26 23-26-10-26-23 12-23 26-23Z" fill="#5a412e"/>
|
||||
<path d="m94 82 32 7-31 10Z" fill="#3a2b21"/>
|
||||
<path d="M54 77h7v8h-7z" fill="#f3e2a5"/>
|
||||
<path d="M44 111h16l-8 18ZM90 111h16l-8 18Z" fill="#3a2b21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 766 B |
|
After Width: | Height: | Size: 224 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Bristlemaw boss sprite">
|
||||
<path d="M22 80c4-31 32-50 64-43 26 6 42 23 43 45 2 23-16 39-43 41-34 2-62-14-64-43Z" fill="#7a4b35"/>
|
||||
<path d="M42 91c11 15 42 22 67 10-9 16-28 24-52 18-13-4-23-13-28-24l13-4Z" fill="#3b2d28" opacity=".55"/>
|
||||
<path d="M42 51c11-12 31-17 52-12 14 3 25 10 31 21-25-9-53-11-83-9Z" fill="#b48762"/>
|
||||
<path d="M30 62 9 48l8 31 20-5Z" fill="#d6c8aa"/>
|
||||
<path d="m104 55 30-12-13 28-20-2Z" fill="#d6c8aa"/>
|
||||
<path d="M40 34 24 18h25l7 17Z" fill="#ad5632"/>
|
||||
<path d="m99 36 15-18 13 24-20 8Z" fill="#ad5632"/>
|
||||
<path d="M72 58c17 0 31 11 31 25S89 108 72 108 41 97 41 83s14-25 31-25Z" fill="#5b3b31"/>
|
||||
<path d="M73 63c9 0 17 7 17 16s-8 17-17 17-17-8-17-17 8-16 17-16Z" fill="#2c2422"/>
|
||||
<path d="M68 72h9v20h-9z" fill="#e8b068"/>
|
||||
<path d="M61 55c5-9 17-9 22 0-6-2-16-2-22 0Z" fill="#261b17"/>
|
||||
<path d="M42 110h16l-8 16ZM90 109h16l-8 16Z" fill="#2a211e"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 162 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Packfang Alpha boss sprite">
|
||||
<path d="M21 78c8-28 38-42 69-32 24 8 36 27 27 49-9 23-38 32-66 20-22-9-35-21-30-37Z" fill="#3f8f73"/>
|
||||
<path d="M54 48c15-15 38-14 55 1-21 0-39 6-54 19Z" fill="#b8f0aa"/>
|
||||
<path d="M28 79 7 58l33 5Z" fill="#2a6e58"/>
|
||||
<path d="M97 66 137 46l-20 36Z" fill="#2a6e58"/>
|
||||
<path d="M92 91c16 2 30 9 41 22-24-5-43-8-58-6Z" fill="#326d59"/>
|
||||
<path d="M70 62c12 0 22 9 22 21S82 105 70 105s-22-10-22-22 10-21 22-21Z" fill="#245848"/>
|
||||
<path d="M60 32 48 15l26 13ZM82 32l14-17 6 27Z" fill="#b8f0aa"/>
|
||||
<circle cx="60" cy="74" r="4" fill="#f4ffd0"/>
|
||||
<circle cx="60" cy="74" r="2" fill="#111"/>
|
||||
<path d="m93 82 31 4-29 12Z" fill="#1f4539"/>
|
||||
<path d="M44 108h13l-7 18ZM80 111h13l-7 18Z" fill="#1f4539"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 828 B |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Palevolt Maw boss sprite">
|
||||
<path d="M27 82c2-28 27-43 55-40 25 3 45 20 45 44 0 23-21 39-50 39-30 0-52-18-50-43Z" fill="#d8d7c9"/>
|
||||
<path d="M42 55c14-13 47-13 66 0-23-2-45 2-68 16Z" fill="#fff4df"/>
|
||||
<path d="M37 70 10 55l15 40 30 2Z" fill="#b9b7aa"/>
|
||||
<path d="m102 68 31-15-13 40-30 4Z" fill="#b9b7aa"/>
|
||||
<path d="M49 72c8-16 32-21 45-8 13 14 8 39-13 46-21 7-42-4-43-21 0-6 4-12 11-17Z" fill="#efe9da"/>
|
||||
<path d="M64 61c17-32 40-43 57-32-22 10-33 27-34 51Z" fill="#d8d7c9"/>
|
||||
<path d="M89 35c8-18 23-24 40-21-14 8-22 18-25 31Z" fill="#fff4df"/>
|
||||
<path d="M60 84c11-5 26-4 36 4-9 8-27 8-36-4Z" fill="#7d4b55"/>
|
||||
<path d="M55 112h14l-8 17ZM88 112h14l-8 17Z" fill="#8d8a80"/>
|
||||
<path d="M105 69c9 1 15 5 19 13-10-3-17-3-25 2Z" fill="#77d9ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 846 B |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 199 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Verdant Wyrm boss sprite">
|
||||
<path d="M26 77c6-30 35-48 66-40 27 7 42 30 36 55-6 24-34 38-64 29-26-8-43-25-38-44Z" fill="#4d9a58"/>
|
||||
<path d="M48 51c13-17 40-22 64-4-25 0-44 6-59 20Z" fill="#f0c24d"/>
|
||||
<path d="M35 69 5 38l13 54 36 10Z" fill="#3f7c4a"/>
|
||||
<path d="m100 65 38-28-15 56-35 9Z" fill="#3f7c4a"/>
|
||||
<path d="M30 63 6 37l31 14 18 36Z" fill="#78b861"/>
|
||||
<path d="m112 58 28-22-20 31-27 22Z" fill="#78b861"/>
|
||||
<path d="M77 22c17 8 30 21 38 39-18-12-37-18-59-18 5-10 12-17 21-21Z" fill="#2d693f"/>
|
||||
<path d="M70 61c13 0 24 10 24 23s-11 24-24 24-24-11-24-24 11-23 24-23Z" fill="#2f5f3c"/>
|
||||
<path d="m96 82 34 7-33 12Z" fill="#244c32"/>
|
||||
<path d="M57 75h6v8h-6z" fill="#f7ffd2"/>
|
||||
<path d="M44 110h15l-8 19ZM88 111h15l-8 19Z" fill="#244c32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 849 B |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 181 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Stormtail boss sprite">
|
||||
<path d="M25 79c7-27 34-42 65-34 24 6 38 23 35 45-3 23-28 37-58 30-27-6-47-24-42-41Z" fill="#6a79bd"/>
|
||||
<path d="M49 50c13-14 38-18 61-3-21 1-40 8-56 22Z" fill="#a8f3ff"/>
|
||||
<path d="M40 69 8 49l20 46 34 5Z" fill="#515d9d"/>
|
||||
<path d="m95 66 38-18-20 48-34 4Z" fill="#515d9d"/>
|
||||
<path d="M92 89c20 3 34 14 41 32-22-10-42-14-62-12Z" fill="#dfeaff"/>
|
||||
<path d="M68 61c13 0 23 10 23 22s-10 23-23 23-23-11-23-23 10-22 23-22Z" fill="#414b87"/>
|
||||
<path d="M58 32 44 12l28 16ZM83 32l19-17 4 29Z" fill="#dfeaff"/>
|
||||
<path d="m82 55 7 12 13-4-11 11 7 13-14-7-11 11 3-15-14-7 15-2Z" fill="#a8f3ff"/>
|
||||
<circle cx="59" cy="74" r="4" fill="#f6ffff"/>
|
||||
<circle cx="59" cy="74" r="2" fill="#101425"/>
|
||||
<path d="M45 108h14l-8 18ZM82 110h14l-8 18Z" fill="#303869"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 874 B |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 200 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" role="img" aria-label="Emberquill boss sprite">
|
||||
<path d="M28 84c3-29 28-45 56-38 24 6 39 24 35 48-3 22-24 34-51 31-25-3-42-18-40-41Z" fill="#d66a35"/>
|
||||
<path d="M52 51c9-14 29-18 49-5-16 0-31 5-43 16Z" fill="#ffd166"/>
|
||||
<path d="M38 67 9 45l12 45 29 5Z" fill="#f28a3e"/>
|
||||
<path d="m99 71 36-20-16 45-30-1Z" fill="#f28a3e"/>
|
||||
<path d="M33 66 7 42l20 9 17 24Z" fill="#ffd166"/>
|
||||
<path d="m112 63 25-16-16 20-20 12Z" fill="#ffd166"/>
|
||||
<path d="M80 26c18 7 29 21 31 40-13-15-28-21-47-22 4-8 9-14 16-18Z" fill="#b6462d"/>
|
||||
<path d="M69 58c12 0 22 10 22 22s-10 22-22 22-22-10-22-22 10-22 22-22Z" fill="#812f25"/>
|
||||
<path d="m93 79 31 5-28 11Z" fill="#61241d"/>
|
||||
<circle cx="60" cy="73" r="5" fill="#fff2a5"/>
|
||||
<circle cx="60" cy="73" r="2" fill="#1f1511"/>
|
||||
<path d="M52 112h13l-8 18ZM86 111h14l-8 18Z" fill="#58251c"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 895 B |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
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()
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -153,6 +154,48 @@ def gitea_request(
|
||||
except urllib.error.HTTPError as exc:
|
||||
data = exc.read()
|
||||
return exc.code, data
|
||||
except urllib.error.URLError as exc:
|
||||
raise ReleaseError(f"{method} {url} failed: {exc.reason}") from exc
|
||||
|
||||
|
||||
def gitea_upload_asset(config: ReleaseConfig, path: str, apk_path: Path) -> tuple[int, bytes]:
|
||||
curl = shutil.which("curl")
|
||||
if curl is None:
|
||||
raise ReleaseError("curl is required for Gitea asset uploads")
|
||||
|
||||
url = config.gitea_url.rstrip("/") + path
|
||||
proc = subprocess.run(
|
||||
[
|
||||
curl,
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--retry",
|
||||
"3",
|
||||
"--retry-all-errors",
|
||||
"--retry-delay",
|
||||
"2",
|
||||
"--request",
|
||||
"POST",
|
||||
url,
|
||||
"--header",
|
||||
f"Authorization: token {config.token}",
|
||||
"--form",
|
||||
f"attachment=@{apk_path}",
|
||||
"--write-out",
|
||||
"\n%{http_code}",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout).decode("utf-8", "replace").strip()
|
||||
raise ReleaseError(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}")
|
||||
try:
|
||||
body, status_text = proc.stdout.rsplit(b"\n", 1)
|
||||
return int(status_text), body
|
||||
except ValueError as exc:
|
||||
output = proc.stdout.decode("utf-8", "replace")
|
||||
raise ReleaseError(f"Gitea asset upload returned malformed curl output: {output}") from exc
|
||||
|
||||
|
||||
def parse_json_response(status: int, data: bytes, action: str) -> dict:
|
||||
@@ -211,26 +254,12 @@ def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> N
|
||||
raise ReleaseError(f"APK not found for release upload: {apk_path}")
|
||||
release_id = find_or_create_release(config, log)
|
||||
|
||||
boundary = f"----iwanttoheal{int(time.time() * 1000)}"
|
||||
header = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
|
||||
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
body = header + apk_path.read_bytes() + footer
|
||||
asset_name = urllib.parse.quote(apk_path.name)
|
||||
path = (
|
||||
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
|
||||
f"/releases/{release_id}/assets?name={asset_name}"
|
||||
)
|
||||
status, data = gitea_request(
|
||||
config,
|
||||
"POST",
|
||||
path,
|
||||
body=body,
|
||||
content_type=f"multipart/form-data; boundary={boundary}",
|
||||
)
|
||||
status, data = gitea_upload_asset(config, path, apk_path)
|
||||
if status == 409:
|
||||
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
|
||||
parse_json_response(status, data, "Upload release asset")
|
||||
|
||||
@@ -68,9 +68,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
action="store_true",
|
||||
help="Skip the RESET confirmation when used with --reset-player-data.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release-only",
|
||||
metavar="VERSION",
|
||||
help="Create or reuse the Gitea release and upload an existing IWantToHeal-Thor-vVERSION.apk, without rebuilding.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.yes_reset and not args.reset_player_data:
|
||||
parser.error("--yes-reset requires --reset-player-data")
|
||||
if args.release_only and args.reset_player_data:
|
||||
parser.error("--release-only cannot be combined with --reset-player-data")
|
||||
return args
|
||||
|
||||
|
||||
@@ -131,44 +138,90 @@ def request_json(method: str, path: str, token: str, data: dict | None = None) -
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")
|
||||
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise SystemExit(f"Gitea {method} failed: {exc.reason}") from exc
|
||||
|
||||
|
||||
def upload_asset(path: str, token: str, apk: Path) -> dict:
|
||||
boundary = "----iwanttoheal-release-boundary"
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n'
|
||||
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
body += apk.read_bytes()
|
||||
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
}
|
||||
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST")
|
||||
url = GITEA_URL + path
|
||||
curl = shutil.which("curl")
|
||||
if curl is None:
|
||||
raise SystemExit("curl is required for Gitea asset uploads")
|
||||
|
||||
proc = subprocess.run(
|
||||
[
|
||||
curl,
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--retry",
|
||||
"3",
|
||||
"--retry-all-errors",
|
||||
"--retry-delay",
|
||||
"2",
|
||||
"--request",
|
||||
"POST",
|
||||
url,
|
||||
"--header",
|
||||
f"Authorization: token {token}",
|
||||
"--form",
|
||||
f"attachment=@{apk}",
|
||||
"--write-out",
|
||||
"\n%{http_code}",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
detail = proc.stderr.strip() or proc.stdout.strip()
|
||||
raise SystemExit(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}")
|
||||
|
||||
try:
|
||||
body, status_text = proc.stdout.rsplit("\n", 1)
|
||||
status = int(status_text)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(f"Gitea asset upload returned malformed curl output: {proc.stdout}") from exc
|
||||
|
||||
if status >= 400:
|
||||
raise SystemExit(f"Gitea asset upload failed ({status}): {body}")
|
||||
|
||||
try:
|
||||
parsed = json.loads(body or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SystemExit(f"Gitea asset upload returned invalid JSON: {body}") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise SystemExit(f"Gitea asset upload returned unexpected JSON: {parsed}")
|
||||
return parsed
|
||||
|
||||
|
||||
def find_or_create_release(version: str, token: str) -> int:
|
||||
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
|
||||
tag = f"v{version}"
|
||||
tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
|
||||
req = urllib.request.Request(GITEA_URL + tag_path, headers={"Authorization": f"token {token}"}, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
release = json.loads(resp.read().decode("utf-8"))
|
||||
release_id = release.get("id")
|
||||
if not release_id:
|
||||
raise SystemExit(f"Gitea release missing id: {release}")
|
||||
print(f"Gitea release exists: {tag} (id {release_id})")
|
||||
return int(release_id)
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")
|
||||
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc
|
||||
if exc.code != 404:
|
||||
detail = exc.read().decode("utf-8", "replace")
|
||||
raise SystemExit(f"Gitea release lookup failed ({exc.code}): {detail}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise SystemExit(f"Gitea release lookup failed: {exc.reason}") from exc
|
||||
|
||||
|
||||
def create_gitea_release(version: str, apk: Path) -> None:
|
||||
token = GITEA_TOKEN.strip()
|
||||
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
|
||||
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
|
||||
|
||||
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
|
||||
release = request_json(
|
||||
"POST",
|
||||
repo_path + "/releases",
|
||||
token,
|
||||
{
|
||||
"tag_name": f"v{version}",
|
||||
"tag_name": tag,
|
||||
"target_commitish": BRANCH,
|
||||
"name": f"v{version}",
|
||||
"name": tag,
|
||||
"body": f"I Want to Heal Android build v{version}",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
@@ -177,7 +230,17 @@ def create_gitea_release(version: str, apk: Path) -> None:
|
||||
release_id = release.get("id")
|
||||
if not release_id:
|
||||
raise SystemExit(f"Gitea release missing id: {release}")
|
||||
print(f"Gitea release created: {tag} (id {release_id})")
|
||||
return int(release_id)
|
||||
|
||||
|
||||
def create_gitea_release(version: str, apk: Path) -> None:
|
||||
token = GITEA_TOKEN.strip()
|
||||
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
|
||||
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
|
||||
|
||||
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
|
||||
release_id = find_or_create_release(version, token)
|
||||
asset_name = urllib.parse.quote(apk.name)
|
||||
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
|
||||
print(f"Gitea release uploaded: v{version}")
|
||||
@@ -289,6 +352,16 @@ def reset_player_data(database_path: Path, *, assume_yes: bool) -> None:
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||
os.chdir(REPO_ROOT)
|
||||
if args.release_only:
|
||||
version = args.release_only.strip()
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
|
||||
raise SystemExit("Version must look like 1.1.2")
|
||||
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
|
||||
if not apk.exists():
|
||||
raise SystemExit(f"APK not found: {apk}")
|
||||
create_gitea_release(version, apk)
|
||||
return 0
|
||||
|
||||
version = prompt_version()
|
||||
apk = build_apk(version)
|
||||
commit_and_push(version)
|
||||
|
||||
@@ -51,6 +51,14 @@ function ensureRuntimeMigrations(database) {
|
||||
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
|
||||
WHERE last_saved_at IS NULL OR last_saved_at = ''
|
||||
`).run()
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS account_iwt2_saves (
|
||||
account_id INTEGER PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
save_json TEXT NOT NULL,
|
||||
character_level INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
function sqliteDateFromMs(value) {
|
||||
@@ -1384,6 +1392,46 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function readIwt2SyncSave(database, accountId) {
|
||||
const row = database.prepare(`
|
||||
SELECT save_json AS saveJson
|
||||
FROM account_iwt2_saves
|
||||
WHERE account_id = ?
|
||||
`).get(accountId)
|
||||
if (!row) return { save: null }
|
||||
try {
|
||||
return { save: JSON.parse(row.saveJson) }
|
||||
} catch {
|
||||
return { save: null }
|
||||
}
|
||||
}
|
||||
|
||||
function importIwt2SyncSave(database, accountId, payload) {
|
||||
const save = payload?.save
|
||||
if (
|
||||
!save
|
||||
|| typeof save !== 'object'
|
||||
|| Number(save.version) !== 2
|
||||
|| typeof save.updatedAt !== 'number'
|
||||
|| !save.character
|
||||
|| typeof save.character !== 'object'
|
||||
|| typeof save.character.level !== 'number'
|
||||
) {
|
||||
throw new Error('The IWT2 save snapshot is invalid.')
|
||||
}
|
||||
const savedAt = sqliteDateFromMs(save.updatedAt)
|
||||
const characterLevel = clampInteger(save.character.level, 1, 1, 1000000)
|
||||
database.prepare(`
|
||||
INSERT INTO account_iwt2_saves (account_id, save_json, character_level, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
save_json = excluded.save_json,
|
||||
character_level = excluded.character_level,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(accountId, JSON.stringify(save), characterLevel, savedAt)
|
||||
return readIwt2SyncSave(database, accountId)
|
||||
}
|
||||
|
||||
function itemById(database, itemId) {
|
||||
return database.prepare(`
|
||||
SELECT
|
||||
@@ -1544,11 +1592,6 @@ function rollEncounterLoot(database, characterId, encounterId, difficultyId, run
|
||||
`).get(encounterId, difficultyId)
|
||||
if (!context) throw new Error('That loot table is not available.')
|
||||
|
||||
const character = database.prepare('SELECT level FROM characters WHERE id = ?').get(characterId)
|
||||
if (character.level < context.unlockLevel) {
|
||||
throw new Error(`${context.difficultyName} unlocks at level ${context.unlockLevel}.`)
|
||||
}
|
||||
|
||||
const entries = database.prepare(`
|
||||
SELECT
|
||||
encounter_loot.drop_weight AS dropWeight,
|
||||
@@ -2249,9 +2292,6 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
||||
JOIN classes ON classes.id = characters.class_id
|
||||
WHERE characters.id = ?
|
||||
`).get(characterId)
|
||||
if (character.level < dungeon.unlockLevel) {
|
||||
throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`)
|
||||
}
|
||||
const maxLevel = Number(
|
||||
database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
|
||||
)
|
||||
@@ -2483,9 +2523,6 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
FROM characters
|
||||
WHERE characters.id = ?
|
||||
`).get(characterId)
|
||||
if (character.level < dungeon.unlockLevel) {
|
||||
throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`)
|
||||
}
|
||||
const maxLevel = Number(
|
||||
database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
|
||||
)
|
||||
@@ -3137,6 +3174,17 @@ export async function handleApiRequest(request, response, next) {
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url === '/api/iwt2/sync-save' && request.method === 'GET') {
|
||||
sendJson(response, 200, readIwt2SyncSave(database, session.accountId))
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url === '/api/iwt2/sync-save' && request.method === 'PUT') {
|
||||
const payload = await readJson(request, 512 * 1024)
|
||||
sendJson(response, 200, importIwt2SyncSave(database, session.accountId, payload))
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url === '/api/profile' && request.method === 'GET') {
|
||||
sendJson(response, 200, getProfile(database, session.characterId, session.accountId))
|
||||
return
|
||||
|
||||
@@ -111,7 +111,109 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen {
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
box-shadow: 7px 7px 0 #08090c;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin-top: 12px;
|
||||
outline: 2px solid var(--edge);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen .iwt2-menu-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(4, minmax(0, 1fr));
|
||||
height: min(100%, 388px);
|
||||
margin: 0 auto;
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen .iwt2-menu-card {
|
||||
align-items: center;
|
||||
background: var(--panel-light);
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
min-height: 0;
|
||||
outline: 2px solid #42414c;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen .iwt2-menu-card > span {
|
||||
align-items: center;
|
||||
background: #13141a;
|
||||
border: 2px solid var(--gold);
|
||||
color: var(--gold);
|
||||
display: flex;
|
||||
flex: 0 0 48px;
|
||||
font-family: var(--pixel-font);
|
||||
font-size: 16px;
|
||||
height: 48px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen .iwt2-menu-card strong {
|
||||
display: block;
|
||||
font-family: var(--pixel-font);
|
||||
font-size: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen .iwt2-menu-card small {
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen .iwt2-menu-card.game-selected > span {
|
||||
background: var(--gold);
|
||||
color: #13141a;
|
||||
}
|
||||
|
||||
@media (max-height: 620px) {
|
||||
.iwt2-menu-screen {
|
||||
margin-top: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen .iwt2-menu-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen .iwt2-menu-card {
|
||||
min-height: 72px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen .iwt2-menu-card > span {
|
||||
flex-basis: 44px;
|
||||
font-size: 14px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen .iwt2-menu-card strong {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen .iwt2-menu-card small {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.iwt2-arena-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 172px minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
@@ -121,6 +223,7 @@
|
||||
.iwt2-arena-stage {
|
||||
background: #090c10;
|
||||
border: 0;
|
||||
grid-column: 2;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -217,6 +320,22 @@
|
||||
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 {
|
||||
background: #dc5162;
|
||||
}
|
||||
@@ -237,17 +356,18 @@
|
||||
.iwt2-party-list {
|
||||
background: rgba(15, 19, 26, 0.88);
|
||||
border: 0;
|
||||
left: 28px;
|
||||
max-height: calc(100% - 112px);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
max-height: none;
|
||||
outline: 0;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 172px;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
align-content: center;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -259,7 +379,7 @@
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
height: 86px;
|
||||
min-height: 86px;
|
||||
outline: 2px solid transparent;
|
||||
@@ -269,27 +389,38 @@
|
||||
}
|
||||
|
||||
.iwt2-party-row.has-target-binding {
|
||||
grid-template-columns: 28px 24px minmax(0, 1fr);
|
||||
grid-template-columns: 28px 28px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.iwt2-party-row > span {
|
||||
.iwt2-party-row > .iwt2-party-portrait {
|
||||
align-items: center;
|
||||
border: 2px solid #050608;
|
||||
color: #fff7df;
|
||||
display: inline-flex;
|
||||
font-weight: 900;
|
||||
height: 24px;
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
overflow: hidden;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.iwt2-party-portrait img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.iwt2-party-row .iwt2-party-target-key {
|
||||
align-items: center;
|
||||
background: #050608;
|
||||
border-color: #0b0d12;
|
||||
color: var(--muted);
|
||||
display: inline-flex;
|
||||
font-family: var(--pixel-font);
|
||||
font-size: 0.52rem;
|
||||
height: 24px;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
padding: 0 2px;
|
||||
width: auto;
|
||||
@@ -591,6 +722,55 @@
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.iwt2-pvp-queue-overlay {
|
||||
align-items: center;
|
||||
background: rgba(5, 5, 8, 0.86);
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
position: fixed;
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
.iwt2-pvp-queue-panel {
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border: 3px solid #0b0c0f;
|
||||
box-shadow: 8px 8px 0 #050507;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 440px;
|
||||
outline: 2px solid var(--gold);
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
width: min(100%, 440px);
|
||||
}
|
||||
|
||||
.iwt2-pvp-queue-panel .placeholder-runes {
|
||||
font-size: 34px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.iwt2-pvp-queue-panel h2 {
|
||||
color: var(--ink);
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.iwt2-pvp-queue-panel small {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.iwt2-pvp-queue-panel .iwt2-result-button {
|
||||
margin-top: 6px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.iwt2-result-button.is-primary {
|
||||
background: #263448;
|
||||
color: #fff4a8;
|
||||
@@ -658,6 +838,68 @@
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.iwt2-name-editor {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.iwt2-name-editor label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.iwt2-name-editor label span {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.iwt2-name-editor input {
|
||||
background: #10141b;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--ink);
|
||||
font-family: var(--pixel-font);
|
||||
font-size: 1rem;
|
||||
min-height: 48px;
|
||||
outline: 2px solid #34343d;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.iwt2-name-editor input:focus {
|
||||
outline-color: #e5b95f;
|
||||
}
|
||||
|
||||
.iwt2-name-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.iwt2-name-grid button {
|
||||
background: #10141b;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font-family: var(--pixel-font);
|
||||
font-size: 0.72rem;
|
||||
min-height: 42px;
|
||||
outline: 2px solid #34343d;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.iwt2-name-grid button.selected {
|
||||
background: #1c2633;
|
||||
outline-color: #e5b95f;
|
||||
}
|
||||
|
||||
.iwt2-screen-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.iwt2-action-list,
|
||||
.iwt2-info-list {
|
||||
display: grid;
|
||||
@@ -725,6 +967,343 @@
|
||||
outline-color: #e5b95f;
|
||||
}
|
||||
|
||||
.iwt2-header-title {
|
||||
color: #f2c96d;
|
||||
font-size: 1rem;
|
||||
margin-left: 10px;
|
||||
margin-right: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.iwt2-header-back {
|
||||
margin-left: 10px;
|
||||
min-height: 32px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.iwt2-heading-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.iwt2-pager-button {
|
||||
background: #1a1b22;
|
||||
border: 2px solid #0a0b0f;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font-family: var(--pixel-font);
|
||||
font-size: 8px;
|
||||
min-height: 30px;
|
||||
outline: 2px solid #4d4b59;
|
||||
padding: 5px 9px;
|
||||
}
|
||||
|
||||
.iwt2-pager-button:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.iwt2-page-counter {
|
||||
color: var(--muted);
|
||||
font-family: var(--pixel-font);
|
||||
font-size: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip > button {
|
||||
background: #151922;
|
||||
border: 2px solid #08090d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-height: 44px;
|
||||
outline: 2px solid #34343d;
|
||||
padding: 7px 9px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip > button.active,
|
||||
.iwt2-difficulty-strip > button.game-selected {
|
||||
background: #1c2633;
|
||||
outline-color: #e5b95f;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip > button:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip strong,
|
||||
.iwt2-difficulty-strip small {
|
||||
display: block;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip strong {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip small {
|
||||
color: var(--muted);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.iwt2-dungeon-list {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.iwt2-dungeon-list .iwt2-action-list {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.iwt2-dungeon-list .iwt2-action-row {
|
||||
min-height: 56px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.iwt2-gear-layout {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: 0.8fr 1fr 1.35fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.iwt2-gear-column {
|
||||
background: #10141b;
|
||||
border: 2px solid #090a0d;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
outline: 2px solid #34343d;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.iwt2-gear-screen {
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.iwt2-gear-column h2 {
|
||||
color: #f2c96d;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.iwt2-gear-class-list,
|
||||
.iwt2-gear-slot-list,
|
||||
.iwt2-infusion-list,
|
||||
.iwt2-gear-cost-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.iwt2-gear-class-list,
|
||||
.iwt2-infusion-list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.iwt2-gear-class,
|
||||
.iwt2-gear-slot,
|
||||
.iwt2-infusion-row,
|
||||
.iwt2-gear-upgrade-button,
|
||||
.iwt2-gear-back {
|
||||
background: #151922;
|
||||
border: 2px solid #08090d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font-family: var(--body-font);
|
||||
outline: 2px solid #34343d;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.iwt2-gear-class {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
min-height: 54px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.iwt2-gear-class > span {
|
||||
align-items: center;
|
||||
background: color-mix(in srgb, var(--class-color) 25%, #10141b);
|
||||
border: 2px solid color-mix(in srgb, var(--class-color) 60%, #090a0d);
|
||||
display: inline-flex;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.iwt2-gear-class > div,
|
||||
.iwt2-infusion-row > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.iwt2-gear-class strong,
|
||||
.iwt2-gear-slot strong,
|
||||
.iwt2-infusion-row strong,
|
||||
.iwt2-gear-detail strong {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.iwt2-gear-class small,
|
||||
.iwt2-gear-slot small,
|
||||
.iwt2-infusion-row small,
|
||||
.iwt2-gear-detail small {
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.25;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.iwt2-gear-slot {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-height: 56px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.iwt2-gear-slot em {
|
||||
color: #fff4a8;
|
||||
font-size: 0.9rem;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.iwt2-gear-class.active,
|
||||
.iwt2-gear-slot.active,
|
||||
.iwt2-infusion-row.active {
|
||||
background: #1a2330;
|
||||
}
|
||||
|
||||
.iwt2-gear-class.game-selected,
|
||||
.iwt2-gear-slot.game-selected,
|
||||
.iwt2-infusion-row.game-selected,
|
||||
.iwt2-gear-upgrade-button.game-selected,
|
||||
.iwt2-gear-back.game-selected {
|
||||
outline-color: #e5b95f;
|
||||
}
|
||||
|
||||
.iwt2-gear-upgrade-button {
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.iwt2-gear-back {
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.iwt2-gear-upgrade-button:disabled,
|
||||
.iwt2-infusion-row:disabled {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.iwt2-gear-detail-panel {
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.iwt2-gear-detail {
|
||||
background: #151922;
|
||||
border: 2px solid #08090d;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.iwt2-gear-detail p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.iwt2-gear-cost-list span {
|
||||
background: #0d1118;
|
||||
border: 1px solid #29313d;
|
||||
color: var(--muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.2;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.iwt2-gear-bonus-grid {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.iwt2-gear-bonus-grid span {
|
||||
background: #0d1118;
|
||||
border: 1px solid #29313d;
|
||||
display: block;
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.iwt2-gear-bonus-grid strong {
|
||||
color: #f2c96d;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.iwt2-gear-bonus-grid small {
|
||||
color: #e5edf6;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.iwt2-gear-cost-list span.met {
|
||||
color: #9ef0bd;
|
||||
}
|
||||
|
||||
.iwt2-gear-cost-list span.missing {
|
||||
color: #ff9c9c;
|
||||
}
|
||||
|
||||
.iwt2-infusion-row {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
min-height: 74px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.iwt2-infusion-row > span {
|
||||
align-items: center;
|
||||
background: #202938;
|
||||
border: 2px solid #08090d;
|
||||
color: #f2c96d;
|
||||
display: inline-flex;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.iwt2-gear-message {
|
||||
color: #fff4a8;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.3;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.iwt2-controller-preview {
|
||||
align-content: center;
|
||||
background: #141922;
|
||||
@@ -753,11 +1332,21 @@
|
||||
}
|
||||
|
||||
.iwt2-bottom-display .dual-control-chip {
|
||||
align-items: center;
|
||||
background: var(--panel-light);
|
||||
border: 2px solid #090a0d;
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
outline: 2px solid #494756;
|
||||
}
|
||||
|
||||
.iwt2-bottom-target-icon {
|
||||
flex: 0 0 auto;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.game-version-grid,
|
||||
@@ -840,6 +1429,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) and (max-height: 620px) {
|
||||
.iwt2-arena-layout {
|
||||
grid-template-columns: 154px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-shell.iwt2-shell {
|
||||
padding: 6px 0;
|
||||
width: min(100%, calc(100% - 20px));
|
||||
@@ -856,6 +1449,7 @@
|
||||
|
||||
.iwt2-shell .character-summary {
|
||||
gap: 7px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.iwt2-shell .character-summary strong {
|
||||
@@ -866,6 +1460,16 @@
|
||||
font-size: 6px;
|
||||
}
|
||||
|
||||
.iwt2-header-title {
|
||||
font-size: 12px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.iwt2-header-back {
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
}
|
||||
|
||||
.iwt2-menu-screen {
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
@@ -924,6 +1528,11 @@
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.iwt2-gear-screen {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.iwt2-screen-shell .screen-heading {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
@@ -943,6 +1552,39 @@
|
||||
padding: 5px 9px;
|
||||
}
|
||||
|
||||
.iwt2-heading-actions {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.iwt2-pager-button {
|
||||
font-size: 8px;
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
}
|
||||
|
||||
.iwt2-page-counter {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip {
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip > button {
|
||||
min-height: 38px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip strong {
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip small {
|
||||
font-size: 0.52rem;
|
||||
}
|
||||
|
||||
.iwt2-action-list,
|
||||
.iwt2-info-list {
|
||||
gap: 7px;
|
||||
@@ -958,6 +1600,112 @@
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.iwt2-dungeon-list .iwt2-action-list {
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.iwt2-dungeon-list .iwt2-action-row {
|
||||
min-height: 58px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.iwt2-gear-layout {
|
||||
gap: 8px;
|
||||
grid-template-columns: 0.78fr 0.92fr 1.3fr;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.iwt2-gear-column {
|
||||
gap: 7px;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.iwt2-gear-column h2 {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.iwt2-gear-class-list,
|
||||
.iwt2-gear-slot-list,
|
||||
.iwt2-infusion-list,
|
||||
.iwt2-gear-cost-list {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.iwt2-gear-class {
|
||||
gap: 6px;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
min-height: 42px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.iwt2-gear-class > span,
|
||||
.iwt2-infusion-row > span {
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
.iwt2-gear-class strong,
|
||||
.iwt2-gear-slot strong,
|
||||
.iwt2-infusion-row strong,
|
||||
.iwt2-gear-detail strong {
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.iwt2-gear-class small,
|
||||
.iwt2-gear-slot small,
|
||||
.iwt2-infusion-row small,
|
||||
.iwt2-gear-detail small,
|
||||
.iwt2-gear-cost-list span {
|
||||
font-size: 0.52rem;
|
||||
}
|
||||
|
||||
.iwt2-gear-slot {
|
||||
min-height: 45px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.iwt2-gear-upgrade-button {
|
||||
min-height: 34px;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.iwt2-gear-back {
|
||||
min-height: 30px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.iwt2-gear-detail {
|
||||
gap: 6px;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.iwt2-gear-bonus-grid {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.iwt2-gear-bonus-grid span {
|
||||
padding: 5px 6px;
|
||||
}
|
||||
|
||||
.iwt2-gear-bonus-grid strong,
|
||||
.iwt2-gear-bonus-grid small {
|
||||
font-size: 0.52rem;
|
||||
}
|
||||
|
||||
.iwt2-infusion-row {
|
||||
gap: 7px;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
min-height: 58px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.iwt2-gear-message {
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.iwt2-action-row strong,
|
||||
.iwt2-info-row strong {
|
||||
font-size: 0.78rem;
|
||||
@@ -1024,32 +1772,34 @@
|
||||
}
|
||||
|
||||
.iwt2-party-list {
|
||||
gap: 5px;
|
||||
left: 10px;
|
||||
max-height: calc(100% - 84px);
|
||||
align-content: center;
|
||||
gap: 7px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
top: 10px;
|
||||
top: 0;
|
||||
width: 154px;
|
||||
}
|
||||
|
||||
.iwt2-party-row {
|
||||
gap: 5px;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
height: 62px;
|
||||
min-height: 62px;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
height: 70px;
|
||||
min-height: 70px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.iwt2-party-row.has-target-binding {
|
||||
grid-template-columns: 24px 20px minmax(0, 1fr);
|
||||
grid-template-columns: 24px 22px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.iwt2-party-row > span,
|
||||
.iwt2-party-row > .iwt2-party-portrait,
|
||||
.iwt2-party-row .iwt2-party-target-key {
|
||||
height: 20px;
|
||||
height: 22px;
|
||||
min-width: 20px;
|
||||
width: 20px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.iwt2-party-row .iwt2-party-target-key .controller-face-icon {
|
||||
@@ -1071,8 +1821,12 @@
|
||||
}
|
||||
|
||||
.iwt2-party-row .iwt2-bar {
|
||||
height: 7px;
|
||||
margin-top: 2px;
|
||||
height: 12px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.iwt2-party-row .iwt2-bar.mana {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.iwt2-party-effects {
|
||||
@@ -1092,6 +1846,7 @@
|
||||
|
||||
.iwt2-ability-bar {
|
||||
bottom: 8px;
|
||||
display: none;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(6, 36px);
|
||||
max-width: 256px;
|
||||
@@ -11955,6 +12710,17 @@ h2 {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip {
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.iwt2-difficulty-strip > button {
|
||||
min-height: 34px;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
.iwt2-action-row:not(:has(small)),
|
||||
.iwt2-info-row:not(:has(small)) {
|
||||
min-height: 32px;
|
||||
|
||||
@@ -116,7 +116,12 @@ function App() {
|
||||
}
|
||||
|
||||
if (selectedVersion === 'iwt2') {
|
||||
return <IWantToHeal2App onBackToGameSelect={() => setSelectedVersion(null)} />
|
||||
return (
|
||||
<IWantToHeal2App
|
||||
onlineBackupsAvailable={authSession.account?.id !== -1}
|
||||
onBackToGameSelect={() => setSelectedVersion(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,9 +9,13 @@ export type RewardSummaryBase = {
|
||||
unlockedAbilities: DungeonReward['unlockedAbilities']
|
||||
}
|
||||
|
||||
export type RunBossCoinAward = NonNullable<DungeonReward['bonusItem']> & {
|
||||
sourceLabel: string
|
||||
}
|
||||
|
||||
export type PvpRunRewardSummary = RewardSummaryBase & {
|
||||
bossesKilled: number
|
||||
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
||||
loot: RunBossCoinAward[]
|
||||
}
|
||||
|
||||
export function createEmptyRewardSummary(): RewardSummaryBase {
|
||||
@@ -60,11 +64,13 @@ export function mergeDungeonRewardSummary<TSummary extends RewardSummaryBase>(
|
||||
export function mergePvpRunRewardSummary(
|
||||
current: PvpRunRewardSummary,
|
||||
reward: DungeonReward,
|
||||
{ bossKilled }: { bossKilled: boolean },
|
||||
{ bossKilled, sourceLabel }: { bossKilled: boolean; sourceLabel?: string },
|
||||
) {
|
||||
return {
|
||||
...mergeDungeonRewardSummary(current, reward),
|
||||
bossesKilled: current.bossesKilled + (bossKilled ? 1 : 0),
|
||||
loot: reward.bonusItem ? [...current.loot, reward.bonusItem] : current.loot,
|
||||
loot: reward.bonusItem
|
||||
? [...current.loot, { ...reward.bonusItem, sourceLabel: sourceLabel ?? `Boss ${current.bossesKilled + 1}` }]
|
||||
: current.loot,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
completeDungeon,
|
||||
completeRoguelike,
|
||||
loadProfile,
|
||||
recordBossKill,
|
||||
type DungeonReward,
|
||||
rollEncounterLoot,
|
||||
type LootRoll,
|
||||
@@ -55,6 +54,7 @@ import {
|
||||
createPveCombatState,
|
||||
createPveRoguelikeRunStart,
|
||||
} from '../combat/pveRoguelikeRunSetup'
|
||||
import type { RunBossCoinAward } from '../combat/rewardSummaries'
|
||||
import {
|
||||
appendCombatLog,
|
||||
type BasicFloatingCombatText,
|
||||
@@ -79,7 +79,7 @@ import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
||||
import { PartyMemberFrame } from './PartyFrames'
|
||||
import { ResourceBar, SpellBar } from './SpellBars'
|
||||
import { barFillStyle } from './barStyles'
|
||||
import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
|
||||
import { BonusItemReward, LootRollList, PvpRunLootList, RewardXpSummary } from './RewardPanels'
|
||||
import { ResultScreen } from './ResultScreen'
|
||||
import {
|
||||
DualScreenTopCombat,
|
||||
@@ -236,6 +236,7 @@ function makeRoguelikeSegment(
|
||||
mode: RoguelikeMode,
|
||||
): RoguelikeEncounter[] {
|
||||
const mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
||||
const stageOneDamageScale = 0.58 + (mode === 'raid' ? 0.13 : 0.1)
|
||||
return buildRoguelikeSegment<RoguelikeMechanic, { roguelikeMechanics: RoguelikeMechanic[] }>({
|
||||
pool,
|
||||
stage,
|
||||
@@ -243,8 +244,8 @@ function makeRoguelikeSegment(
|
||||
trashCandidateCount: (trashCount) => Math.min(trashCount, 4 + stage * (mode === 'raid' ? 1 : 2)),
|
||||
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
|
||||
healthScale: difficulty.healthMultiplier * (0.64 + stage * (mode === 'raid' ? 0.15 : 0.11)),
|
||||
damageScale: difficulty.damageMultiplier * (0.58 + stage * (mode === 'raid' ? 0.13 : 0.1)),
|
||||
partyDamageScale: 0.9 + stage * 0.05,
|
||||
damageScale: difficulty.damageMultiplier * stageOneDamageScale,
|
||||
partyDamageScale: 0.95,
|
||||
idBase: 900000,
|
||||
bossDescription: (selectedMechanics) => `Roguelike boss with ${selectedMechanics.map(mechanicLabel).join(', ')}.`,
|
||||
extraFields: (_encounter, isBoss, selectedMechanics) => ({
|
||||
@@ -265,6 +266,7 @@ export function CombatScreen({
|
||||
roguelikeAbilityLabelMode = 'ability',
|
||||
roguelikeEncounterPool,
|
||||
onExit,
|
||||
onMainMenu = onExit,
|
||||
onProfileUpdated,
|
||||
}: {
|
||||
difficulty: Difficulty
|
||||
@@ -278,6 +280,7 @@ export function CombatScreen({
|
||||
roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
|
||||
roguelikeEncounterPool?: DungeonEncounter[]
|
||||
onExit: () => void
|
||||
onMainMenu?: () => void
|
||||
onProfileUpdated: (profile: CharacterProfile) => void
|
||||
}) {
|
||||
const staticEncounters = useMemo(
|
||||
@@ -359,6 +362,7 @@ export function CombatScreen({
|
||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||
const [rewardError, setRewardError] = useState('')
|
||||
const [lootRolls, setLootRolls] = useState<LootRoll[]>([])
|
||||
const [roguelikeBossCoins, setRoguelikeBossCoins] = useState<RunBossCoinAward[]>([])
|
||||
const [showEndLog, setShowEndLog] = useState(false)
|
||||
const {
|
||||
floatingTexts,
|
||||
@@ -540,9 +544,31 @@ export function CombatScreen({
|
||||
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
|
||||
if (recordedBossKillIdsRef.current.has(key)) return
|
||||
recordedBossKillIdsRef.current.add(key)
|
||||
recordBossKill(encounter.id, { petVariant: 'purple' })
|
||||
completeRoguelike(
|
||||
dungeon.id,
|
||||
difficulty.id,
|
||||
0,
|
||||
0,
|
||||
Math.max(1, Math.round((Date.now() - runStartedAtRef.current) / 1000)),
|
||||
{
|
||||
bossesCleared: 0,
|
||||
fightsCleared: 0,
|
||||
lootSourceEncounterId: encounter.id,
|
||||
roguelikeStage,
|
||||
},
|
||||
)
|
||||
.then((result) => {
|
||||
onProfileUpdated(result.profile)
|
||||
if (result.bonusItem) {
|
||||
setRoguelikeBossCoins((current) => [...current, {
|
||||
...result.bonusItem!,
|
||||
sourceLabel: encounter.enemyName,
|
||||
}])
|
||||
addLog(
|
||||
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
|
||||
'loot',
|
||||
)
|
||||
}
|
||||
if (result.petAwarded) {
|
||||
addLog(
|
||||
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
|
||||
@@ -552,11 +578,11 @@ export function CombatScreen({
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
addLog(
|
||||
reason instanceof Error ? reason.message : 'Unable to record boss kill.',
|
||||
reason instanceof Error ? reason.message : 'Unable to award boss coins.',
|
||||
'danger',
|
||||
)
|
||||
})
|
||||
}, [addLog, encounterIndex, isRoguelike, onProfileUpdated])
|
||||
}, [addLog, difficulty.id, dungeon.id, encounterIndex, isRoguelike, onProfileUpdated, roguelikeStage])
|
||||
|
||||
const resetRun = useCallback(() => {
|
||||
const nextRoguelikeEncounters = roguelikeMode
|
||||
@@ -581,6 +607,7 @@ export function CombatScreen({
|
||||
setReward(setup.defaults.reward)
|
||||
setRewardError(setup.defaults.rewardError)
|
||||
setLootRolls(setup.defaults.lootRolls)
|
||||
setRoguelikeBossCoins([])
|
||||
setShowEndLog(setup.defaults.showEndLog)
|
||||
clearFloatingTexts()
|
||||
setRoguelikeUpgrades(setup.defaults.roguelikeUpgrades)
|
||||
@@ -1374,10 +1401,10 @@ export function CombatScreen({
|
||||
<div>
|
||||
<p className="eyebrow">Game Paused</p>
|
||||
<h2>{dungeon.name}</h2>
|
||||
<p>Combat is stopped. Resume the fight or leave the current run.</p>
|
||||
<button onClick={() => setPaused(false)} type="button">Resume</button>
|
||||
<button className="secondary-result-button" onClick={onExit} type="button">
|
||||
Leave {contentName}
|
||||
<p>Combat is stopped. Continue the fight or return to the main menu.</p>
|
||||
<button onClick={() => setPaused(false)} type="button">Continue</button>
|
||||
<button className="secondary-result-button" onClick={onMainMenu} type="button">
|
||||
Main Menu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1434,7 +1461,11 @@ export function CombatScreen({
|
||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||
{reward && (
|
||||
<>
|
||||
{isRoguelike && <p>Run totals</p>}
|
||||
<RewardXpSummary reward={reward} />
|
||||
{isRoguelike && (
|
||||
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
|
||||
)}
|
||||
{!isRoguelike && (
|
||||
<>
|
||||
<p>Component tier: item level {reward.droppedItemLevel}.</p>
|
||||
@@ -1459,6 +1490,7 @@ export function CombatScreen({
|
||||
<>
|
||||
<RewardXpSummary reward={reward} />
|
||||
<p>{encounterIndex} encounters cleared.</p>
|
||||
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
|
||||
<p className="efficiency-result">
|
||||
{reward.resourceSpent} {gameClass.resourceName} spent
|
||||
<small>{reward.durationSeconds}s survived</small>
|
||||
|
||||
@@ -229,6 +229,7 @@ function spellResourceCost(spell: Spell, buffs: StackCounts<SelfBuffId>, debuffs
|
||||
|
||||
function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
|
||||
const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
||||
const stageOneDamageScale = 0.8 + (kind === 'raid' ? 0.18 : 0.14)
|
||||
return buildRoguelikeSegment<BossMechanic, {
|
||||
bossMechanics: BossMechanic[]
|
||||
sourceEncounterId?: number
|
||||
@@ -240,8 +241,8 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
|
||||
trashCandidateCount: (trashCount) => Math.min(trashCount, 5 + stage * (kind === 'raid' ? 1 : 2)),
|
||||
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
|
||||
healthScale: 0.75 + stage * (kind === 'raid' ? 0.28 : 0.22),
|
||||
damageScale: 0.8 + stage * (kind === 'raid' ? 0.18 : 0.14),
|
||||
partyDamageScale: 0.85 + stage * 0.04,
|
||||
damageScale: stageOneDamageScale,
|
||||
partyDamageScale: 0.89,
|
||||
idBase: 910000,
|
||||
bossDescription: (selectedMechanics) => `PvP boss with ${selectedMechanics.join(', ')}.`,
|
||||
extraFields: (encounter, isBoss, selectedMechanics) => ({
|
||||
@@ -293,6 +294,7 @@ export function PvPRoguelikeScreen({
|
||||
contentType,
|
||||
encounterPool,
|
||||
onExit,
|
||||
onMainMenu = onExit,
|
||||
onProfileUpdated,
|
||||
}: {
|
||||
profile: CharacterProfile
|
||||
@@ -300,6 +302,7 @@ export function PvPRoguelikeScreen({
|
||||
contentType: PvpContentType
|
||||
encounterPool: DungeonEncounter[]
|
||||
onExit: () => void
|
||||
onMainMenu?: () => void
|
||||
onProfileUpdated: (profile: CharacterProfile) => void
|
||||
}) {
|
||||
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
||||
@@ -537,7 +540,10 @@ export function PvPRoguelikeScreen({
|
||||
)
|
||||
.then((result) => {
|
||||
setReward(result)
|
||||
setRunSummary((current) => mergePvpRunRewardSummary(current, result, { bossKilled: isBossReward }))
|
||||
setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
|
||||
bossKilled: isBossReward,
|
||||
sourceLabel: rewardEncounter?.enemyName,
|
||||
}))
|
||||
onProfileUpdated(result.profile)
|
||||
if (result.experienceGained > 0) {
|
||||
addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
||||
@@ -579,7 +585,10 @@ export function PvPRoguelikeScreen({
|
||||
)
|
||||
.then((result) => {
|
||||
setReward(result)
|
||||
setRunSummary((current) => mergePvpRunRewardSummary(current, result, { bossKilled: false }))
|
||||
setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
|
||||
bossKilled: false,
|
||||
sourceLabel: 'Match Win',
|
||||
}))
|
||||
onProfileUpdated(result.profile)
|
||||
if (result.experienceGained > 0) {
|
||||
addLog(`Match win bonus: +${result.experienceGained} XP.`, 'loot')
|
||||
@@ -1416,14 +1425,14 @@ export function PvPRoguelikeScreen({
|
||||
if (!entry || pvpOverlayEntryDisabled(entry)) return
|
||||
if (entry.kind === 'queueBack') onExit()
|
||||
else if (entry.kind === 'pauseResume') setPaused(false)
|
||||
else if (entry.kind === 'pauseLeave') onExit()
|
||||
else if (entry.kind === 'pauseLeave') onMainMenu()
|
||||
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
|
||||
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
|
||||
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
|
||||
}
|
||||
|
||||
useGameAction((action) => {
|
||||
if (status === 'queueing' || status === 'round-countdown') {
|
||||
if (status === 'queueing') {
|
||||
if (action === 'back' || action === 'pause') onExit()
|
||||
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
|
||||
return
|
||||
@@ -1440,6 +1449,13 @@ export function PvPRoguelikeScreen({
|
||||
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
|
||||
return
|
||||
}
|
||||
if (status === 'round-countdown') {
|
||||
if (action === 'back' || action === 'pause') {
|
||||
setOverlaySelectedIndex(0)
|
||||
setPaused(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (status === 'upgrade-choice') {
|
||||
if (action === 'confirm') {
|
||||
openPvpOverlayEntry(activePvpOverlayEntry())
|
||||
@@ -1773,16 +1789,16 @@ export function PvPRoguelikeScreen({
|
||||
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
|
||||
type="button"
|
||||
>
|
||||
Resume
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
onClick={onExit}
|
||||
onClick={onMainMenu}
|
||||
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
|
||||
type="button"
|
||||
>
|
||||
Leave
|
||||
Main Menu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1808,11 +1824,12 @@ export function PvPRoguelikeScreen({
|
||||
>
|
||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||
<div className="reward-summary">
|
||||
<p>Run totals</p>
|
||||
<p>{runSummary.bossesKilled} bosses killed.</p>
|
||||
<RewardXpSummary reward={runSummary} />
|
||||
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
|
||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||
<PvpRunLootList loot={runSummary.loot} />
|
||||
<PvpRunLootList loot={runSummary.loot} emptyLabel="No boss coins collected this run." />
|
||||
{reward && runSummary.bossesKilled === 0 && (
|
||||
<>
|
||||
<RewardXpSummary reward={reward} />
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
type Spell,
|
||||
} from '../game'
|
||||
import { completeRoguelike, recordPvpMatch } from '../profile'
|
||||
import type { CharacterProfile } from '../profile'
|
||||
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
||||
import type { GameMode } from '../gameRepository'
|
||||
import { roguelikeCoinItemLevel } from '../shared/rewardRules.mjs'
|
||||
import { PartyMemberFrame } from './PartyFrames'
|
||||
import { SpellBar } from './SpellBars'
|
||||
import { barFillStyle } from './barStyles'
|
||||
@@ -64,9 +65,10 @@ import {
|
||||
import {
|
||||
createEmptyRewardSummary,
|
||||
mergeDungeonRewardSummary,
|
||||
type RunBossCoinAward,
|
||||
type RewardSummaryBase,
|
||||
} from '../combat/rewardSummaries'
|
||||
import { RewardXpSummary } from './RewardPanels'
|
||||
import { PvpRunLootList, RewardXpSummary } from './RewardPanels'
|
||||
import { ResultScreen } from './ResultScreen'
|
||||
import {
|
||||
publishPvpMatchState,
|
||||
@@ -158,6 +160,25 @@ function slotSpellName(slot: SlotKey, spells: Spell[], fallback: string) {
|
||||
return spells.find((candidate) => candidate.key === slot)?.name ?? fallback
|
||||
}
|
||||
|
||||
function starterSpellsForClass(gameClass: CharacterProfile['classes'][number]) {
|
||||
return gameClass.spells
|
||||
.filter((spell) => spell.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
.map((spell, index) => toCombatSpell(spell, String(index + 1)))
|
||||
}
|
||||
|
||||
function randomCpuClass(classes: CharacterProfile['classes'], fallbackId: CharacterProfile['classes'][number]['id']) {
|
||||
const pool = classes.length > 0 ? classes : []
|
||||
return pool[Math.floor(Math.random() * pool.length)] ?? classes.find((candidate) => candidate.id === fallbackId)
|
||||
}
|
||||
|
||||
function stadiumBossCoinSource(bosses: DungeonEncounter[], stage: number) {
|
||||
const targetItemLevel = roguelikeCoinItemLevel(stage)
|
||||
const eligible = bosses.filter((boss) => boss.lootTables.some((item) => item.itemLevel === targetItemLevel))
|
||||
const pool = eligible.length > 0 ? eligible : bosses
|
||||
return pool.length > 0 ? pool[(stage - 1) % pool.length] : undefined
|
||||
}
|
||||
|
||||
function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] {
|
||||
const directName = slotSpellName('1', spells, 'Mend')
|
||||
const sustainName = slotSpellName('2', spells, 'Renew')
|
||||
@@ -330,11 +351,11 @@ export function PvpStadiumScreen({
|
||||
onProfileUpdated: (profile: CharacterProfile) => void
|
||||
}) {
|
||||
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
||||
const starterSpells = useMemo(() => gameClass.spells
|
||||
.filter((spell) => spell.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
||||
const [cpuGameClass, setCpuGameClass] = useState(() => randomCpuClass(profile.classes, gameClass.id) ?? gameClass)
|
||||
const starterSpells = useMemo(() => starterSpellsForClass(gameClass), [gameClass])
|
||||
const cpuStarterSpells = useMemo(() => starterSpellsForClass(cpuGameClass), [cpuGameClass])
|
||||
const buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells])
|
||||
const cpuBuffCatalog = useMemo(() => buildStadiumBuffs(cpuStarterSpells), [cpuStarterSpells])
|
||||
const partyTemplate = useMemo(
|
||||
() => INITIAL_PARTY.map((member) => ({
|
||||
...member,
|
||||
@@ -351,6 +372,12 @@ export function PvpStadiumScreen({
|
||||
)
|
||||
const rewardDungeon = profile.dungeons.find((candidate) => candidate.contentType === 'dungeon') ?? profile.dungeons[0]
|
||||
const rewardDifficulty = rewardDungeon.difficulties[0]
|
||||
const stadiumCoinBosses = useMemo(
|
||||
() => profile.dungeons
|
||||
.flatMap((candidate) => candidate.encounters)
|
||||
.filter((candidate) => candidate.isBoss && candidate.lootTables.length > 0),
|
||||
[profile.dungeons],
|
||||
)
|
||||
const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'shop' | 'won' | 'lost'>('queueing')
|
||||
const [playerSide, setPlayerSide] = useState<StadiumSideState>(() => createStadiumStarterSide<StadiumBuffId>({
|
||||
partyTemplate,
|
||||
@@ -382,6 +409,7 @@ export function PvpStadiumScreen({
|
||||
clearFloatingTexts,
|
||||
} = useSidedFloatingCombatText()
|
||||
const [rewardSummary, setRewardSummary] = useState<RewardSummaryBase>(() => createEmptyRewardSummary())
|
||||
const [stadiumBossCoins, setStadiumBossCoins] = useState<RunBossCoinAward[]>([])
|
||||
const [rewardError, setRewardError] = useState('')
|
||||
const [showEndLog, setShowEndLog] = useState(false)
|
||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||
@@ -417,8 +445,8 @@ export function PvpStadiumScreen({
|
||||
cost: playerSpellSlotCost,
|
||||
})
|
||||
const opponentBuffSummary = useMemo(
|
||||
() => summarizeStacks(cpuSide.buffs, buffCatalog),
|
||||
[buffCatalog, cpuSide.buffs],
|
||||
() => summarizeStacks(cpuSide.buffs, liveMatch ? buffCatalog : cpuBuffCatalog),
|
||||
[buffCatalog, cpuBuffCatalog, cpuSide.buffs, liveMatch],
|
||||
)
|
||||
const playerBuffSummary = useMemo(
|
||||
() => summarizeStacks(playerSide.buffs, buffCatalog),
|
||||
@@ -473,18 +501,31 @@ export function PvpStadiumScreen({
|
||||
setStatus('round-countdown')
|
||||
}, [clearRoundCountdown, startRoundCountdown])
|
||||
|
||||
const awardXp = useCallback((key: string, mode: StadiumExperienceMode) => {
|
||||
const awardXp = useCallback((
|
||||
key: string,
|
||||
mode: StadiumExperienceMode,
|
||||
coinSource?: { encounter: DungeonEncounter; stage: number; label: string },
|
||||
) => {
|
||||
if (awardedXpRef.current.has(key)) return
|
||||
awardedXpRef.current.add(key)
|
||||
completeRoguelike(rewardDungeon.id, rewardDifficulty.id, 0, 0, Math.max(1, Math.floor(playerRef.current.survivalSeconds || 1)), {
|
||||
bossesCleared: 0,
|
||||
fightsCleared: 1,
|
||||
experienceMode: mode,
|
||||
lootSourceEncounterId: coinSource?.encounter.id,
|
||||
roguelikeStage: coinSource?.stage,
|
||||
})
|
||||
.then((result) => {
|
||||
setRewardSummary((current) => mergeDungeonRewardSummary(current, result))
|
||||
if (result.bonusItem && coinSource) {
|
||||
setStadiumBossCoins((current) => [...current, {
|
||||
...result.bonusItem!,
|
||||
sourceLabel: coinSource.label,
|
||||
}])
|
||||
}
|
||||
onProfileUpdated(result.profile)
|
||||
if (result.experienceGained > 0) addLog(`+${result.experienceGained} XP awarded.`, 'loot')
|
||||
if (result.bonusItem) addLog(`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`, 'loot')
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
setRewardError(reason instanceof Error ? reason.message : 'Unable to award Stadium XP.')
|
||||
@@ -521,6 +562,7 @@ export function PvpStadiumScreen({
|
||||
setLiveMatch(setup.liveMatch)
|
||||
setPaused(setup.defaults.paused)
|
||||
setRewardSummary(createEmptyRewardSummary())
|
||||
setStadiumBossCoins([])
|
||||
setRewardError(setup.defaults.rewardError)
|
||||
setShowEndLog(setup.defaults.showEndLog)
|
||||
clearFloatingTexts()
|
||||
@@ -559,11 +601,13 @@ export function PvpStadiumScreen({
|
||||
setLiveMatch(null)
|
||||
setPaused(setup.defaults.paused)
|
||||
setRewardSummary(createEmptyRewardSummary())
|
||||
setStadiumBossCoins([])
|
||||
setRewardError(setup.defaults.rewardError)
|
||||
setShowEndLog(setup.defaults.showEndLog)
|
||||
clearFloatingTexts()
|
||||
loggedOpponentRoundRef.current = ''
|
||||
const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
|
||||
setCpuGameClass(randomCpuClass(profile.classes, gameClass.id) ?? gameClass)
|
||||
setCpuDifficulty(randomCpu)
|
||||
setQueueMessage(message)
|
||||
setLog([{ id: 1, text: message, tone: 'system' }])
|
||||
@@ -591,7 +635,7 @@ export function PvpStadiumScreen({
|
||||
},
|
||||
},
|
||||
})
|
||||
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameMode, partyTemplate, resetShopTimer, setSelectedTargetId, startLiveMatch])
|
||||
}, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, cpuPartyTemplate, gameClass, gameMode, partyTemplate, profile.classes, resetShopTimer, setSelectedTargetId, startLiveMatch])
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => startMatch())
|
||||
@@ -602,6 +646,7 @@ export function PvpStadiumScreen({
|
||||
current: StadiumSideState,
|
||||
setCurrent: Dispatch<SetStateAction<StadiumSideState>>,
|
||||
sideName: 'player' | 'cpu',
|
||||
spells: Spell[],
|
||||
spell: Spell,
|
||||
targetId: string,
|
||||
) => {
|
||||
@@ -614,7 +659,7 @@ export function PvpStadiumScreen({
|
||||
id: (slot) => `slot${slot as SlotKey}-extra-target` as StadiumBuffId,
|
||||
})
|
||||
const renewDuration = hasBuff('slot2-double-duration') && spell.key === '2' ? 10 : 5
|
||||
const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
|
||||
const shieldEffect = spells.find((candidate) => candidate.kind === 'shield')
|
||||
const shieldPower = (sourcePower: number, strength = 1) => Math.round(
|
||||
sourcePower
|
||||
* strength
|
||||
@@ -717,7 +762,7 @@ export function PvpStadiumScreen({
|
||||
wasReady: effectiveCost === 0 && current.freeCastReady,
|
||||
},
|
||||
})
|
||||
}, [addFloatingHeal, starterSpells])
|
||||
}, [addFloatingHeal])
|
||||
|
||||
const castPlayerSpell = useCallback((spell: Spell) => {
|
||||
if (status !== 'playing' || !playerAlive) return
|
||||
@@ -726,9 +771,9 @@ export function PvpStadiumScreen({
|
||||
const next = typeof value === 'function' ? value(playerRef.current) : value
|
||||
playerRef.current = next
|
||||
setPlayerSide(next)
|
||||
}, 'player', spell, targetId)
|
||||
}, 'player', starterSpells, spell, targetId)
|
||||
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
|
||||
}, [addLog, applySpell, playerAlive, status])
|
||||
}, [addLog, applySpell, playerAlive, starterSpells, status])
|
||||
|
||||
const getTargetParty = useCallback(() => playerRef.current.party, [])
|
||||
const {
|
||||
@@ -749,7 +794,7 @@ export function PvpStadiumScreen({
|
||||
runCpuHealTurn({
|
||||
side: cpuRef.current,
|
||||
elapsedTicks,
|
||||
spells: starterSpells,
|
||||
spells: cpuStarterSpells,
|
||||
behavior,
|
||||
preferSlots: true,
|
||||
applySpell: (side, spell, targetId) => {
|
||||
@@ -757,10 +802,10 @@ export function PvpStadiumScreen({
|
||||
const next = typeof value === 'function' ? value(cpuRef.current) : value
|
||||
cpuRef.current = next
|
||||
setCpuSide(next)
|
||||
}, 'cpu', spell, targetId)
|
||||
}, 'cpu', cpuStarterSpells, spell, targetId)
|
||||
},
|
||||
})
|
||||
}, [applySpell, cpuDifficulty, elapsedTicks, starterSpells, status])
|
||||
}, [applySpell, cpuDifficulty, cpuStarterSpells, elapsedTicks, status])
|
||||
|
||||
const advanceBoss = useCallback((side: StadiumSideState) => {
|
||||
if (side.roundStatus !== 'playing') return side
|
||||
@@ -823,7 +868,7 @@ export function PvpStadiumScreen({
|
||||
})
|
||||
if (!liveMatchRef.current) {
|
||||
const purchases = chooseStadiumCpuPurchases<StadiumBuffId, StadiumBuff>(
|
||||
buffCatalog,
|
||||
cpuBuffCatalog,
|
||||
stadiumShopPointsForOutcome(outcome, 'opponent'),
|
||||
)
|
||||
setCpuSide((current) => {
|
||||
@@ -832,14 +877,17 @@ export function PvpStadiumScreen({
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [buffCatalog, startShopTimer])
|
||||
}, [cpuBuffCatalog, startShopTimer])
|
||||
|
||||
const finishRound = useCallback((outcome: StadiumRoundOutcome) => {
|
||||
if (status !== 'playing') return
|
||||
if (roundResolvedRef.current) return
|
||||
roundResolvedRef.current = true
|
||||
const result = resolveStadiumRound({ outcome, roundIndex, wins: roundWins })
|
||||
awardXp(result.roundExperience.key, result.roundExperience.mode)
|
||||
const roundCoinSource = stadiumBossCoinSource(stadiumCoinBosses, roundIndex)
|
||||
awardXp(result.roundExperience.key, result.roundExperience.mode, roundCoinSource
|
||||
? { encounter: roundCoinSource, stage: roundIndex, label: `Round ${roundIndex}: ${roundCoinSource.enemyName}` }
|
||||
: undefined)
|
||||
addLog(result.log.text, result.log.tone)
|
||||
if (result.status === 'won') {
|
||||
setRoundWins(result.nextWins)
|
||||
@@ -849,7 +897,13 @@ export function PvpStadiumScreen({
|
||||
playerRef.current = next
|
||||
return next
|
||||
})
|
||||
if (result.matchExperience) awardXp(result.matchExperience.key, result.matchExperience.mode)
|
||||
if (result.matchExperience) {
|
||||
const matchCoinStage = roundIndex + 1
|
||||
const matchCoinSource = stadiumBossCoinSource(stadiumCoinBosses, matchCoinStage)
|
||||
awardXp(result.matchExperience.key, result.matchExperience.mode, matchCoinSource
|
||||
? { encounter: matchCoinSource, stage: matchCoinStage, label: `Match Win: ${matchCoinSource.enemyName}` }
|
||||
: undefined)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (result.status === 'lost') {
|
||||
@@ -863,7 +917,7 @@ export function PvpStadiumScreen({
|
||||
return
|
||||
}
|
||||
beginShop(outcome, result.nextWins)
|
||||
}, [addLog, awardXp, beginShop, roundIndex, roundWins, status])
|
||||
}, [addLog, awardXp, beginShop, roundIndex, roundWins, stadiumCoinBosses, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'playing' || paused) return
|
||||
@@ -1077,12 +1131,12 @@ export function PvpStadiumScreen({
|
||||
encounterCount: 5,
|
||||
party: playerSide.party,
|
||||
opponentName: opponentLabel,
|
||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `${cpuGameClass.name} | CPU ${cpuDifficulty}` : cpuGameClass.name),
|
||||
opponentParty: cpuSide.party,
|
||||
opponentEnemyHealth: 0,
|
||||
opponentResource: cpuSide.resource,
|
||||
opponentMaxResource: MAX_RESOURCE,
|
||||
opponentResourceName: gameClass.resourceName,
|
||||
opponentResourceName: liveMatch ? gameClass.resourceName : cpuGameClass.resourceName,
|
||||
opponentBuffSummary,
|
||||
opponentDebuffSummary,
|
||||
floatingTexts: dualScreenFloatingTexts,
|
||||
@@ -1110,13 +1164,15 @@ export function PvpStadiumScreen({
|
||||
activeBindings,
|
||||
controllerIconStyle,
|
||||
cpuDifficulty,
|
||||
cpuGameClass.name,
|
||||
cpuGameClass.resourceName,
|
||||
cpuSide.party,
|
||||
cpuSide.resource,
|
||||
cpuSide.survivalSeconds,
|
||||
directPartyTargeting,
|
||||
dualScreenFloatingTexts,
|
||||
gameClass.resourceName,
|
||||
liveMatch?.opponentClassName,
|
||||
liveMatch,
|
||||
opponentBuffSummary,
|
||||
opponentDebuffSummary,
|
||||
opponentLabel,
|
||||
@@ -1277,10 +1333,10 @@ export function PvpStadiumScreen({
|
||||
<div>
|
||||
<p className="eyebrow">Opponent</p>
|
||||
<h2>{opponentLabel}</h2>
|
||||
<small>Survival {formatTime(cpuSide.survivalSeconds)}</small>
|
||||
<small>{liveMatch?.opponentClassName ?? cpuGameClass.name} | Survival {formatTime(cpuSide.survivalSeconds)}</small>
|
||||
</div>
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
||||
<span>{liveMatch ? gameClass.resourceName : cpuGameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
||||
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1401,8 +1457,10 @@ export function PvpStadiumScreen({
|
||||
>
|
||||
<p>Final score {roundWins.player} - {roundWins.opponent}</p>
|
||||
<div className="reward-summary">
|
||||
<p>Run totals</p>
|
||||
<RewardXpSummary reward={rewardSummary} />
|
||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||
<PvpRunLootList loot={stadiumBossCoins} emptyLabel="No boss coins collected this run." />
|
||||
</div>
|
||||
</ResultScreen>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CombatLogEntry } from '../game'
|
||||
import type { RunBossCoinAward } from '../combat/rewardSummaries'
|
||||
import type { DungeonReward, LootRoll } from '../profile'
|
||||
|
||||
type BonusItem = NonNullable<DungeonReward['bonusItem']>
|
||||
@@ -119,14 +120,16 @@ export function LootRollList({
|
||||
|
||||
export function PvpRunLootList({
|
||||
loot,
|
||||
emptyLabel = 'No boss coins awarded',
|
||||
}: {
|
||||
loot: BonusItem[]
|
||||
loot: RunBossCoinAward[]
|
||||
emptyLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="run-loot-rolls">
|
||||
{loot.length > 0 ? loot.map((item, index) => (
|
||||
<div className="dropped" key={`${item.id}-${index}`}>
|
||||
<strong>Boss {index + 1}</strong>
|
||||
<strong>{item.sourceLabel}</strong>
|
||||
<span>
|
||||
{item.glyph} {item.name} x{item.quantity}
|
||||
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
|
||||
@@ -134,8 +137,8 @@ export function PvpRunLootList({
|
||||
</div>
|
||||
)) : (
|
||||
<div>
|
||||
<strong>Loot</strong>
|
||||
<span>No boss loot awarded</span>
|
||||
<strong>Boss Coins</strong>
|
||||
<span>{emptyLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1127,9 +1127,6 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
throw new Error('That difficulty is not available for this dungeon.')
|
||||
}
|
||||
const cd = save.characters[save.activeClassId]
|
||||
if (cd.level < difficulty.unlockLevel) {
|
||||
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
|
||||
}
|
||||
|
||||
const previousLevel = cd.level
|
||||
const previousExperience = cd.experience
|
||||
@@ -1251,9 +1248,6 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
throw new Error('That difficulty is not available for this roguelike.')
|
||||
}
|
||||
const cd = save.characters[save.activeClassId]
|
||||
if (cd.level < difficulty.unlockLevel) {
|
||||
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
|
||||
}
|
||||
|
||||
const previousLevel = cd.level
|
||||
const previousExperience = cd.experience
|
||||
|
||||
@@ -435,6 +435,10 @@ function hasDedicatedGameNavigation() {
|
||||
).some(isVisible)
|
||||
}
|
||||
|
||||
function isDedicatedNavigationAction(action: InputAction) {
|
||||
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
|
||||
}
|
||||
|
||||
function dispatchGameAction(action: InputAction, device: InputDevice) {
|
||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
||||
detail: { action, device },
|
||||
@@ -621,7 +625,12 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
setLastDevice(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()
|
||||
dispatchGameAction(action, device)
|
||||
return
|
||||
@@ -709,6 +718,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
'navigateRight',
|
||||
'confirm',
|
||||
'back',
|
||||
'pause',
|
||||
] satisfies InputAction[]
|
||||
const directTargetActions = [
|
||||
'targetParty1',
|
||||
@@ -738,7 +748,13 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
'navigateLeft',
|
||||
'navigateRight',
|
||||
] 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]
|
||||
: uiOverlay
|
||||
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
|
||||
|
||||
@@ -305,7 +305,6 @@ function IWantToHeal1App({
|
||||
const characterLevel = profile?.character.level ?? 0
|
||||
const savedTier = tierOptions.find((candidate) => (
|
||||
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
|
||||
&& characterLevel >= candidate.unlockLevel
|
||||
))
|
||||
if (savedTier) return savedTier
|
||||
for (let index = tierOptions.length - 1; index >= 0; index -= 1) {
|
||||
@@ -500,7 +499,7 @@ function IWantToHeal1App({
|
||||
|
||||
function dungeonEntries() {
|
||||
const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0]
|
||||
const locked = profile && difficulty ? profile.character.level < difficulty.unlockLevel : true
|
||||
const locked = !profile || !difficulty
|
||||
const entries: DungeonNavEntry[] = [
|
||||
{ kind: 'back' },
|
||||
]
|
||||
@@ -522,7 +521,7 @@ function IWantToHeal1App({
|
||||
entries.push({
|
||||
kind: 'activity',
|
||||
index,
|
||||
disabled: !profile || profile.character.level < candidateDifficulty.unlockLevel,
|
||||
disabled: !profile || !candidateDifficulty,
|
||||
})
|
||||
})
|
||||
entries.push(
|
||||
@@ -533,7 +532,7 @@ function IWantToHeal1App({
|
||||
entries.push({
|
||||
kind: 'tier',
|
||||
index,
|
||||
disabled: !profile || profile.character.level < difficultyOption.unlockLevel,
|
||||
disabled: !profile || !difficultyOption,
|
||||
})
|
||||
})
|
||||
entries.push({ kind: 'loot' })
|
||||
@@ -592,7 +591,6 @@ function IWantToHeal1App({
|
||||
(option) => option.droppedItemLevel === selectedDifficultyOption.droppedItemLevel,
|
||||
) ?? candidate.difficulties[0]
|
||||
: candidate.difficulties[0]
|
||||
if (profile && profile.character.level < difficulty.unlockLevel) return
|
||||
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||
else setSelectedDungeonId(candidate.id)
|
||||
setSelectedDifficultyId(difficulty.id)
|
||||
@@ -601,7 +599,7 @@ function IWantToHeal1App({
|
||||
function selectTierByIndex(index: number) {
|
||||
const difficulty = tierOptions[index]
|
||||
const activity = selectedActivityOption ?? activityOptions[0]
|
||||
if (!difficulty || !activity || (profile && profile.character.level < difficulty.unlockLevel)) return
|
||||
if (!difficulty || !activity) return
|
||||
setActivityPage(0)
|
||||
const nextActivity = activity.difficulties.some(
|
||||
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||
@@ -622,7 +620,7 @@ function IWantToHeal1App({
|
||||
function startSelectedRun(marathon: boolean) {
|
||||
const activity = selectedActivityOption ?? activityOptions[0]
|
||||
const difficulty = selectedDifficultyOption ?? activity?.difficulties[0]
|
||||
if (!activity || !difficulty || (profile && profile.character.level < difficulty.unlockLevel)) return
|
||||
if (!activity || !difficulty) return
|
||||
setSelectedMarathonMode(marathon)
|
||||
setCombatContentId(activity.id)
|
||||
setSelectedDifficultyId(difficulty.id)
|
||||
@@ -787,7 +785,6 @@ function IWantToHeal1App({
|
||||
|| !selectedActivityOption
|
||||
|| !selectedDifficultyOption
|
||||
) return null
|
||||
const locked = profile.character.level < selectedDifficultyOption.unlockLevel
|
||||
return {
|
||||
contentType: selectedActivityOption.contentType,
|
||||
title: selectedActivityOption.name,
|
||||
@@ -797,7 +794,7 @@ function IWantToHeal1App({
|
||||
difficultyName: selectedDifficultyOption.name,
|
||||
itemLevel: selectedDifficultyOption.droppedItemLevel,
|
||||
experience: Math.round(selectedActivityOption.experienceReward * selectedDifficultyOption.experienceMultiplier),
|
||||
lockedReason: locked ? `Unlocks at level ${selectedDifficultyOption.unlockLevel}` : undefined,
|
||||
lockedReason: undefined,
|
||||
stats: {
|
||||
health: `${selectedDifficultyOption.healthMultiplier.toFixed(2)}x`,
|
||||
damage: `${selectedDifficultyOption.damageMultiplier.toFixed(2)}x`,
|
||||
@@ -863,6 +860,7 @@ function IWantToHeal1App({
|
||||
onExit={() => {
|
||||
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
|
||||
}}
|
||||
onMainMenu={() => setScreen('menu')}
|
||||
onProfileUpdated={setProfile}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -896,6 +894,10 @@ function IWantToHeal1App({
|
||||
setRoguelikeVariant('pvp')
|
||||
setScreen('roguelike')
|
||||
}}
|
||||
onMainMenu={() => {
|
||||
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
|
||||
setScreen('menu')
|
||||
}}
|
||||
onProfileUpdated={setProfile}
|
||||
profile={profile}
|
||||
/>
|
||||
@@ -917,7 +919,6 @@ function IWantToHeal1App({
|
||||
const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE)
|
||||
const activity = selectedActivityOption ?? dungeon
|
||||
const selectedDifficulty = selectedDifficultyOption ?? activity.difficulties[0]
|
||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||
return (
|
||||
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''} ${screen === 'settings' ? 'settings-shell' : ''}`}>
|
||||
{screen !== 'hunter-profile' && (
|
||||
@@ -1271,13 +1272,11 @@ function IWantToHeal1App({
|
||||
const difficulty = candidate.difficulties.find(
|
||||
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
||||
) ?? candidate.difficulties[0]
|
||||
const locked = profile.character.level < difficulty.unlockLevel
|
||||
const selected = candidate.id === activity.id
|
||||
return (
|
||||
<button
|
||||
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('activity', index) ? 'game-selected' : ''}`}
|
||||
className={`activity-card ${selected ? 'selected' : ''} ${dungeonEntrySelected('activity', index) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={locked}
|
||||
key={candidate.id}
|
||||
onClick={() => {
|
||||
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||
@@ -1310,16 +1309,13 @@ function IWantToHeal1App({
|
||||
<h2>Run</h2>
|
||||
</div>
|
||||
<small>
|
||||
{difficultyLocked
|
||||
? `Unlocks at level ${selectedDifficulty.unlockLevel}`
|
||||
: 'Pick a hunt or marathon.'}
|
||||
Pick a hunt or marathon.
|
||||
</small>
|
||||
</div>
|
||||
<div className="part-picker">
|
||||
<button
|
||||
className={`primary-button selected-part ${dungeonEntrySelected('start') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={difficultyLocked}
|
||||
onClick={() => {
|
||||
setSelectedMarathonMode(false)
|
||||
setCombatContentId(activity.id)
|
||||
@@ -1334,7 +1330,6 @@ function IWantToHeal1App({
|
||||
<button
|
||||
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${dungeonEntrySelected('marathon') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={difficultyLocked}
|
||||
onClick={() => {
|
||||
setSelectedMarathonMode(true)
|
||||
setCombatContentId(activity.id)
|
||||
@@ -1355,17 +1350,15 @@ function IWantToHeal1App({
|
||||
<p className="eyebrow">Item Level</p>
|
||||
<h2>Tier</h2>
|
||||
</div>
|
||||
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
|
||||
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers are available at every level.</small>
|
||||
</div>
|
||||
<div className="tier-grid">
|
||||
{tierOptions.map((difficulty, index) => {
|
||||
const locked = profile.character.level < difficulty.unlockLevel
|
||||
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
||||
return (
|
||||
<button
|
||||
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''} ${dungeonEntrySelected('tier', index) ? 'game-selected' : ''}`}
|
||||
className={`${selected ? 'selected' : ''} ${dungeonEntrySelected('tier', index) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={locked}
|
||||
key={difficulty.id}
|
||||
onClick={() => {
|
||||
setActivityPage(0)
|
||||
@@ -1389,7 +1382,7 @@ function IWantToHeal1App({
|
||||
type="button"
|
||||
>
|
||||
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
||||
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
|
||||
<span>{difficulty.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Iwt2CloudSaveScreen,
|
||||
Iwt2CustomizeCharacterScreen,
|
||||
Iwt2DungeonsScreen,
|
||||
Iwt2GearUpgradeScreen,
|
||||
Iwt2HunterProfileScreen,
|
||||
Iwt2ModeScreen,
|
||||
Iwt2RoguelikeScreen,
|
||||
@@ -27,6 +28,13 @@ import {
|
||||
type Iwt2Save,
|
||||
} from './save/iwt2Repository'
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from './content/bosses'
|
||||
import { iwt2BossCoinRewardFor } from './content/bossRewards'
|
||||
import {
|
||||
findIwt2Difficulty,
|
||||
IWT2_DUNGEON_DIFFICULTIES,
|
||||
IWT2_RAID_DIFFICULTIES,
|
||||
type Iwt2Difficulty,
|
||||
} from './content/difficulties'
|
||||
import { abilitiesForHealer, IWT2_HEALER_METADATA } from './content/healerAbilities'
|
||||
import {
|
||||
buildIwt2OpponentDebuffChoices,
|
||||
@@ -38,6 +46,18 @@ import {
|
||||
type Iwt2RoguelikeSelfBuffId,
|
||||
type Iwt2RoguelikeVariant,
|
||||
} 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 =
|
||||
| 'menu'
|
||||
@@ -48,12 +68,14 @@ type Iwt2Screen =
|
||||
| 'roguelike'
|
||||
| 'roguelike-arena'
|
||||
| 'roguelike-upgrade'
|
||||
| 'gear-upgrade'
|
||||
| 'hunter-profile'
|
||||
| 'customize-character'
|
||||
| 'settings'
|
||||
|
||||
const IWT2_MENU_COLUMNS = 4
|
||||
const IWT2_MENU_COLUMNS = 2
|
||||
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
|
||||
|
||||
type Iwt2RoguelikeRunState = {
|
||||
bossIds: Iwt2BossId[]
|
||||
@@ -72,16 +94,10 @@ const MENU_ITEMS: Array<{
|
||||
description: string
|
||||
glyph: string
|
||||
}> = [
|
||||
{
|
||||
screen: 'cloud-save',
|
||||
title: 'Backup Slot',
|
||||
description: 'Save or restore the isolated IWT2 progress slot.',
|
||||
glyph: 'C',
|
||||
},
|
||||
{
|
||||
screen: 'dungeons',
|
||||
title: 'Dungeons',
|
||||
description: 'Queue into Bulldrome, Yian Kut Ku, Great Jaggi, and Khezu boss arenas.',
|
||||
description: 'Queue into seven modular IWT2 boss arenas.',
|
||||
glyph: 'D',
|
||||
},
|
||||
{
|
||||
@@ -102,6 +118,12 @@ const MENU_ITEMS: Array<{
|
||||
description: 'Race another healer through roguelike encounters with buffs and sabotage.',
|
||||
glyph: 'P',
|
||||
},
|
||||
{
|
||||
screen: 'gear-upgrade',
|
||||
title: 'Gear Upgrade',
|
||||
description: 'Spend boss coins on class gear slots and infusion abilities.',
|
||||
glyph: 'G',
|
||||
},
|
||||
{
|
||||
screen: 'hunter-profile',
|
||||
title: 'Hunter Profile',
|
||||
@@ -114,6 +136,12 @@ const MENU_ITEMS: Array<{
|
||||
description: 'Choose healer kit, armor palette, and IWT2 hunter callsign.',
|
||||
glyph: 'K',
|
||||
},
|
||||
{
|
||||
screen: 'cloud-save',
|
||||
title: 'Backup Slot',
|
||||
description: 'Choose local, online, or fresh IWT2 progress.',
|
||||
glyph: 'C',
|
||||
},
|
||||
{
|
||||
screen: 'settings',
|
||||
title: 'Settings',
|
||||
@@ -122,17 +150,27 @@ const MENU_ITEMS: Array<{
|
||||
},
|
||||
]
|
||||
|
||||
export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: () => void }) {
|
||||
export function IWantToHeal2App({
|
||||
onlineBackupsAvailable,
|
||||
onBackToGameSelect,
|
||||
}: {
|
||||
onlineBackupsAvailable: boolean
|
||||
onBackToGameSelect: () => void
|
||||
}) {
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const [screen, setScreen] = useState<Iwt2Screen>('menu')
|
||||
const [save, setSave] = useState<Iwt2Save>(loadIwt2Save)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>('bulldrome')
|
||||
const [arenaModeLabel, setArenaModeLabel] = useState('Dungeon')
|
||||
const [arenaDifficulty, setArenaDifficulty] = useState<Iwt2Difficulty>(IWT2_DUNGEON_DIFFICULTIES[0])
|
||||
const [selectedDungeonDifficultySlug, setSelectedDungeonDifficultySlug] = useState(IWT2_DUNGEON_DIFFICULTIES[0].slug)
|
||||
const [selectedRaidDifficultySlug, setSelectedRaidDifficultySlug] = useState(IWT2_RAID_DIFFICULTIES[0].slug)
|
||||
const [roguelikeVariant, setRoguelikeVariant] = useState<Iwt2RoguelikeVariant>('pve')
|
||||
const [roguelikeContentType, setRoguelikeContentType] = useState<Iwt2RoguelikeContentType>('dungeon')
|
||||
const [roguelikeRun, setRoguelikeRun] = useState<Iwt2RoguelikeRunState | null>(null)
|
||||
const [pvpQueueMessage, setPvpQueueMessage] = useState('')
|
||||
const [pvpQueueing, setPvpQueueing] = useState(false)
|
||||
const cancelPvpQueueRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -143,9 +181,16 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
cancelPvpQueueRef.current?.()
|
||||
}, [])
|
||||
|
||||
function cancelIwt2PvpQueue() {
|
||||
cancelPvpQueueRef.current?.()
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueing(false)
|
||||
setPvpQueueMessage('')
|
||||
}
|
||||
|
||||
const setupDualScreenState = useMemo<DualScreenSetupState | null>(
|
||||
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save),
|
||||
[save, screen, selectedBossId],
|
||||
() => buildIwt2SetupDualScreenState(screen, selectedBossId, save, currentSetupDifficulty(screen, selectedDungeonDifficultySlug, selectedRaidDifficultySlug)),
|
||||
[save, screen, selectedBossId, selectedDungeonDifficultySlug, selectedRaidDifficultySlug],
|
||||
)
|
||||
const workshopDualScreenState = useMemo<DualScreenWorkshopState | null>(
|
||||
() => buildIwt2WorkshopDualScreenState(screen, save),
|
||||
@@ -191,7 +236,8 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
return (
|
||||
<BossArenaScreen
|
||||
bossId={selectedBossId}
|
||||
key={`arena-${selectedBossId}-${arenaModeLabel}`}
|
||||
difficulty={arenaDifficulty}
|
||||
key={`arena-${selectedBossId}-${arenaModeLabel}-${arenaDifficulty.slug}`}
|
||||
modeLabel={arenaModeLabel}
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
@@ -224,6 +270,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
}}
|
||||
save={save}
|
||||
onBack={() => setScreen('roguelike')}
|
||||
onMainMenu={() => setScreen('menu')}
|
||||
onPvpRequeue={() => {
|
||||
setScreen('roguelike')
|
||||
startIwt2RoguelikeRun()
|
||||
}}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
)
|
||||
@@ -258,12 +309,17 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2DungeonsScreen
|
||||
difficultySlug={selectedDungeonDifficultySlug}
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onOpenBoss={(bossId) => {
|
||||
setArenaModeLabel('Dungeon')
|
||||
onDifficultyChange={setSelectedDungeonDifficultySlug}
|
||||
onOpenBoss={(bossId, difficulty) => {
|
||||
setArenaModeLabel(`${difficulty.name} Dungeon`)
|
||||
setArenaDifficulty(difficulty)
|
||||
setSelectedBossId(bossId)
|
||||
setScreen('arena')
|
||||
}}
|
||||
onPreviewBoss={setSelectedBossId}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
@@ -276,20 +332,23 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
<Iwt2RoguelikeScreen
|
||||
contentType={roguelikeContentType}
|
||||
variant={roguelikeVariant}
|
||||
onBack={() => setScreen('menu')}
|
||||
onBack={() => {
|
||||
cancelIwt2PvpQueue()
|
||||
setScreen('menu')
|
||||
}}
|
||||
onCancelQueue={cancelIwt2PvpQueue}
|
||||
onContentTypeChange={setRoguelikeContentType}
|
||||
onStart={() => {
|
||||
startIwt2RoguelikeRun()
|
||||
}}
|
||||
onVariantChange={(nextVariant) => {
|
||||
cancelPvpQueueRef.current?.()
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage('')
|
||||
cancelIwt2PvpQueue()
|
||||
setRoguelikeVariant(nextVariant)
|
||||
if (nextVariant === 'pve' && roguelikeContentType === 'stadium') {
|
||||
setRoguelikeContentType('dungeon')
|
||||
}
|
||||
}}
|
||||
queueing={pvpQueueing}
|
||||
queueMessage={pvpQueueMessage}
|
||||
/>
|
||||
</main>
|
||||
@@ -301,13 +360,18 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2ModeScreen
|
||||
difficultySlug={selectedRaidDifficultySlug}
|
||||
mode="Raids"
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onOpenBoss={(bossId) => {
|
||||
setArenaModeLabel('Raid')
|
||||
onDifficultyChange={setSelectedRaidDifficultySlug}
|
||||
onOpenBoss={(bossId, difficulty) => {
|
||||
setArenaModeLabel(`${difficulty.name} Raid`)
|
||||
setArenaDifficulty(difficulty)
|
||||
setSelectedBossId(bossId)
|
||||
setScreen('arena')
|
||||
}}
|
||||
onPreviewBoss={setSelectedBossId}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
@@ -316,12 +380,29 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
if (screen === 'hunter-profile') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2HunterProfileScreen save={save} onBack={() => setScreen('menu')} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'gear-upgrade') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
<Iwt2Header
|
||||
save={save}
|
||||
title="Gear Upgrade"
|
||||
onBack={() => setScreen('menu')}
|
||||
onBackToGameSelect={onBackToGameSelect}
|
||||
/>
|
||||
<Iwt2GearUpgradeScreen
|
||||
save={save}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'customize-character') {
|
||||
return (
|
||||
<main className="game-shell iwt2-shell">
|
||||
@@ -341,6 +422,7 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
<Iwt2Header save={save} onBackToGameSelect={onBackToGameSelect} />
|
||||
<Iwt2CloudSaveScreen
|
||||
save={save}
|
||||
onlineBackupsAvailable={onlineBackupsAvailable}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaveUpdated={setSave}
|
||||
/>
|
||||
@@ -363,14 +445,11 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
|
||||
{screen === 'menu' && (
|
||||
<section className="iwt2-menu-screen" data-game-nav-active="true">
|
||||
<div className="iwt2-menu-heading">
|
||||
<p className="eyebrow">I Want To Heal 2</p>
|
||||
<h1>Mode Select</h1>
|
||||
</div>
|
||||
<div className="iwt2-menu-grid">
|
||||
{MENU_ITEMS.map((item, index) => (
|
||||
<button
|
||||
className={`iwt2-menu-card ${selectedIndex === index ? 'game-selected' : ''}`}
|
||||
aria-label={`${item.title}. ${item.description}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selectedIndex === index ? 'true' : undefined}
|
||||
key={`${item.screen}-${item.title}`}
|
||||
@@ -392,28 +471,33 @@ export function IWantToHeal2App({ onBackToGameSelect }: { onBackToGameSelect: ()
|
||||
)
|
||||
|
||||
function startIwt2RoguelikeRun() {
|
||||
cancelPvpQueueRef.current?.()
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueMessage('')
|
||||
cancelIwt2PvpQueue()
|
||||
if (roguelikeVariant !== 'pvp') {
|
||||
beginIwt2RoguelikeArena(roguelikeVariant, roguelikeContentType)
|
||||
return
|
||||
}
|
||||
const startStage = 1
|
||||
setPvpQueueing(true)
|
||||
setPvpQueueMessage('Queuing for PvP...')
|
||||
cancelPvpQueueRef.current = startPvpQueueWithCpuFallback<unknown>({
|
||||
contentType: roguelikeContentType,
|
||||
startStage,
|
||||
gameMode: getGameMode(),
|
||||
liveMatchActive: () => false,
|
||||
onSearching: setPvpQueueMessage,
|
||||
onSearching: (message) => {
|
||||
setPvpQueueing(true)
|
||||
setPvpQueueMessage(message)
|
||||
},
|
||||
onCpuMatch: (_difficulty, message) => {
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueing(false)
|
||||
setPvpQueueMessage(message)
|
||||
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||
},
|
||||
onLiveMatch: (...liveMatchArgs) => {
|
||||
const message = liveMatchArgs[2]
|
||||
cancelPvpQueueRef.current = null
|
||||
setPvpQueueing(false)
|
||||
setPvpQueueMessage(message)
|
||||
beginIwt2RoguelikeArena('pvp', roguelikeContentType)
|
||||
},
|
||||
@@ -450,7 +534,7 @@ function createRoguelikeRun(
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
): Iwt2RoguelikeRunState {
|
||||
return {
|
||||
bossIds: createRandomRoguelikeBossPair(),
|
||||
bossIds: createRoguelikeBossPair(variant, contentType, 1),
|
||||
buffs: [],
|
||||
contentType,
|
||||
debuffs: [],
|
||||
@@ -465,7 +549,14 @@ function buildRoguelikeChoices(save: Iwt2Save, variant: Iwt2RoguelikeVariant) {
|
||||
const selfCatalog = [IWT2_REVIVE_PARTY_CHOICE, ...buildIwt2SelfBuffChoices(abilities)]
|
||||
const debuffCatalog = buildIwt2OpponentDebuffChoices(abilities)
|
||||
return {
|
||||
selfChoices: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
|
||||
selfChoices: variant === 'pvp'
|
||||
? chooseRunChoicesWithPreferredFirst(
|
||||
selfCatalog,
|
||||
IWT2_ROGUELIKE_CHOICE_COUNT,
|
||||
isExtraTargetBuff,
|
||||
IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE,
|
||||
)
|
||||
: chooseRunChoices(selfCatalog, IWT2_ROGUELIKE_CHOICE_COUNT),
|
||||
debuffChoices: variant === 'pvp'
|
||||
? chooseRunChoices(debuffCatalog, IWT2_ROGUELIKE_CHOICE_COUNT)
|
||||
: [],
|
||||
@@ -511,15 +602,43 @@ function applyRoguelikeChoice(
|
||||
return {
|
||||
...run,
|
||||
...nextBase,
|
||||
bossIds: createRandomRoguelikeBossPair(),
|
||||
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1),
|
||||
...buildRoguelikeChoices(save, run.variant),
|
||||
stage: run.stage + 1,
|
||||
}
|
||||
}
|
||||
|
||||
function createRandomRoguelikeBossPair(): Iwt2BossId[] {
|
||||
const pool = Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]
|
||||
return chooseRunChoices(pool, 2)
|
||||
function createRoguelikeBossPair(
|
||||
variant: Iwt2RoguelikeVariant,
|
||||
contentType: Iwt2RoguelikeContentType,
|
||||
stage: 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)
|
||||
|
||||
if (weightedProgressionEnabled) {
|
||||
return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool })
|
||||
}
|
||||
return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool })
|
||||
}
|
||||
|
||||
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[] {
|
||||
@@ -533,23 +652,63 @@ function chooseRunChoices<T>(items: readonly T[], count: number): T[] {
|
||||
return choices
|
||||
}
|
||||
|
||||
function chooseRunChoicesWithPreferredFirst<T>(
|
||||
items: readonly T[],
|
||||
count: number,
|
||||
preferred: (item: T) => boolean,
|
||||
preferredChance: number,
|
||||
): T[] {
|
||||
if (count <= 0) return []
|
||||
const pool = [...items]
|
||||
const choices: T[] = []
|
||||
const preferredPool = pool.filter(preferred)
|
||||
if (preferredPool.length > 0 && Math.random() < preferredChance) {
|
||||
const preferredChoice = preferredPool[Math.floor(Math.random() * preferredPool.length)]
|
||||
const preferredIndex = pool.indexOf(preferredChoice)
|
||||
if (preferredIndex >= 0) {
|
||||
const [choice] = pool.splice(preferredIndex, 1)
|
||||
if (choice) choices.push(choice)
|
||||
}
|
||||
}
|
||||
while (pool.length > 0 && choices.length < count) {
|
||||
const index = Math.floor(Math.random() * pool.length)
|
||||
const [choice] = pool.splice(index, 1)
|
||||
if (choice) choices.push(choice)
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
function isExtraTargetBuff(choice: Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>) {
|
||||
return choice.id.endsWith('-extra-target')
|
||||
}
|
||||
|
||||
function Iwt2Header({
|
||||
onBack,
|
||||
onBackToGameSelect,
|
||||
save,
|
||||
title,
|
||||
}: {
|
||||
onBack?: () => void
|
||||
onBackToGameSelect: () => void
|
||||
save: Iwt2Save
|
||||
title?: string
|
||||
}) {
|
||||
return (
|
||||
<header className="topbar app-header">
|
||||
<button className="brand-button" onClick={onBackToGameSelect} type="button">
|
||||
<strong>Games</strong>
|
||||
</button>
|
||||
{title && <strong className="iwt2-header-title">{title}</strong>}
|
||||
<div className="character-summary">
|
||||
<strong>{save.character.name}</strong>
|
||||
<small>IWT2 Level {save.character.level}</small>
|
||||
<small>{save.character.experience} XP</small>
|
||||
</div>
|
||||
{onBack && (
|
||||
<button className="back-button iwt2-header-back" data-controller-nav="skip" onClick={onBack} type="button">
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -558,63 +717,48 @@ function buildIwt2SetupDualScreenState(
|
||||
screen: Iwt2Screen,
|
||||
selectedBossId: Iwt2BossId,
|
||||
save: Iwt2Save,
|
||||
difficulty: Iwt2Difficulty,
|
||||
): DualScreenSetupState | null {
|
||||
if (screen !== 'dungeons' && screen !== 'raids') return null
|
||||
const boss = IWT2_BOSS_METADATA[selectedBossId]
|
||||
const raid = screen === 'raids'
|
||||
const coinReward = iwt2BossCoinRewardFor(selectedBossId, difficulty.slug)
|
||||
return {
|
||||
contentType: raid ? 'raid' : 'dungeon',
|
||||
description: raid
|
||||
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics.`
|
||||
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery.`,
|
||||
difficultyName: `IWT2 Level ${save.character.level}`,
|
||||
experience: 125,
|
||||
? `${boss.name} raid assignment. Tank holds aggro while party moves around modular boss mechanics. Reward: ${coinReward.name}.`
|
||||
: `${boss.name} arena. Heal the party through melee pressure, telegraphs, hazards, and stun recovery. Reward: ${coinReward.name}.`,
|
||||
difficultyName: difficulty.name,
|
||||
experience: Math.round(125 * difficulty.experienceMultiplier),
|
||||
initials: boss.icon,
|
||||
itemLevel: save.character.level,
|
||||
itemLevel: difficulty.droppedItemLevel,
|
||||
lockedReason: undefined,
|
||||
stats: {
|
||||
damage: `${boss.meleeDamage}`,
|
||||
health: `${boss.maxHealth}`,
|
||||
loot: 'IWT2',
|
||||
xp: '125',
|
||||
damage: `${difficulty.damageMultiplier.toFixed(2)}x`,
|
||||
health: `${difficulty.healthMultiplier.toFixed(2)}x`,
|
||||
loot: coinReward.name,
|
||||
xp: `${difficulty.experienceMultiplier.toFixed(1)}x`,
|
||||
},
|
||||
subtitle: `${raid ? 'Raid' : 'Dungeon'} | 6 Players | ${IWT2_HEALER_METADATA[save.character.healerStyle].name}`,
|
||||
title: raid ? `${boss.name} Raid` : `${boss.name} Arena`,
|
||||
}
|
||||
}
|
||||
|
||||
function currentSetupDifficulty(
|
||||
screen: Iwt2Screen,
|
||||
selectedDungeonDifficultySlug: string,
|
||||
selectedRaidDifficultySlug: string,
|
||||
): Iwt2Difficulty {
|
||||
if (screen === 'raids') {
|
||||
return findIwt2Difficulty(IWT2_RAID_DIFFICULTIES, selectedRaidDifficultySlug)
|
||||
}
|
||||
return findIwt2Difficulty(IWT2_DUNGEON_DIFFICULTIES, selectedDungeonDifficultySlug)
|
||||
}
|
||||
|
||||
function buildIwt2WorkshopDualScreenState(
|
||||
screen: Iwt2Screen,
|
||||
save: Iwt2Save,
|
||||
): DualScreenWorkshopState | null {
|
||||
if (screen === 'hunter-profile') {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
glyph: 'H',
|
||||
meta: `${save.character.experience} XP`,
|
||||
status: `Level ${save.character.level}`,
|
||||
title: save.character.name,
|
||||
},
|
||||
{
|
||||
glyph: IWT2_HEALER_METADATA[save.character.healerStyle].icon,
|
||||
meta: IWT2_HEALER_METADATA[save.character.healerStyle].description,
|
||||
status: 'Class',
|
||||
title: IWT2_HEALER_METADATA[save.character.healerStyle].name,
|
||||
},
|
||||
...(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
|
||||
glyph: IWT2_BOSS_METADATA[bossId].icon,
|
||||
meta: `${save.collectionLog.bossKills[bossId] ?? 0} kills`,
|
||||
status: `${save.collectionLog.dropsFound[bossDropIdForAppSummary(bossId)] ?? 0} drops`,
|
||||
title: IWT2_BOSS_METADATA[bossId].name,
|
||||
})),
|
||||
],
|
||||
mode: 'collection',
|
||||
subtitle: 'IWT2 inventory and collection log',
|
||||
summary: `${save.inventory.length} inventory slots`,
|
||||
title: 'Hunter Profile',
|
||||
}
|
||||
}
|
||||
|
||||
if (screen === 'customize-character') {
|
||||
const healer = IWT2_HEALER_METADATA[save.character.healerStyle]
|
||||
return {
|
||||
@@ -633,10 +777,3 @@ function buildIwt2WorkshopDualScreenState(
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function bossDropIdForAppSummary(bossId: Iwt2BossId): string {
|
||||
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
|
||||
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
|
||||
if (bossId === 'khezu') return 'khezu-pearl'
|
||||
return 'raw-bulldrome-coin'
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export function Iwt2BottomDisplay() {
|
||||
binding={activeBindings[action]}
|
||||
iconStyle="playstation"
|
||||
/>{' '}
|
||||
<img alt="" aria-hidden="true" className="iwt2-bottom-target-icon" draggable={false} src={target.uiIconUrl} />
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export function ArenaBar({
|
||||
className = '',
|
||||
current,
|
||||
label,
|
||||
max,
|
||||
shield = 0,
|
||||
}: {
|
||||
className?: string
|
||||
current: number
|
||||
label?: string
|
||||
max: number
|
||||
shield?: number
|
||||
}) {
|
||||
@@ -15,6 +17,7 @@ export function ArenaBar({
|
||||
<div className={`iwt2-bar ${className}`}>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
{shieldPercent > 0 && <i style={{ width: `${shieldPercent}%` }} />}
|
||||
{label && <em>{label}</em>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export function BossHud({ boss, bosses }: { boss?: Iwt2BossEntityState, bosses?:
|
||||
<div>
|
||||
<strong>{metadata.name}</strong>
|
||||
<small>{Math.ceil(entry.health)} / {entry.maxHealth} HP</small>
|
||||
{entry.maxArmor > 0 && <small>{Math.ceil(entry.armor)} / {entry.maxArmor} Armor</small>}
|
||||
{entry.mechanicEnergyMax > 0 && <small>{Math.ceil(entry.mechanicEnergy)} / {entry.mechanicEnergyMax} Static</small>}
|
||||
</div>
|
||||
<small className="iwt2-boss-phase">{entry.attackPhase}</small>
|
||||
<ArenaBar className="boss" current={entry.health} max={entry.maxHealth} />
|
||||
|
||||
@@ -40,13 +40,14 @@ export function PartyFrames({
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span style={{ background: meta.color }}>{meta.icon}</span>
|
||||
<span className="iwt2-party-portrait" style={{ backgroundColor: meta.color }}>
|
||||
<img alt="" aria-hidden="true" draggable={false} src={meta.uiIconUrl} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="iwt2-party-row-title">
|
||||
<strong>{meta.name}</strong>
|
||||
<small>{Math.ceil(member.health)}</small>
|
||||
</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 && (
|
||||
<ArenaBar className="mana" current={member.mana} max={member.maxMana} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Iwt2BossId } from './bosses'
|
||||
|
||||
export const IWT2_BOSS_PET_DROP_RATE = 1 / 500
|
||||
|
||||
export type Iwt2BossCoinReward = {
|
||||
id: string
|
||||
name: string
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||
itemLevel: number
|
||||
glyph: string
|
||||
}
|
||||
|
||||
export type Iwt2BossPetReward = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type Iwt2CoinTier = {
|
||||
idPrefix: string
|
||||
namePrefix: string
|
||||
rarity: Iwt2BossCoinReward['rarity']
|
||||
itemLevel: number
|
||||
glyph: string
|
||||
}
|
||||
|
||||
const IWT2_COIN_TIERS: Record<string, Iwt2CoinTier> = {
|
||||
initiate: { idPrefix: 'raw', namePrefix: 'Raw', rarity: 'common', itemLevel: 1, glyph: 'R' },
|
||||
veteran: { idPrefix: 'green', namePrefix: 'Green', rarity: 'uncommon', itemLevel: 10, glyph: 'G' },
|
||||
champion: { idPrefix: 'blue', namePrefix: 'Blue', rarity: 'rare', itemLevel: 15, glyph: 'B' },
|
||||
mythic: { idPrefix: 'purple', namePrefix: 'Purple', rarity: 'epic', itemLevel: 20, glyph: 'P' },
|
||||
ascendant: { idPrefix: 'orange', namePrefix: 'Orange', rarity: 'legendary', itemLevel: 25, glyph: 'O' },
|
||||
'raid-normal': { idPrefix: 'green', namePrefix: 'Green', rarity: 'uncommon', itemLevel: 10, glyph: 'G' },
|
||||
'raid-champion': { idPrefix: 'blue', namePrefix: 'Blue', rarity: 'rare', itemLevel: 15, glyph: 'B' },
|
||||
'raid-mythic': { idPrefix: 'purple', namePrefix: 'Purple', rarity: 'epic', itemLevel: 20, glyph: 'P' },
|
||||
'raid-ascendant': { idPrefix: 'orange', namePrefix: 'Orange', rarity: 'legendary', itemLevel: 25, glyph: 'O' },
|
||||
}
|
||||
|
||||
const IWT2_BOSS_REWARD_DISPLAY_NAMES: Record<Iwt2BossId, string> = {
|
||||
'barroth': 'Mirehorn',
|
||||
'bulldrome': 'Bristlemaw',
|
||||
'cinderback-ricochet': 'Cinderback Ricochet',
|
||||
'crystal-bat-matriarch': 'Crystal Bat Matriarch',
|
||||
'ember-mantis-duelist': 'Ember Mantis Duelist',
|
||||
'great-jaggi': 'Packfang Alpha',
|
||||
'hollowcrown-revenant': 'Hollowcrown Revenant',
|
||||
'khezu': 'Palevolt Maw',
|
||||
'obsidian-ram-golem': 'Obsidian Ram Golem',
|
||||
'rathian': 'Verdant Wyrm',
|
||||
'rimebastion': 'Rimebastion',
|
||||
'sandglass-scorpion': 'Sandglass Scorpion',
|
||||
'stormcoil-wyrm': 'Stormcoil Wyrm',
|
||||
'tobi-kadachi': 'Stormtail',
|
||||
'venom-orchid-hydra': 'Venom Orchid Hydra',
|
||||
'yian-kut-ku': 'Emberquill',
|
||||
}
|
||||
|
||||
export const IWT2_BOSS_COIN_DIFFICULTY_SLUGS = [
|
||||
'initiate',
|
||||
'veteran',
|
||||
'champion',
|
||||
'mythic',
|
||||
'ascendant',
|
||||
] as const
|
||||
|
||||
export const IWT2_LEGACY_BOSS_MATERIAL_REWARDS: Record<Iwt2BossId, { id: string, name: string }> = {
|
||||
'barroth': { id: 'barroth-shell', name: 'Mirehorn Shell' },
|
||||
'bulldrome': { id: 'raw-bulldrome-coin', name: 'Raw Bristlemaw Coin' },
|
||||
'cinderback-ricochet': { id: 'cinderback-plate', name: 'Cinderback Plate' },
|
||||
'crystal-bat-matriarch': { id: 'crystal-bat-fang', name: 'Crystal Bat Fang' },
|
||||
'ember-mantis-duelist': { id: 'ember-mantis-blade', name: 'Ember Mantis Blade' },
|
||||
'great-jaggi': { id: 'great-jaggi-hide', name: 'Packfang Alpha Hide' },
|
||||
'hollowcrown-revenant': { id: 'hollowcrown-antler', name: 'Hollowcrown Antler' },
|
||||
'khezu': { id: 'khezu-pearl', name: 'Palevolt Maw Pearl' },
|
||||
'obsidian-ram-golem': { id: 'obsidian-ram-core', name: 'Obsidian Ram Core' },
|
||||
'rathian': { id: 'rathian-scale', name: 'Verdant Wyrm Scale' },
|
||||
'rimebastion': { id: 'rimebastion-core', name: 'Rimebastion Core' },
|
||||
'sandglass-scorpion': { id: 'sandglass-stinger', name: 'Sandglass Stinger' },
|
||||
'stormcoil-wyrm': { id: 'stormcoil-conductor', name: 'Stormcoil Conductor' },
|
||||
'tobi-kadachi': { id: 'tobi-kadachi-pelt', name: 'Stormtail Pelt' },
|
||||
'venom-orchid-hydra': { id: 'venom-orchid-pod', name: 'Venom Orchid Pod' },
|
||||
'yian-kut-ku': { id: 'yian-kut-ku-scale', name: 'Emberquill Scale' },
|
||||
}
|
||||
|
||||
const IWT2_BOSS_PET_REWARDS: Record<Iwt2BossId, Iwt2BossPetReward> = {
|
||||
'barroth': { id: 'barroth-pet', name: 'Mirehorn Pet' },
|
||||
'bulldrome': { id: 'bulldrome-pet', name: 'Bristlemaw Pet' },
|
||||
'cinderback-ricochet': { id: 'cinderback-ricochet-pet', name: 'Cinderback Ricochet Pet' },
|
||||
'crystal-bat-matriarch': { id: 'crystal-bat-matriarch-pet', name: 'Crystal Bat Matriarch Pet' },
|
||||
'ember-mantis-duelist': { id: 'ember-mantis-duelist-pet', name: 'Ember Mantis Duelist Pet' },
|
||||
'great-jaggi': { id: 'great-jaggi-pet', name: 'Packfang Alpha Pet' },
|
||||
'hollowcrown-revenant': { id: 'hollowcrown-revenant-pet', name: 'Hollowcrown Revenant Pet' },
|
||||
'khezu': { id: 'khezu-pet', name: 'Palevolt Maw Pet' },
|
||||
'obsidian-ram-golem': { id: 'obsidian-ram-golem-pet', name: 'Obsidian Ram Golem Pet' },
|
||||
'rathian': { id: 'rathian-pet', name: 'Verdant Wyrm Pet' },
|
||||
'rimebastion': { id: 'rimebastion-pet', name: 'Rimebastion Pet' },
|
||||
'sandglass-scorpion': { id: 'sandglass-scorpion-pet', name: 'Sandglass Scorpion Pet' },
|
||||
'stormcoil-wyrm': { id: 'stormcoil-wyrm-pet', name: 'Stormcoil Wyrm Pet' },
|
||||
'tobi-kadachi': { id: 'tobi-kadachi-pet', name: 'Stormtail Pet' },
|
||||
'venom-orchid-hydra': { id: 'venom-orchid-hydra-pet', name: 'Venom Orchid Hydra Pet' },
|
||||
'yian-kut-ku': { id: 'yian-kut-ku-pet', name: 'Emberquill Pet' },
|
||||
}
|
||||
|
||||
export function iwt2BossCoinRewardFor(
|
||||
bossId: Iwt2BossId,
|
||||
difficultySlug = 'initiate',
|
||||
): Iwt2BossCoinReward {
|
||||
const tier = IWT2_COIN_TIERS[difficultySlug] ?? IWT2_COIN_TIERS.initiate
|
||||
return {
|
||||
id: `${tier.idPrefix}-${bossId}-coin`,
|
||||
name: `${tier.namePrefix} ${formatBossNameForCoin(bossId)} Coin`,
|
||||
rarity: tier.rarity,
|
||||
itemLevel: tier.itemLevel,
|
||||
glyph: tier.glyph,
|
||||
}
|
||||
}
|
||||
|
||||
export function iwt2BossCoinRewardsFor(bossId: Iwt2BossId): Iwt2BossCoinReward[] {
|
||||
return IWT2_BOSS_COIN_DIFFICULTY_SLUGS.map((difficultySlug) => (
|
||||
iwt2BossCoinRewardFor(bossId, difficultySlug)
|
||||
))
|
||||
}
|
||||
|
||||
export function iwt2BossPetRewardFor(bossId: Iwt2BossId): Iwt2BossPetReward {
|
||||
return IWT2_BOSS_PET_REWARDS[bossId]
|
||||
}
|
||||
|
||||
function formatBossNameForCoin(bossId: Iwt2BossId): string {
|
||||
return IWT2_BOSS_REWARD_DISPLAY_NAMES[bossId]
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
import { IWT2_BALANCE_OVERRIDES } from './balanceOverrides'
|
||||
|
||||
export type Iwt2BossId = 'bulldrome' | 'yian-kut-ku' | 'great-jaggi' | 'khezu'
|
||||
export type Iwt2BossId =
|
||||
| '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'
|
||||
|
||||
export type Iwt2BalanceOverrides = {
|
||||
bosses?: Partial<Record<Iwt2BossId, Partial<Pick<Iwt2BossMetadata, 'maxHealth' | 'birdHealth'>>>>
|
||||
@@ -10,6 +26,11 @@ export type Iwt2BossMetadata = {
|
||||
id: Iwt2BossId
|
||||
name: string
|
||||
icon: string
|
||||
spriteUrl: string
|
||||
spriteView?: 'topDown' | 'side'
|
||||
spriteWidthScale?: number
|
||||
spriteHeightScale?: number
|
||||
spriteYOffsetScale?: number
|
||||
color: string
|
||||
accentColor: string
|
||||
maxHealth: number
|
||||
@@ -64,45 +85,120 @@ export type Iwt2BossMetadata = {
|
||||
lightningStrikeDamage?: number
|
||||
lightningStrikeStunSeconds?: number
|
||||
lightningStrikeCount?: number
|
||||
poisonTailCooldown?: number
|
||||
tailSweepWindup?: number
|
||||
tailSweepRecover?: number
|
||||
tailSweepInnerRadius?: number
|
||||
tailSweepOuterRadius?: number
|
||||
tailSweepAngleRadians?: number
|
||||
tailSweepDamage?: number
|
||||
tailSweepStunSeconds?: number
|
||||
poisonSpitCooldown?: number
|
||||
poisonSpitWindup?: number
|
||||
poisonPuddleRadius?: number
|
||||
poisonPuddleDamage?: number
|
||||
poisonPuddleSeconds?: number
|
||||
mudArmor?: number
|
||||
mudSprayCooldown?: number
|
||||
mudSprayWindup?: number
|
||||
mudSprayRange?: number
|
||||
mudSprayAngleRadians?: number
|
||||
mudSprayDamage?: number
|
||||
mudSprayStunSeconds?: number
|
||||
mudPuddleRadius?: number
|
||||
mudPuddleSeconds?: number
|
||||
staticEnergyMax?: number
|
||||
staticEnergyPerSecond?: number
|
||||
staticEnergyPerMelee?: number
|
||||
staticPounceWindup?: number
|
||||
staticPounceSpeed?: number
|
||||
staticPounceDamage?: number
|
||||
staticPounceStunSeconds?: number
|
||||
staticPounceLength?: number
|
||||
staticPounceWidth?: number
|
||||
chainShockCooldown?: number
|
||||
chainShockWindup?: number
|
||||
chainShockRadius?: number
|
||||
chainShockDamage?: number
|
||||
chainShockStunSeconds?: number
|
||||
iceWallCooldown?: number
|
||||
iceWallWindup?: number
|
||||
iceWallActiveSeconds?: number
|
||||
iceWallCount?: number
|
||||
iceWallLength?: number
|
||||
iceWallWidth?: number
|
||||
iceWallDamage?: number
|
||||
iceShardDamage?: number
|
||||
iceShardStunSeconds?: number
|
||||
slashCooldown?: number
|
||||
slashWindup?: number
|
||||
slashDamage?: number
|
||||
slashStunSeconds?: number
|
||||
slashWidth?: number
|
||||
crossSlashAngleRadians?: number
|
||||
sidestepDistance?: number
|
||||
ricochetCooldown?: number
|
||||
ricochetWindup?: number
|
||||
ricochetSpeed?: number
|
||||
ricochetDamage?: number
|
||||
ricochetStunSeconds?: number
|
||||
ricochetSeconds?: number
|
||||
lavaTrailRadius?: number
|
||||
lavaTrailDamage?: number
|
||||
lavaTrailSeconds?: number
|
||||
armorSlamWindup?: number
|
||||
armorSlamRadius?: number
|
||||
armorSlamDamage?: number
|
||||
armorSlamStunSeconds?: number
|
||||
}
|
||||
|
||||
const DEFAULT_BULLDROME_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'bulldrome',
|
||||
name: 'Bulldrome',
|
||||
name: 'Bristlemaw',
|
||||
icon: 'B',
|
||||
spriteUrl: '/iwt2/bosses/bulldrome-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 6.2,
|
||||
spriteHeightScale: 4.5,
|
||||
spriteYOffsetScale: -0.14,
|
||||
color: '#8f5a3c',
|
||||
accentColor: '#e6b17f',
|
||||
maxHealth: 950,
|
||||
radius: 31,
|
||||
moveSpeed: 128,
|
||||
meleeRange: 58,
|
||||
meleeDamage: 9,
|
||||
meleeDamage: 20,
|
||||
meleeCooldown: 1.05,
|
||||
chargeCooldown: 4.75,
|
||||
chargeWindup: 0.55,
|
||||
chargeSpeed: 510,
|
||||
chargeDamage: 32,
|
||||
chargeDamage: 60,
|
||||
chargeStunSeconds: 0.75,
|
||||
chargeLength: 560,
|
||||
chargeOvershoot: 120,
|
||||
chargeWidth: 62,
|
||||
slamWindup: 0.45,
|
||||
slamRadius: 104,
|
||||
slamDamage: 24,
|
||||
slamDamage: 55,
|
||||
slamStunSeconds: 0.75,
|
||||
}
|
||||
|
||||
const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'yian-kut-ku',
|
||||
name: 'Yian Kut Ku',
|
||||
icon: 'Y',
|
||||
name: 'Emberquill',
|
||||
icon: 'E',
|
||||
spriteUrl: '/iwt2/bosses/yian-kut-ku-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 5.9,
|
||||
spriteHeightScale: 4.7,
|
||||
spriteYOffsetScale: -0.1,
|
||||
color: '#d66a35',
|
||||
accentColor: '#ffd166',
|
||||
maxHealth: 400,
|
||||
maxHealth: 750,
|
||||
radius: 29,
|
||||
moveSpeed: 118,
|
||||
meleeRange: 56,
|
||||
meleeDamage: 8,
|
||||
meleeDamage: 12,
|
||||
meleeCooldown: 1,
|
||||
chargeCooldown: 0,
|
||||
chargeWindup: 0,
|
||||
@@ -119,32 +215,37 @@ const DEFAULT_YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
fireballCooldown: 3.6,
|
||||
fireballWindup: 0.55,
|
||||
fireballSpeed: 285,
|
||||
fireballDamage: 12,
|
||||
fireballDamage: 32,
|
||||
fireballRadius: 9,
|
||||
firePuddleRadius: 42,
|
||||
firePuddleDamage: 7,
|
||||
firePuddleDamage: 15,
|
||||
firePuddleSeconds: 8,
|
||||
birdWaveThresholds: [0.9, 0.4],
|
||||
birdWaveThresholds: [0.5],
|
||||
birdFlightCooldown: 10,
|
||||
birdFlightWindup: 0.85,
|
||||
birdFlightSpeed: 360,
|
||||
birdHealth: 58,
|
||||
birdHealth: 55,
|
||||
birdRadius: 16,
|
||||
birdContactDamage: 10,
|
||||
birdContactDamage: 26,
|
||||
birdStunSeconds: 0.75,
|
||||
}
|
||||
|
||||
const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'great-jaggi',
|
||||
name: 'Great Jaggi',
|
||||
icon: 'J',
|
||||
name: 'Packfang Alpha',
|
||||
icon: 'P',
|
||||
spriteUrl: '/iwt2/bosses/great-jaggi-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 5.9,
|
||||
spriteHeightScale: 4.1,
|
||||
spriteYOffsetScale: -0.09,
|
||||
color: '#3f8f73',
|
||||
accentColor: '#b8f0aa',
|
||||
maxHealth: 620,
|
||||
maxHealth: 1000,
|
||||
radius: 27,
|
||||
moveSpeed: 146,
|
||||
meleeRange: 52,
|
||||
meleeDamage: 7,
|
||||
meleeDamage: 18,
|
||||
meleeCooldown: 0.82,
|
||||
chargeCooldown: 0,
|
||||
chargeWindup: 0,
|
||||
@@ -160,7 +261,7 @@ const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
slamStunSeconds: 0,
|
||||
packHowlCooldown: 5.8,
|
||||
packHowlWindup: 0.95,
|
||||
packLaneDamage: 15,
|
||||
packLaneDamage: 45,
|
||||
packLaneStunSeconds: 0.45,
|
||||
packLaneWidth: 24,
|
||||
packLaneCount: 3,
|
||||
@@ -168,15 +269,20 @@ const DEFAULT_GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
|
||||
const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'khezu',
|
||||
name: 'Khezu',
|
||||
icon: 'K',
|
||||
name: 'Palevolt Maw',
|
||||
icon: 'P',
|
||||
spriteUrl: '/iwt2/bosses/khezu-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 5.9,
|
||||
spriteHeightScale: 4.4,
|
||||
spriteYOffsetScale: -0.12,
|
||||
color: '#d8d7c9',
|
||||
accentColor: '#77d9ff',
|
||||
maxHealth: 760,
|
||||
maxHealth: 1100,
|
||||
radius: 30,
|
||||
moveSpeed: 92,
|
||||
meleeRange: 58,
|
||||
meleeDamage: 10,
|
||||
meleeDamage: 20,
|
||||
meleeCooldown: 1.15,
|
||||
chargeCooldown: 0,
|
||||
chargeWindup: 0,
|
||||
@@ -194,16 +300,466 @@ const DEFAULT_KHEZU_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
thunderRingWindup: 0.9,
|
||||
thunderRingInnerRadius: 70,
|
||||
thunderRingOuterRadius: 172,
|
||||
thunderRingDamage: 22,
|
||||
thunderRingDamage: 60,
|
||||
thunderRingStunSeconds: 0.7,
|
||||
lightningStrikeCooldown: 4.2,
|
||||
lightningStrikeWindup: 0.78,
|
||||
lightningStrikeRadius: 46,
|
||||
lightningStrikeDamage: 17,
|
||||
lightningStrikeDamage: 48,
|
||||
lightningStrikeStunSeconds: 0.55,
|
||||
lightningStrikeCount: 2,
|
||||
}
|
||||
|
||||
const DEFAULT_RATHIAN_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'rathian',
|
||||
name: 'Verdant Wyrm',
|
||||
icon: 'V',
|
||||
spriteUrl: '/iwt2/bosses/rathian-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 6.9,
|
||||
spriteHeightScale: 4.35,
|
||||
spriteYOffsetScale: -0.12,
|
||||
color: '#4d9a58',
|
||||
accentColor: '#f0c24d',
|
||||
maxHealth: 1120,
|
||||
radius: 34,
|
||||
moveSpeed: 118,
|
||||
meleeRange: 62,
|
||||
meleeDamage: 16,
|
||||
meleeCooldown: 1.08,
|
||||
chargeCooldown: 0,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
poisonTailCooldown: 5.4,
|
||||
tailSweepWindup: 0.72,
|
||||
tailSweepRecover: 0.48,
|
||||
tailSweepInnerRadius: 24,
|
||||
tailSweepOuterRadius: 118,
|
||||
tailSweepAngleRadians: Math.PI * 1.25,
|
||||
tailSweepDamage: 45,
|
||||
tailSweepStunSeconds: 0.55,
|
||||
poisonSpitCooldown: 6.2,
|
||||
poisonSpitWindup: 0.7,
|
||||
poisonPuddleRadius: 46,
|
||||
poisonPuddleDamage: 15,
|
||||
poisonPuddleSeconds: 8,
|
||||
}
|
||||
|
||||
const DEFAULT_BARROTH_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'barroth',
|
||||
name: 'Mirehorn',
|
||||
icon: 'M',
|
||||
spriteUrl: '/iwt2/bosses/barroth-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 6.3,
|
||||
spriteHeightScale: 3.35,
|
||||
spriteYOffsetScale: -0.07,
|
||||
color: '#8d714d',
|
||||
accentColor: '#d6bd80',
|
||||
maxHealth: 1280,
|
||||
radius: 35,
|
||||
moveSpeed: 104,
|
||||
meleeRange: 60,
|
||||
meleeDamage: 16,
|
||||
meleeCooldown: 1.18,
|
||||
chargeCooldown: 0,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
mudArmor: 280,
|
||||
mudSprayCooldown: 5.8,
|
||||
mudSprayWindup: 0.82,
|
||||
mudSprayRange: 172,
|
||||
mudSprayAngleRadians: Math.PI * 0.58,
|
||||
mudSprayDamage: 28,
|
||||
mudSprayStunSeconds: 0.25,
|
||||
mudPuddleRadius: 40,
|
||||
mudPuddleSeconds: 7.5,
|
||||
}
|
||||
|
||||
const DEFAULT_TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'tobi-kadachi',
|
||||
name: 'Stormtail',
|
||||
icon: 'S',
|
||||
spriteUrl: '/iwt2/bosses/tobi-kadachi-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 6.7,
|
||||
spriteHeightScale: 3.85,
|
||||
spriteYOffsetScale: -0.08,
|
||||
color: '#6a79bd',
|
||||
accentColor: '#a8f3ff',
|
||||
maxHealth: 920,
|
||||
radius: 28,
|
||||
moveSpeed: 160,
|
||||
meleeRange: 53,
|
||||
meleeDamage: 13,
|
||||
meleeCooldown: 0.78,
|
||||
chargeCooldown: 2.2,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
staticEnergyMax: 100,
|
||||
staticEnergyPerSecond: 16,
|
||||
staticEnergyPerMelee: 15,
|
||||
staticPounceWindup: 0.58,
|
||||
staticPounceSpeed: 560,
|
||||
staticPounceDamage: 46,
|
||||
staticPounceStunSeconds: 0.6,
|
||||
staticPounceLength: 520,
|
||||
staticPounceWidth: 46,
|
||||
chainShockCooldown: 5.2,
|
||||
chainShockWindup: 0.85,
|
||||
chainShockRadius: 86,
|
||||
chainShockDamage: 34,
|
||||
chainShockStunSeconds: 0.45,
|
||||
}
|
||||
|
||||
const DEFAULT_RIMEBASTION_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'rimebastion',
|
||||
name: 'Rimebastion',
|
||||
icon: 'I',
|
||||
spriteUrl: '/iwt2/bosses/rimebastion-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 7.2,
|
||||
spriteHeightScale: 4.5,
|
||||
spriteYOffsetScale: -0.08,
|
||||
color: '#5f9fd6',
|
||||
accentColor: '#b8f3ff',
|
||||
maxHealth: 1380,
|
||||
radius: 37,
|
||||
moveSpeed: 86,
|
||||
meleeRange: 62,
|
||||
meleeDamage: 16,
|
||||
meleeCooldown: 1.2,
|
||||
chargeCooldown: 5.6,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
iceWallCooldown: 6.2,
|
||||
iceWallWindup: 0.9,
|
||||
iceWallActiveSeconds: 0.7,
|
||||
iceWallCount: 3,
|
||||
iceWallLength: 180,
|
||||
iceWallWidth: 22,
|
||||
iceWallDamage: 18,
|
||||
iceShardDamage: 34,
|
||||
iceShardStunSeconds: 0.55,
|
||||
}
|
||||
|
||||
const DEFAULT_EMBER_MANTIS_DUELIST_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'ember-mantis-duelist',
|
||||
name: 'Ember Mantis Duelist',
|
||||
icon: 'M',
|
||||
spriteUrl: '/iwt2/bosses/ember-mantis-duelist-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 5.6,
|
||||
spriteHeightScale: 4.6,
|
||||
spriteYOffsetScale: -0.1,
|
||||
color: '#b94831',
|
||||
accentColor: '#ffb24a',
|
||||
maxHealth: 1040,
|
||||
radius: 29,
|
||||
moveSpeed: 168,
|
||||
meleeRange: 54,
|
||||
meleeDamage: 13,
|
||||
meleeCooldown: 0.78,
|
||||
chargeCooldown: 3.8,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
slashCooldown: 4.2,
|
||||
slashWindup: 0.48,
|
||||
slashDamage: 36,
|
||||
slashStunSeconds: 0.35,
|
||||
slashWidth: 24,
|
||||
crossSlashAngleRadians: Math.PI * 0.32,
|
||||
sidestepDistance: 94,
|
||||
}
|
||||
|
||||
const DEFAULT_CINDERBACK_RICOCHET_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'cinderback-ricochet',
|
||||
name: 'Cinderback Ricochet',
|
||||
icon: 'C',
|
||||
spriteUrl: '/iwt2/bosses/cinderback-ricochet-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 6.6,
|
||||
spriteHeightScale: 3.8,
|
||||
spriteYOffsetScale: -0.06,
|
||||
color: '#9a4f2f',
|
||||
accentColor: '#ff8a3d',
|
||||
maxHealth: 1250,
|
||||
radius: 34,
|
||||
moveSpeed: 106,
|
||||
meleeRange: 60,
|
||||
meleeDamage: 14,
|
||||
meleeCooldown: 1.05,
|
||||
chargeCooldown: 5.4,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
ricochetCooldown: 6.6,
|
||||
ricochetWindup: 0.65,
|
||||
ricochetSpeed: 440,
|
||||
ricochetDamage: 40,
|
||||
ricochetStunSeconds: 0.45,
|
||||
ricochetSeconds: 1.45,
|
||||
lavaTrailRadius: 34,
|
||||
lavaTrailDamage: 15,
|
||||
lavaTrailSeconds: 5.5,
|
||||
armorSlamWindup: 0.42,
|
||||
armorSlamRadius: 86,
|
||||
armorSlamDamage: 44,
|
||||
armorSlamStunSeconds: 0.5,
|
||||
}
|
||||
|
||||
const DEFAULT_OBSIDIAN_RAM_GOLEM_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'obsidian-ram-golem',
|
||||
name: 'Obsidian Ram Golem',
|
||||
icon: 'O',
|
||||
spriteUrl: '/iwt2/bosses/obsidian-ram-golem-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 6.5,
|
||||
spriteHeightScale: 4.25,
|
||||
spriteYOffsetScale: -0.08,
|
||||
color: '#2f3034',
|
||||
accentColor: '#ff7b32',
|
||||
maxHealth: 1420,
|
||||
radius: 36,
|
||||
moveSpeed: 98,
|
||||
meleeRange: 62,
|
||||
meleeDamage: 14,
|
||||
meleeCooldown: 1.12,
|
||||
chargeCooldown: 5.7,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
mudArmor: 240,
|
||||
}
|
||||
|
||||
const DEFAULT_STORMCOIL_WYRM_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'stormcoil-wyrm',
|
||||
name: 'Stormcoil Wyrm',
|
||||
icon: 'S',
|
||||
spriteUrl: '/iwt2/bosses/stormcoil-wyrm-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 7.1,
|
||||
spriteHeightScale: 4.15,
|
||||
spriteYOffsetScale: -0.1,
|
||||
color: '#273b75',
|
||||
accentColor: '#78ecff',
|
||||
maxHealth: 1120,
|
||||
radius: 30,
|
||||
moveSpeed: 142,
|
||||
meleeRange: 58,
|
||||
meleeDamage: 12,
|
||||
meleeCooldown: 0.95,
|
||||
chargeCooldown: 5.1,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
chainShockCooldown: 5,
|
||||
chainShockWindup: 0.78,
|
||||
chainShockRadius: 94,
|
||||
chainShockDamage: 18,
|
||||
chainShockStunSeconds: 0.38,
|
||||
}
|
||||
|
||||
const DEFAULT_VENOM_ORCHID_HYDRA_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'venom-orchid-hydra',
|
||||
name: 'Venom Orchid Hydra',
|
||||
icon: 'V',
|
||||
spriteUrl: '/iwt2/bosses/venom-orchid-hydra-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 7.2,
|
||||
spriteHeightScale: 4.6,
|
||||
spriteYOffsetScale: -0.1,
|
||||
color: '#5e316c',
|
||||
accentColor: '#d8f05b',
|
||||
maxHealth: 1320,
|
||||
radius: 35,
|
||||
moveSpeed: 84,
|
||||
meleeRange: 64,
|
||||
meleeDamage: 12,
|
||||
meleeCooldown: 1.15,
|
||||
chargeCooldown: 5.4,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
poisonSpitCooldown: 5.6,
|
||||
poisonSpitWindup: 0.75,
|
||||
poisonPuddleRadius: 42,
|
||||
poisonPuddleDamage: 15,
|
||||
poisonPuddleSeconds: 7,
|
||||
}
|
||||
|
||||
const DEFAULT_SANDGLASS_SCORPION_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'sandglass-scorpion',
|
||||
name: 'Sandglass Scorpion',
|
||||
icon: 'G',
|
||||
spriteUrl: '/iwt2/bosses/sandglass-scorpion-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 6.4,
|
||||
spriteHeightScale: 4.25,
|
||||
spriteYOffsetScale: -0.07,
|
||||
color: '#9d7430',
|
||||
accentColor: '#ffd15a',
|
||||
maxHealth: 1245,
|
||||
radius: 33,
|
||||
moveSpeed: 126,
|
||||
meleeRange: 58,
|
||||
meleeDamage: 13,
|
||||
meleeCooldown: 0.98,
|
||||
chargeCooldown: 5.2,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
mudPuddleRadius: 44,
|
||||
mudPuddleSeconds: 6,
|
||||
}
|
||||
|
||||
const DEFAULT_CRYSTAL_BAT_MATRIARCH_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'crystal-bat-matriarch',
|
||||
name: 'Crystal Bat Matriarch',
|
||||
icon: 'Q',
|
||||
spriteUrl: '/iwt2/bosses/crystal-bat-matriarch-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 6.8,
|
||||
spriteHeightScale: 4.75,
|
||||
spriteYOffsetScale: -0.12,
|
||||
color: '#4e2d76',
|
||||
accentColor: '#8eeaff',
|
||||
maxHealth: 1050,
|
||||
radius: 31,
|
||||
moveSpeed: 150,
|
||||
meleeRange: 56,
|
||||
meleeDamage: 11,
|
||||
meleeCooldown: 0.9,
|
||||
chargeCooldown: 5.1,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
}
|
||||
|
||||
const DEFAULT_HOLLOWCROWN_REVENANT_BOSS_METADATA: Iwt2BossMetadata = {
|
||||
id: 'hollowcrown-revenant',
|
||||
name: 'Hollowcrown Revenant',
|
||||
icon: 'H',
|
||||
spriteUrl: '/iwt2/bosses/hollowcrown-revenant-side-cel.png',
|
||||
spriteView: 'side',
|
||||
spriteWidthScale: 6.6,
|
||||
spriteHeightScale: 4.35,
|
||||
spriteYOffsetScale: -0.1,
|
||||
color: '#3c3f5e',
|
||||
accentColor: '#9be8ff',
|
||||
maxHealth: 1080,
|
||||
radius: 30,
|
||||
moveSpeed: 158,
|
||||
meleeRange: 56,
|
||||
meleeDamage: 12,
|
||||
meleeCooldown: 0.9,
|
||||
chargeCooldown: 4.8,
|
||||
chargeWindup: 0,
|
||||
chargeSpeed: 0,
|
||||
chargeDamage: 0,
|
||||
chargeStunSeconds: 0,
|
||||
chargeLength: 0,
|
||||
chargeOvershoot: 0,
|
||||
chargeWidth: 0,
|
||||
slamWindup: 0,
|
||||
slamRadius: 0,
|
||||
slamDamage: 0,
|
||||
slamStunSeconds: 0,
|
||||
}
|
||||
|
||||
function applyBossOverrides(metadata: Iwt2BossMetadata): Iwt2BossMetadata {
|
||||
const override = IWT2_BALANCE_OVERRIDES.bosses?.[metadata.id]
|
||||
if (!override) return metadata
|
||||
@@ -217,10 +773,34 @@ export const BULLDROME_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFA
|
||||
export const YIAN_KUT_KU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_YIAN_KUT_KU_BOSS_METADATA)
|
||||
export const GREAT_JAGGI_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_GREAT_JAGGI_BOSS_METADATA)
|
||||
export const KHEZU_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_KHEZU_BOSS_METADATA)
|
||||
export const RATHIAN_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_RATHIAN_BOSS_METADATA)
|
||||
export const BARROTH_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_BARROTH_BOSS_METADATA)
|
||||
export const TOBI_KADACHI_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_TOBI_KADACHI_BOSS_METADATA)
|
||||
export const RIMEBASTION_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_RIMEBASTION_BOSS_METADATA)
|
||||
export const EMBER_MANTIS_DUELIST_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_EMBER_MANTIS_DUELIST_BOSS_METADATA)
|
||||
export const CINDERBACK_RICOCHET_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_CINDERBACK_RICOCHET_BOSS_METADATA)
|
||||
export const OBSIDIAN_RAM_GOLEM_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_OBSIDIAN_RAM_GOLEM_BOSS_METADATA)
|
||||
export const STORMCOIL_WYRM_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_STORMCOIL_WYRM_BOSS_METADATA)
|
||||
export const VENOM_ORCHID_HYDRA_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_VENOM_ORCHID_HYDRA_BOSS_METADATA)
|
||||
export const SANDGLASS_SCORPION_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_SANDGLASS_SCORPION_BOSS_METADATA)
|
||||
export const CRYSTAL_BAT_MATRIARCH_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_CRYSTAL_BAT_MATRIARCH_BOSS_METADATA)
|
||||
export const HOLLOWCROWN_REVENANT_BOSS_METADATA: Iwt2BossMetadata = applyBossOverrides(DEFAULT_HOLLOWCROWN_REVENANT_BOSS_METADATA)
|
||||
|
||||
export const IWT2_BOSS_METADATA: Record<Iwt2BossId, Iwt2BossMetadata> = {
|
||||
bulldrome: BULLDROME_BOSS_METADATA,
|
||||
'yian-kut-ku': YIAN_KUT_KU_BOSS_METADATA,
|
||||
'great-jaggi': GREAT_JAGGI_BOSS_METADATA,
|
||||
khezu: KHEZU_BOSS_METADATA,
|
||||
rathian: RATHIAN_BOSS_METADATA,
|
||||
barroth: BARROTH_BOSS_METADATA,
|
||||
'tobi-kadachi': TOBI_KADACHI_BOSS_METADATA,
|
||||
rimebastion: RIMEBASTION_BOSS_METADATA,
|
||||
'ember-mantis-duelist': EMBER_MANTIS_DUELIST_BOSS_METADATA,
|
||||
'cinderback-ricochet': CINDERBACK_RICOCHET_BOSS_METADATA,
|
||||
'obsidian-ram-golem': OBSIDIAN_RAM_GOLEM_BOSS_METADATA,
|
||||
'stormcoil-wyrm': STORMCOIL_WYRM_BOSS_METADATA,
|
||||
'venom-orchid-hydra': VENOM_ORCHID_HYDRA_BOSS_METADATA,
|
||||
'sandglass-scorpion': SANDGLASS_SCORPION_BOSS_METADATA,
|
||||
'crystal-bat-matriarch': CRYSTAL_BAT_MATRIARCH_BOSS_METADATA,
|
||||
'hollowcrown-revenant': HOLLOWCROWN_REVENANT_BOSS_METADATA,
|
||||
}
|
||||
|
||||
@@ -2,11 +2,48 @@ export type Iwt2PlayerClassId = 'healer' | 'paladin' | 'ranger' | 'mage' | 'rogu
|
||||
|
||||
export type Iwt2PlayerClassRole = 'healer' | 'tank' | 'damage'
|
||||
|
||||
export type Iwt2ClassEquipmentSlot =
|
||||
| 'weapon'
|
||||
| 'offhand'
|
||||
| 'head'
|
||||
| 'chest'
|
||||
| 'hands'
|
||||
| 'legs'
|
||||
|
||||
export type Iwt2ClassLayerMotion = {
|
||||
offsetXScale?: number
|
||||
offsetYScale?: number
|
||||
rotation?: number
|
||||
}
|
||||
|
||||
export type Iwt2ClassWeaponLayer = {
|
||||
id: string
|
||||
slot: Iwt2ClassEquipmentSlot
|
||||
url: string
|
||||
drawOrder: 'behindBody' | 'front'
|
||||
heightScale: number
|
||||
offsetXScale: number
|
||||
offsetYScale: number
|
||||
originX?: number
|
||||
originY?: number
|
||||
attackMotion?: Iwt2ClassLayerMotion
|
||||
tierAccentEligible?: boolean
|
||||
}
|
||||
|
||||
export type Iwt2ClassLayeredArenaSprite = {
|
||||
bodyUrl: string
|
||||
bodyHeightScale?: number
|
||||
weaponLayers: Iwt2ClassWeaponLayer[]
|
||||
}
|
||||
|
||||
export type Iwt2ClassMetadata = {
|
||||
id: Iwt2PlayerClassId
|
||||
name: string
|
||||
role: Iwt2PlayerClassRole
|
||||
icon: string
|
||||
arenaSpriteUrl: string
|
||||
arenaLayeredSprite?: Iwt2ClassLayeredArenaSprite
|
||||
uiIconUrl: string
|
||||
color: string
|
||||
accentColor: string
|
||||
maxHealth: number
|
||||
@@ -25,6 +62,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
||||
name: 'Player Healer',
|
||||
role: 'healer',
|
||||
icon: '+',
|
||||
arenaSpriteUrl: '/iwt2/classes/arena/healer-chunky.png',
|
||||
uiIconUrl: '/iwt2/classes/ui/healer-medallion.png',
|
||||
color: '#2fbf71',
|
||||
accentColor: '#b7f7cf',
|
||||
maxHealth: 125,
|
||||
@@ -41,6 +80,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
||||
name: 'Paladin Tank',
|
||||
role: 'tank',
|
||||
icon: '🛡',
|
||||
arenaSpriteUrl: '/iwt2/classes/arena/paladin-chunky.png',
|
||||
uiIconUrl: '/iwt2/classes/ui/paladin-medallion.png',
|
||||
color: '#f2c94c',
|
||||
accentColor: '#fff0a8',
|
||||
maxHealth: 190,
|
||||
@@ -57,6 +98,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
||||
name: 'Ranger',
|
||||
role: 'damage',
|
||||
icon: '🏹',
|
||||
arenaSpriteUrl: '/iwt2/classes/arena/ranger-chunky.png',
|
||||
uiIconUrl: '/iwt2/classes/ui/ranger-medallion.png',
|
||||
color: '#4aa3df',
|
||||
accentColor: '#b9e3ff',
|
||||
maxHealth: 115,
|
||||
@@ -64,8 +107,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
||||
radius: 14,
|
||||
attackRange: 245,
|
||||
attackDamage: 8,
|
||||
attackCooldown: 0.62,
|
||||
castTime: 0.32,
|
||||
attackCooldown: 0.31,
|
||||
castTime: 0.16,
|
||||
projectileSpeed: 410,
|
||||
},
|
||||
mage: {
|
||||
@@ -73,6 +116,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
||||
name: 'Mage',
|
||||
role: 'damage',
|
||||
icon: '⚚',
|
||||
arenaSpriteUrl: '/iwt2/classes/arena/mage-chunky.png',
|
||||
uiIconUrl: '/iwt2/classes/ui/mage-medallion.png',
|
||||
color: '#c56cf0',
|
||||
accentColor: '#efc4ff',
|
||||
maxHealth: 100,
|
||||
@@ -80,8 +125,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
||||
radius: 14,
|
||||
attackRange: 220,
|
||||
attackDamage: 11,
|
||||
attackCooldown: 0.78,
|
||||
castTime: 0.42,
|
||||
attackCooldown: 0.39,
|
||||
castTime: 0.21,
|
||||
projectileSpeed: 330,
|
||||
},
|
||||
rogue: {
|
||||
@@ -89,6 +134,8 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
||||
name: 'Rogue',
|
||||
role: 'damage',
|
||||
icon: '††',
|
||||
arenaSpriteUrl: '/iwt2/classes/arena/rogue-chunky.png',
|
||||
uiIconUrl: '/iwt2/classes/ui/rogue-medallion.png',
|
||||
color: '#55efc4',
|
||||
accentColor: '#c8fff2',
|
||||
maxHealth: 110,
|
||||
@@ -105,6 +152,30 @@ export const IWT2_CLASS_METADATA: Record<Iwt2PlayerClassId, Iwt2ClassMetadata> =
|
||||
name: 'Warrior',
|
||||
role: 'damage',
|
||||
icon: '⚔',
|
||||
arenaSpriteUrl: '/iwt2/classes/arena/warrior-chunky.png',
|
||||
arenaLayeredSprite: {
|
||||
bodyUrl: '/iwt2/classes/arena/layers/warrior-body.png',
|
||||
weaponLayers: [
|
||||
{
|
||||
id: 'axe',
|
||||
slot: 'weapon',
|
||||
url: '/iwt2/classes/arena/layers/warrior-weapon-axe.png',
|
||||
drawOrder: 'front',
|
||||
heightScale: 1.72,
|
||||
offsetXScale: 0.38,
|
||||
offsetYScale: -0.88,
|
||||
originX: 0.18,
|
||||
originY: 0.5,
|
||||
attackMotion: {
|
||||
offsetXScale: 0.22,
|
||||
offsetYScale: -0.18,
|
||||
rotation: 0.54,
|
||||
},
|
||||
tierAccentEligible: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
uiIconUrl: '/iwt2/classes/ui/warrior-medallion.png',
|
||||
color: '#ff7675',
|
||||
accentColor: '#ffd0d0',
|
||||
maxHealth: 150,
|
||||
|
||||