Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fa1c8c070 | ||
|
|
802dadc7f3 |
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 115
|
versionCode 117
|
||||||
versionName "1.1.34"
|
versionName "1.1.36"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -4,10 +4,30 @@ import { createServer } from 'vite'
|
|||||||
const DEFAULT_WORKERS = Math.max(1, Math.min(8, Number.parseInt(process.env.IWT2_SIM_WORKERS ?? '8', 10)))
|
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_MAX_SECONDS = Math.max(30, Number.parseFloat(process.env.IWT2_SIM_SECONDS ?? '180'))
|
||||||
const DEFAULT_DT = 1 / 30
|
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 PVP_BOSS_HEALTH_MULTIPLIER = 0.7
|
||||||
const BOSS_HEALTH_PER_STAGE = 0.1
|
const BOSS_HEALTH_PER_STAGE = 0.1
|
||||||
const ARENA_BOUNDS = { width: 960, height: 540 }
|
const ARENA_BOUNDS = { width: 960, height: 540 }
|
||||||
const HEALER_STYLES = ['dawnweaver', 'lifebinder', 'runesage']
|
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()
|
const activeChildren = new Set()
|
||||||
|
|
||||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
@@ -32,6 +52,7 @@ const config = {
|
|||||||
bossFilter: parseList(args.get('--bosses') ?? 'all'),
|
bossFilter: parseList(args.get('--bosses') ?? 'all'),
|
||||||
bossHpPercent: parsePositiveNumber(args.get('--boss-hp-percent') ?? '100', 100),
|
bossHpPercent: parsePositiveNumber(args.get('--boss-hp-percent') ?? '100', 100),
|
||||||
classes: parseList(args.get('--classes') ?? args.get('--class') ?? 'all'),
|
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),
|
gearLevel: parseNonNegativeInt(args.get('--gear-level') ?? args.get('--gear') ?? '5', 5),
|
||||||
maxSeconds: Number.parseFloat(args.get('--seconds') ?? String(DEFAULT_MAX_SECONDS)),
|
maxSeconds: Number.parseFloat(args.get('--seconds') ?? String(DEFAULT_MAX_SECONDS)),
|
||||||
repeats: parsePositiveInt(args.get('--repeats') ?? '1', 1),
|
repeats: parsePositiveInt(args.get('--repeats') ?? '1', 1),
|
||||||
@@ -66,6 +87,7 @@ async function runMain({
|
|||||||
bossFilter,
|
bossFilter,
|
||||||
bossHpPercent,
|
bossHpPercent,
|
||||||
classes,
|
classes,
|
||||||
|
estimateOnly,
|
||||||
gearLevel,
|
gearLevel,
|
||||||
maxSeconds,
|
maxSeconds,
|
||||||
repeats,
|
repeats,
|
||||||
@@ -75,6 +97,33 @@ async function runMain({
|
|||||||
workers,
|
workers,
|
||||||
}) {
|
}) {
|
||||||
const safeWorkers = Math.max(1, Math.floor(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) => (
|
const childResults = await Promise.all(Array.from({ length: safeWorkers }, (_, shardIndex) => (
|
||||||
runChild({
|
runChild({
|
||||||
bossCount,
|
bossCount,
|
||||||
@@ -92,6 +141,7 @@ async function runMain({
|
|||||||
})
|
})
|
||||||
)))
|
)))
|
||||||
const results = childResults.flat()
|
const results = childResults.flat()
|
||||||
|
const elapsedSeconds = round((Date.now() - startedAt) / 1000)
|
||||||
const summary = summarizeResults(results)
|
const summary = summarizeResults(results)
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
config: {
|
config: {
|
||||||
@@ -101,6 +151,8 @@ async function runMain({
|
|||||||
bosses: bossFilter,
|
bosses: bossFilter,
|
||||||
classes,
|
classes,
|
||||||
gearLevel,
|
gearLevel,
|
||||||
|
elapsedSeconds,
|
||||||
|
estimate,
|
||||||
orderedTrials: results.length,
|
orderedTrials: results.length,
|
||||||
repeats,
|
repeats,
|
||||||
requiredBosses,
|
requiredBosses,
|
||||||
@@ -549,6 +601,7 @@ Options:
|
|||||||
--bosses all|bulldrome,yian-kut-ku,...
|
--bosses all|bulldrome,yian-kut-ku,...
|
||||||
--required-bosses none|yian-kut-ku,...
|
--required-bosses none|yian-kut-ku,...
|
||||||
--repeats 1
|
--repeats 1
|
||||||
|
--estimate-only
|
||||||
--boss-hp-percent 100
|
--boss-hp-percent 100
|
||||||
--boss-damage-percent 100
|
--boss-damage-percent 100
|
||||||
--stages 1,2
|
--stages 1,2
|
||||||
@@ -562,9 +615,43 @@ Examples:
|
|||||||
node scripts/iwt2-pvp-roguelike-boss-sim.mjs --boss-hp-percent 125 --boss-damage-percent 110
|
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 --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 --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) {
|
function filteredBossIds(allBossIds, filter) {
|
||||||
if (filter.length === 0 || filter.includes('all')) return allBossIds
|
if (filter.length === 0 || filter.includes('all')) return allBossIds
|
||||||
const known = new Set(allBossIds)
|
const known = new Set(allBossIds)
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ function sendHtml(response) {
|
|||||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
.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; }
|
.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; }
|
.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 { border: 1px solid #3a4656; color: #eef2f6; background: #202733; border-radius: 6px; padding: 9px 10px; cursor: pointer; }
|
||||||
button.primary { background: #245a7a; border-color: #327aa3; }
|
button.primary { background: #245a7a; border-color: #327aa3; }
|
||||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
@@ -232,6 +235,8 @@ function sendHtml(response) {
|
|||||||
<h2>Required Bosses</h2>
|
<h2>Required Bosses</h2>
|
||||||
<div id="requiredBosses" class="bosses"></div>
|
<div id="requiredBosses" class="bosses"></div>
|
||||||
|
|
||||||
|
<div id="estimate" class="estimate"></div>
|
||||||
|
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button id="run" class="primary">Run</button>
|
<button id="run" class="primary">Run</button>
|
||||||
<button id="stop" disabled>Stop</button>
|
<button id="stop" disabled>Stop</button>
|
||||||
@@ -255,6 +260,7 @@ function sendHtml(response) {
|
|||||||
const status = document.getElementById("status");
|
const status = document.getElementById("status");
|
||||||
const raw = document.getElementById("raw");
|
const raw = document.getElementById("raw");
|
||||||
const dashboard = document.getElementById("dashboard");
|
const dashboard = document.getElementById("dashboard");
|
||||||
|
const estimateBox = document.getElementById("estimate");
|
||||||
const run = document.getElementById("run");
|
const run = document.getElementById("run");
|
||||||
const stop = document.getElementById("stop");
|
const stop = document.getElementById("stop");
|
||||||
|
|
||||||
@@ -265,6 +271,8 @@ function sendHtml(response) {
|
|||||||
renderChecks("requiredBosses", config.bosses, false);
|
renderChecks("requiredBosses", config.bosses, false);
|
||||||
wireAll("allClasses", "classes");
|
wireAll("allClasses", "classes");
|
||||||
wireAll("allBosses", "bosses");
|
wireAll("allBosses", "bosses");
|
||||||
|
wireEstimateUpdates();
|
||||||
|
updateEstimate();
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderChecks(containerId, values, checked) {
|
function renderChecks(containerId, values, checked) {
|
||||||
@@ -277,9 +285,11 @@ function sendHtml(response) {
|
|||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
master.addEventListener("change", () => {
|
master.addEventListener("change", () => {
|
||||||
container.querySelectorAll("input").forEach(input => input.checked = master.checked);
|
container.querySelectorAll("input").forEach(input => input.checked = master.checked);
|
||||||
|
updateEstimate();
|
||||||
});
|
});
|
||||||
container.addEventListener("change", () => {
|
container.addEventListener("change", () => {
|
||||||
master.checked = [...container.querySelectorAll("input")].every(input => input.checked);
|
master.checked = [...container.querySelectorAll("input")].every(input => input.checked);
|
||||||
|
updateEstimate();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,15 +343,100 @@ function sendHtml(response) {
|
|||||||
].join(" ");
|
].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 () => {
|
run.addEventListener("click", async () => {
|
||||||
const options = payload();
|
const options = payload();
|
||||||
raw.textContent = "$ " + commandFor(options) + "\\n\\n";
|
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>';
|
dashboard.innerHTML = '<div class="empty">Simulation running...</div>';
|
||||||
state.lastJson = null;
|
state.lastJson = null;
|
||||||
state.running = true;
|
state.running = true;
|
||||||
run.disabled = true;
|
run.disabled = true;
|
||||||
stop.disabled = false;
|
stop.disabled = false;
|
||||||
status.textContent = "Running...";
|
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) });
|
const response = await fetch("/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(options) });
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
status.textContent = "Failed to start";
|
status.textContent = "Failed to start";
|
||||||
@@ -393,8 +488,8 @@ function sendHtml(response) {
|
|||||||
return [
|
return [
|
||||||
'<div class="cards">',
|
'<div class="cards">',
|
||||||
metric('Trials', totalTrials),
|
metric('Trials', totalTrials),
|
||||||
|
metric('Elapsed', duration(data.config?.elapsedSeconds ?? 0)),
|
||||||
metric('Worst fail rate', worst ? percent(failRate(worst)) : 'n/a'),
|
metric('Worst fail rate', worst ? percent(failRate(worst)) : 'n/a'),
|
||||||
metric('Worst combo', worst ? escapeHtml(worst.key) : 'n/a'),
|
|
||||||
metric('Gear / repeats', 'Gear +' + escapeHtml(data.config?.gearLevel ?? '?') + ' / ' + escapeHtml(data.config?.repeats ?? '?') + 'x'),
|
metric('Gear / repeats', 'Gear +' + escapeHtml(data.config?.gearLevel ?? '?') + ' / ' + escapeHtml(data.config?.repeats ?? '?') + 'x'),
|
||||||
'</div>',
|
'</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>' : '',
|
totalFailures === 0 ? '<div class="empty risk-low">No failures found in returned hardest-combo rows. Review deaths and fight time for near-fails.</div>' : '',
|
||||||
|
|||||||
@@ -76,9 +76,11 @@ type Iwt2Screen =
|
|||||||
const IWT2_MENU_COLUMNS = 2
|
const IWT2_MENU_COLUMNS = 2
|
||||||
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
const IWT2_ROGUELIKE_CHOICE_COUNT = 3
|
||||||
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
|
const IWT2_PVP_FIRST_BUFF_EXTRA_TARGET_CHANCE = 0.65
|
||||||
|
const IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD = 5
|
||||||
|
|
||||||
type Iwt2RoguelikeRunState = {
|
type Iwt2RoguelikeRunState = {
|
||||||
bossIds: Iwt2BossId[]
|
bossIds: Iwt2BossId[]
|
||||||
|
bossesDefeated: number
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
contentType: Iwt2RoguelikeContentType
|
contentType: Iwt2RoguelikeContentType
|
||||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||||
@@ -256,10 +258,13 @@ export function IWantToHeal2App({
|
|||||||
buffs: roguelikeRun.buffs,
|
buffs: roguelikeRun.buffs,
|
||||||
contentType: roguelikeRun.contentType,
|
contentType: roguelikeRun.contentType,
|
||||||
debuffs: roguelikeRun.debuffs,
|
debuffs: roguelikeRun.debuffs,
|
||||||
|
greenCoinThreshold: IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD,
|
||||||
|
bossesDefeated: roguelikeRun.bossesDefeated,
|
||||||
onVictory: () => {
|
onVictory: () => {
|
||||||
setRoguelikeRun((current) => current
|
setRoguelikeRun((current) => current
|
||||||
? {
|
? {
|
||||||
...current,
|
...current,
|
||||||
|
bossesDefeated: current.bossesDefeated + current.bossIds.length,
|
||||||
...buildRoguelikeChoices(save, current.variant),
|
...buildRoguelikeChoices(save, current.variant),
|
||||||
}
|
}
|
||||||
: current)
|
: current)
|
||||||
@@ -534,7 +539,8 @@ function createRoguelikeRun(
|
|||||||
contentType: Iwt2RoguelikeContentType,
|
contentType: Iwt2RoguelikeContentType,
|
||||||
): Iwt2RoguelikeRunState {
|
): Iwt2RoguelikeRunState {
|
||||||
return {
|
return {
|
||||||
bossIds: createRoguelikeBossPair(variant, contentType, 1),
|
bossIds: createRoguelikeBossPair(variant, contentType, 1, 0),
|
||||||
|
bossesDefeated: 0,
|
||||||
buffs: [],
|
buffs: [],
|
||||||
contentType,
|
contentType,
|
||||||
debuffs: [],
|
debuffs: [],
|
||||||
@@ -602,7 +608,7 @@ function applyRoguelikeChoice(
|
|||||||
return {
|
return {
|
||||||
...run,
|
...run,
|
||||||
...nextBase,
|
...nextBase,
|
||||||
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1),
|
bossIds: createRoguelikeBossPair(run.variant, run.contentType, run.stage + 1, run.bossesDefeated),
|
||||||
...buildRoguelikeChoices(save, run.variant),
|
...buildRoguelikeChoices(save, run.variant),
|
||||||
stage: run.stage + 1,
|
stage: run.stage + 1,
|
||||||
}
|
}
|
||||||
@@ -612,6 +618,7 @@ function createRoguelikeBossPair(
|
|||||||
variant: Iwt2RoguelikeVariant,
|
variant: Iwt2RoguelikeVariant,
|
||||||
contentType: Iwt2RoguelikeContentType,
|
contentType: Iwt2RoguelikeContentType,
|
||||||
stage: number,
|
stage: number,
|
||||||
|
bossesDefeated: number,
|
||||||
): Iwt2BossId[] {
|
): Iwt2BossId[] {
|
||||||
const weightedProgressionEnabled = variant === 'pve'
|
const weightedProgressionEnabled = variant === 'pve'
|
||||||
? IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
? IWT2_PVE_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
@@ -619,11 +626,12 @@ function createRoguelikeBossPair(
|
|||||||
? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
? IWT2_PVP_STADIUM_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
: IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
: IWT2_PVP_ROGUELIKE_WEIGHTED_BOSS_PROGRESSION_ENABLED
|
||||||
const bossPool = roguelikeBossPoolFor(variant, contentType)
|
const bossPool = roguelikeBossPoolFor(variant, contentType)
|
||||||
|
const count = bossesDefeated >= IWT2_ROGUELIKE_GREEN_COIN_BOSS_THRESHOLD ? 3 : 2
|
||||||
|
|
||||||
if (weightedProgressionEnabled) {
|
if (weightedProgressionEnabled) {
|
||||||
return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool })
|
return createIwt2WeightedRoguelikeBossPair(stage, Math.random, { bossPool, count })
|
||||||
}
|
}
|
||||||
return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool })
|
return createUniformIwt2RoguelikeBossPair(Math.random, { bossPool, count })
|
||||||
}
|
}
|
||||||
|
|
||||||
function roguelikeBossPoolFor(
|
function roguelikeBossPoolFor(
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { Iwt2BossId } from './bosses'
|
|||||||
import type { Iwt2PlayerClassId } from './classes'
|
import type { Iwt2PlayerClassId } from './classes'
|
||||||
import { iwt2BossCoinRewardFor } from './bossRewards'
|
import { iwt2BossCoinRewardFor } from './bossRewards'
|
||||||
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
import { IWT2_INFUSION_ABILITIES, type Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
import type { Iwt2RoguelikeSelfBuffId } from './roguelike'
|
||||||
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
|
export { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass } from './infusionAbilities'
|
||||||
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
|
export type { Iwt2InfusionAbility, Iwt2InfusionAbilityId } from './infusionAbilities'
|
||||||
|
|
||||||
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet'
|
export type Iwt2GearSlotId = 'weapon' | 'helmet' | 'chest' | 'legs' | 'feet'
|
||||||
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5
|
export type Iwt2GearLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
|
||||||
|
export type Iwt2PassiveInfusionId = Exclude<Iwt2RoguelikeSelfBuffId, 'revive-party-members'>
|
||||||
export type Iwt2GearStatId =
|
export type Iwt2GearStatId =
|
||||||
| 'maxHealth'
|
| 'maxHealth'
|
||||||
| 'moveSpeed'
|
| 'moveSpeed'
|
||||||
@@ -24,6 +26,7 @@ export type Iwt2GearSlotProgress = {
|
|||||||
export type Iwt2ClassGearProgress = {
|
export type Iwt2ClassGearProgress = {
|
||||||
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
|
slots: Record<Iwt2GearSlotId, Iwt2GearSlotProgress>
|
||||||
infusionAbilityId: Iwt2InfusionAbilityId | null
|
infusionAbilityId: Iwt2InfusionAbilityId | null
|
||||||
|
passiveInfusionId: Iwt2PassiveInfusionId | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
|
export type Iwt2GearProgress = Record<Iwt2PlayerClassId, Iwt2ClassGearProgress>
|
||||||
@@ -43,6 +46,9 @@ export type Iwt2GearSlotRecipe = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet']
|
export const IWT2_GEAR_SLOTS: Iwt2GearSlotId[] = ['weapon', 'helmet', 'chest', 'legs', 'feet']
|
||||||
|
export const IWT2_MAX_GEAR_LEVEL: Iwt2GearLevel = 10
|
||||||
|
export const IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL: Iwt2GearLevel = 5
|
||||||
|
export const IWT2_PASSIVE_INFUSION_MIN_GEAR_LEVEL: Iwt2GearLevel = 10
|
||||||
|
|
||||||
export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
|
export const IWT2_GEAR_SLOT_LABELS: Record<Iwt2GearSlotId, string> = {
|
||||||
weapon: 'Weapon',
|
weapon: 'Weapon',
|
||||||
@@ -120,7 +126,13 @@ export function createDefaultIwt2GearProgress(): Iwt2GearProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean {
|
export function isIwt2InfusionUnlocked(progress: Iwt2ClassGearProgress): boolean {
|
||||||
return Object.values(progress.slots).some((slot) => slot.level >= 5)
|
return Object.values(progress.slots).some((slot) => slot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIwt2PassiveInfusionUnlocked(progress: Iwt2GearProgress): boolean {
|
||||||
|
return Object.values(progress).some((classProgress) => (
|
||||||
|
Object.values(classProgress.slots).some((slot) => slot.level >= IWT2_PASSIVE_INFUSION_MIN_GEAR_LEVEL)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function iwt2GearUpgradeCosts(
|
export function iwt2GearUpgradeCosts(
|
||||||
@@ -128,7 +140,7 @@ export function iwt2GearUpgradeCosts(
|
|||||||
slotId: Iwt2GearSlotId,
|
slotId: Iwt2GearSlotId,
|
||||||
currentLevel: Iwt2GearLevel,
|
currentLevel: Iwt2GearLevel,
|
||||||
): Iwt2GearUpgradeCost[] {
|
): Iwt2GearUpgradeCost[] {
|
||||||
if (currentLevel >= 5) return []
|
if (currentLevel >= IWT2_MAX_GEAR_LEVEL) return []
|
||||||
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
const recipe = IWT2_GEAR_SLOT_RECIPES[classId][slotId]
|
||||||
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
|
const nextLevel = (currentLevel + 1) as Exclude<Iwt2GearLevel, 0>
|
||||||
const slug = upgradeDifficultySlug(nextLevel)
|
const slug = upgradeDifficultySlug(nextLevel)
|
||||||
@@ -147,10 +159,15 @@ export function iwt2GearUpgradeCosts(
|
|||||||
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
|
{ itemId: primary.id, itemName: primary.name, quantity: 4 },
|
||||||
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 3 },
|
||||||
]
|
]
|
||||||
return [
|
if (nextLevel === 5) return [
|
||||||
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
|
{ itemId: primary.id, itemName: primary.name, quantity: 5 },
|
||||||
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 },
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 },
|
||||||
]
|
]
|
||||||
|
const overcap = nextLevel - 5
|
||||||
|
return [
|
||||||
|
{ itemId: primary.id, itemName: primary.name, quantity: 5 + overcap },
|
||||||
|
{ itemId: secondary.id, itemName: secondary.name, quantity: 4 + overcap },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function iwt2InfusionCosts(
|
export function iwt2InfusionCosts(
|
||||||
@@ -178,6 +195,7 @@ function createDefaultClassGearProgress(): Iwt2ClassGearProgress {
|
|||||||
feet: { level: 0 },
|
feet: { level: 0 },
|
||||||
},
|
},
|
||||||
infusionAbilityId: null,
|
infusionAbilityId: null,
|
||||||
|
passiveInfusionId: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +211,7 @@ function slotRecipe(
|
|||||||
|
|
||||||
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
|
function upgradeDifficultySlug(level: Exclude<Iwt2GearLevel, 0>): string {
|
||||||
if (level <= 2) return 'initiate'
|
if (level <= 2) return 'initiate'
|
||||||
|
if (level >= 6) return 'veteran'
|
||||||
if (level === 3) return 'veteran'
|
if (level === 3) return 'veteran'
|
||||||
if (level === 4) return 'champion'
|
if (level === 4) return 'champion'
|
||||||
return 'mythic'
|
return 'mythic'
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function createIwt2PvpNormalizedGearProgress(
|
|||||||
IWT2_GEAR_SLOTS.map((slotId) => [slotId, { level: config.gearLevel }]),
|
IWT2_GEAR_SLOTS.map((slotId) => [slotId, { level: config.gearLevel }]),
|
||||||
),
|
),
|
||||||
infusionAbilityId: null,
|
infusionAbilityId: null,
|
||||||
|
passiveInfusionId: null,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
) as Iwt2GearProgress
|
) as Iwt2GearProgress
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type TierWeight = {
|
|||||||
|
|
||||||
type Iwt2RoguelikeBossPoolOptions = {
|
type Iwt2RoguelikeBossPoolOptions = {
|
||||||
bossPool?: readonly Iwt2BossId[]
|
bossPool?: readonly Iwt2BossId[]
|
||||||
|
count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = {
|
const IWT2_ROGUELIKE_BOSS_TIERS: Record<Iwt2RoguelikeBossTier, readonly Iwt2BossId[]> = {
|
||||||
@@ -67,16 +68,17 @@ export function createIwt2WeightedRoguelikeBossPair(
|
|||||||
const choices: Iwt2BossId[] = []
|
const choices: Iwt2BossId[] = []
|
||||||
const maxThreat = maxThreatForStage(stage)
|
const maxThreat = maxThreatForStage(stage)
|
||||||
const bossPool = normalizeBossPool(options.bossPool)
|
const bossPool = normalizeBossPool(options.bossPool)
|
||||||
|
const count = normalizeBossCount(options.count)
|
||||||
|
|
||||||
while (choices.length < 2) {
|
while (choices.length < count) {
|
||||||
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random, bossPool)
|
const next = chooseWeightedBossForStage(stage, choices, maxThreat, random, bossPool)
|
||||||
if (!next) break
|
if (!next) break
|
||||||
choices.push(next)
|
choices.push(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
return choices.length === 2
|
return choices.length === count
|
||||||
? choices
|
? choices
|
||||||
: createUniformIwt2RoguelikeBossPair(random, { bossPool })
|
: createUniformIwt2RoguelikeBossPair(random, { bossPool, count })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUniformIwt2RoguelikeBossPair(
|
export function createUniformIwt2RoguelikeBossPair(
|
||||||
@@ -84,8 +86,9 @@ export function createUniformIwt2RoguelikeBossPair(
|
|||||||
options: Iwt2RoguelikeBossPoolOptions = {},
|
options: Iwt2RoguelikeBossPoolOptions = {},
|
||||||
): Iwt2BossId[] {
|
): Iwt2BossId[] {
|
||||||
const pool = normalizeBossPool(options.bossPool)
|
const pool = normalizeBossPool(options.bossPool)
|
||||||
|
const count = normalizeBossCount(options.count)
|
||||||
const choices: Iwt2BossId[] = []
|
const choices: Iwt2BossId[] = []
|
||||||
while (pool.length > 0 && choices.length < 2) {
|
while (pool.length > 0 && choices.length < count) {
|
||||||
const index = randomIndex(pool.length, random)
|
const index = randomIndex(pool.length, random)
|
||||||
const [choice] = pool.splice(index, 1)
|
const [choice] = pool.splice(index, 1)
|
||||||
if (choice) choices.push(choice)
|
if (choice) choices.push(choice)
|
||||||
@@ -192,7 +195,7 @@ function maxThreatForStage(stage: number): number {
|
|||||||
if (safeStage <= 2) return 3
|
if (safeStage <= 2) return 3
|
||||||
if (safeStage === 3) return 4
|
if (safeStage === 3) return 4
|
||||||
if (safeStage <= 5) return 5
|
if (safeStage <= 5) return 5
|
||||||
return 6
|
return 8
|
||||||
}
|
}
|
||||||
|
|
||||||
function threatForBoss(bossId: Iwt2BossId): number {
|
function threatForBoss(bossId: Iwt2BossId): number {
|
||||||
@@ -203,6 +206,10 @@ function randomIndex(length: number, random: () => number): number {
|
|||||||
return Math.min(length - 1, Math.floor(safeRandom(random) * length))
|
return Math.min(length - 1, Math.floor(safeRandom(random) * length))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBossCount(count: number | undefined): number {
|
||||||
|
return Math.max(1, Math.min(3, Math.floor(count ?? 2)))
|
||||||
|
}
|
||||||
|
|
||||||
function safeRandom(random: () => number): number {
|
function safeRandom(random: () => number): number {
|
||||||
const value = random()
|
const value = random()
|
||||||
return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0
|
return Number.isFinite(value) ? Math.min(0.999999999, Math.max(0, value)) : 0
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ type PhaserArenaProps = {
|
|||||||
|
|
||||||
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
|
export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef }: PhaserArenaProps) {
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const onStepRef = useRef(onStep)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onStepRef.current = onStep
|
||||||
|
}, [onStep])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hostRef.current) return undefined
|
if (!hostRef.current) return undefined
|
||||||
@@ -21,7 +26,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
|
|||||||
getMovement: () => movementRef.current,
|
getMovement: () => movementRef.current,
|
||||||
getSelectedPartyId: () => selectedPartyIdRef.current,
|
getSelectedPartyId: () => selectedPartyIdRef.current,
|
||||||
getState: () => stateRef.current,
|
getState: () => stateRef.current,
|
||||||
step: onStep,
|
step: (movement, dtSeconds) => onStepRef.current(movement, dtSeconds),
|
||||||
})
|
})
|
||||||
const game = new Phaser.Game({
|
const game = new Phaser.Game({
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
@@ -40,7 +45,7 @@ export function PhaserArena({ movementRef, onStep, selectedPartyIdRef, stateRef
|
|||||||
return () => {
|
return () => {
|
||||||
game.destroy(true)
|
game.destroy(true)
|
||||||
}
|
}
|
||||||
}, [movementRef, onStep, selectedPartyIdRef, stateRef])
|
}, [movementRef, selectedPartyIdRef, stateRef])
|
||||||
|
|
||||||
return <div className="iwt2-phaser-host" ref={hostRef} />
|
return <div className="iwt2-phaser-host" ref={hostRef} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import {
|
|||||||
import type { Iwt2BossId } from '../content/bosses'
|
import type { Iwt2BossId } from '../content/bosses'
|
||||||
import {
|
import {
|
||||||
createDefaultIwt2GearProgress,
|
createDefaultIwt2GearProgress,
|
||||||
|
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||||
IWT2_GEAR_SLOTS,
|
IWT2_GEAR_SLOTS,
|
||||||
|
IWT2_MAX_GEAR_LEVEL,
|
||||||
iwt2GearUpgradeCosts,
|
iwt2GearUpgradeCosts,
|
||||||
iwt2InfusionCosts,
|
iwt2InfusionCosts,
|
||||||
isIwt2InfusionUnlocked,
|
isIwt2InfusionUnlocked,
|
||||||
|
isIwt2PassiveInfusionUnlocked,
|
||||||
type Iwt2GearLevel,
|
type Iwt2GearLevel,
|
||||||
type Iwt2GearProgress,
|
type Iwt2GearProgress,
|
||||||
type Iwt2GearSlotId,
|
type Iwt2GearSlotId,
|
||||||
|
type Iwt2PassiveInfusionId,
|
||||||
} from '../content/gear'
|
} from '../content/gear'
|
||||||
import {
|
import {
|
||||||
IWT2_INFUSION_ABILITIES,
|
IWT2_INFUSION_ABILITIES,
|
||||||
@@ -263,7 +267,7 @@ export function upgradeIwt2GearSlot(
|
|||||||
): Iwt2Save {
|
): Iwt2Save {
|
||||||
const classProgress = save.gearProgress[classId]
|
const classProgress = save.gearProgress[classId]
|
||||||
const slot = classProgress.slots[slotId]
|
const slot = classProgress.slots[slotId]
|
||||||
if (slot.level >= 5) throw new Error('Gear slot already at +5.')
|
if (slot.level >= IWT2_MAX_GEAR_LEVEL) throw new Error(`Gear slot already at +${IWT2_MAX_GEAR_LEVEL}.`)
|
||||||
const costs = iwt2GearUpgradeCosts(classId, slotId, slot.level)
|
const costs = iwt2GearUpgradeCosts(classId, slotId, slot.level)
|
||||||
const inventory = spendInventoryCosts(save.inventory, costs)
|
const inventory = spendInventoryCosts(save.inventory, costs)
|
||||||
return {
|
return {
|
||||||
@@ -294,8 +298,10 @@ export function setIwt2InfusionAbility(
|
|||||||
const classProgress = save.gearProgress[classId]
|
const classProgress = save.gearProgress[classId]
|
||||||
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
const ability = IWT2_INFUSION_ABILITIES[abilityId]
|
||||||
if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.')
|
if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.')
|
||||||
if (!isIwt2InfusionUnlocked(classProgress)) throw new Error('Upgrade any gear slot to +5 first.')
|
if (!isIwt2InfusionUnlocked(classProgress)) throw new Error(`Upgrade any gear slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} first.`)
|
||||||
if (classProgress.slots[slotId].level < 5) throw new Error('Select a +5 gear slot to anchor the infusion cost.')
|
if (classProgress.slots[slotId].level < IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) {
|
||||||
|
throw new Error(`Select a +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} gear slot to anchor the infusion cost.`)
|
||||||
|
}
|
||||||
if (classProgress.infusionAbilityId === abilityId) return save
|
if (classProgress.infusionAbilityId === abilityId) return save
|
||||||
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
|
const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId))
|
||||||
return {
|
return {
|
||||||
@@ -312,6 +318,27 @@ export function setIwt2InfusionAbility(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setIwt2PassiveInfusion(
|
||||||
|
save: Iwt2Save,
|
||||||
|
passiveInfusionId: Iwt2PassiveInfusionId,
|
||||||
|
): Iwt2Save {
|
||||||
|
if (!isIwt2PassiveInfusionUnlocked(save.gearProgress)) {
|
||||||
|
throw new Error(`Upgrade any gear slot to +${IWT2_MAX_GEAR_LEVEL} first.`)
|
||||||
|
}
|
||||||
|
if (save.gearProgress.healer.passiveInfusionId === passiveInfusionId) return save
|
||||||
|
return {
|
||||||
|
...save,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
gearProgress: {
|
||||||
|
...save.gearProgress,
|
||||||
|
healer: {
|
||||||
|
...save.gearProgress.healer,
|
||||||
|
passiveInfusionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean {
|
export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean {
|
||||||
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
|
return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity)
|
||||||
}
|
}
|
||||||
@@ -450,6 +477,7 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
|
|||||||
const classProgress = rawClassProgress as {
|
const classProgress = rawClassProgress as {
|
||||||
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
|
slots?: Partial<Record<Iwt2GearSlotId, { level?: unknown }>>
|
||||||
infusionAbilityId?: unknown
|
infusionAbilityId?: unknown
|
||||||
|
passiveInfusionId?: unknown
|
||||||
}
|
}
|
||||||
for (const slotId of IWT2_GEAR_SLOTS) {
|
for (const slotId of IWT2_GEAR_SLOTS) {
|
||||||
next[classId].slots[slotId] = {
|
next[classId].slots[slotId] = {
|
||||||
@@ -461,6 +489,10 @@ function normalizeGearProgress(value: unknown): Iwt2GearProgress {
|
|||||||
&& isIwt2InfusionUnlocked(next[classId])
|
&& isIwt2InfusionUnlocked(next[classId])
|
||||||
? infusionAbilityId as Iwt2InfusionAbilityId
|
? infusionAbilityId as Iwt2InfusionAbilityId
|
||||||
: null
|
: null
|
||||||
|
next[classId].passiveInfusionId = classId === 'healer'
|
||||||
|
&& isIwt2PassiveInfusionUnlocked(next)
|
||||||
|
? asPassiveInfusionId(classProgress.passiveInfusionId)
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
@@ -470,10 +502,15 @@ function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function asGearLevel(value: unknown): Iwt2GearLevel {
|
function asGearLevel(value: unknown): Iwt2GearLevel {
|
||||||
const level = Math.max(0, Math.min(5, Math.floor(Number(value) || 0)))
|
const level = Math.max(0, Math.min(IWT2_MAX_GEAR_LEVEL, Math.floor(Number(value) || 0)))
|
||||||
return level as Iwt2GearLevel
|
return level as Iwt2GearLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asPassiveInfusionId(value: unknown): Iwt2PassiveInfusionId | null {
|
||||||
|
if (typeof value !== 'string' || value === 'revive-party-members') return null
|
||||||
|
return value as Iwt2PassiveInfusionId
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
|
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
const byId = new Map<string, Iwt2InventoryItem>()
|
const byId = new Map<string, Iwt2InventoryItem>()
|
||||||
|
|||||||
@@ -82,9 +82,11 @@ type BossArenaScreenProps = {
|
|||||||
onPvpRequeue?: () => void
|
onPvpRequeue?: () => void
|
||||||
onSaveUpdated: (save: Iwt2Save) => void
|
onSaveUpdated: (save: Iwt2Save) => void
|
||||||
roguelikeRun?: {
|
roguelikeRun?: {
|
||||||
|
bossesDefeated: number
|
||||||
buffs: Iwt2RoguelikeSelfBuffId[]
|
buffs: Iwt2RoguelikeSelfBuffId[]
|
||||||
contentType: Iwt2RoguelikeContentType
|
contentType: Iwt2RoguelikeContentType
|
||||||
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
debuffs: Iwt2RoguelikeOpponentDebuffId[]
|
||||||
|
greenCoinThreshold: number
|
||||||
onVictory: () => void
|
onVictory: () => void
|
||||||
stage: number
|
stage: number
|
||||||
variant: Iwt2RoguelikeVariant
|
variant: Iwt2RoguelikeVariant
|
||||||
@@ -247,9 +249,12 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
const gearAbilities = activeGearProgress
|
const gearAbilities = activeGearProgress
|
||||||
? applyIwt2PveGearToHealerAbilities(baseAbilities, activeGearProgress)
|
? applyIwt2PveGearToHealerAbilities(baseAbilities, activeGearProgress)
|
||||||
: baseAbilities
|
: baseAbilities
|
||||||
|
const passiveInfusionBuffs = activeGearProgress?.healer.passiveInfusionId
|
||||||
|
? [activeGearProgress.healer.passiveInfusionId]
|
||||||
|
: []
|
||||||
return applyRoguelikeModifiers(
|
return applyRoguelikeModifiers(
|
||||||
gearAbilities,
|
gearAbilities,
|
||||||
roguelikeRun?.buffs ?? [],
|
[...passiveInfusionBuffs, ...(roguelikeRun?.buffs ?? [])],
|
||||||
roguelikeRun?.debuffs ?? [],
|
roguelikeRun?.debuffs ?? [],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -402,7 +407,7 @@ export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save,
|
|||||||
for (const defeatedBoss of newlyDefeatedBosses) {
|
for (const defeatedBoss of newlyDefeatedBosses) {
|
||||||
nextRecordedIds.add(defeatedBoss.bossId)
|
nextRecordedIds.add(defeatedBoss.bossId)
|
||||||
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
|
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
|
||||||
difficultySlug,
|
difficultySlug: rewardDifficultySlugForKill(roguelikeRun, newDropAwards.length, difficultySlug),
|
||||||
experienceMultiplier,
|
experienceMultiplier,
|
||||||
})
|
})
|
||||||
updatedSave = reward.save
|
updatedSave = reward.save
|
||||||
@@ -670,6 +675,17 @@ function roguelikeBossHealthScale(stage: number): number {
|
|||||||
return 1 + Math.max(0, stage - 1) * 0.1
|
return 1 + Math.max(0, stage - 1) * 0.1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rewardDifficultySlugForKill(
|
||||||
|
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined,
|
||||||
|
defeatedEarlierThisArena: number,
|
||||||
|
fallbackSlug: string,
|
||||||
|
): string {
|
||||||
|
if (!roguelikeRun) return fallbackSlug
|
||||||
|
return roguelikeRun.bossesDefeated + defeatedEarlierThisArena >= roguelikeRun.greenCoinThreshold
|
||||||
|
? 'veteran'
|
||||||
|
: fallbackSlug
|
||||||
|
}
|
||||||
|
|
||||||
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
|
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
|
||||||
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
|
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
|
||||||
return DEFAULT_OVERLAY_NAV_ENTRIES
|
return DEFAULT_OVERLAY_NAV_ENTRIES
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
createDefaultIwt2Save,
|
createDefaultIwt2Save,
|
||||||
loadIwt2OnlineSave,
|
loadIwt2OnlineSave,
|
||||||
setIwt2InfusionAbility,
|
setIwt2InfusionAbility,
|
||||||
|
setIwt2PassiveInfusion,
|
||||||
updateIwt2CharacterSettings,
|
updateIwt2CharacterSettings,
|
||||||
upgradeIwt2GearSlot,
|
upgradeIwt2GearSlot,
|
||||||
canAffordIwt2Costs,
|
canAffordIwt2Costs,
|
||||||
@@ -40,6 +41,8 @@ import {
|
|||||||
IWT2_HEALER_ORDER,
|
IWT2_HEALER_ORDER,
|
||||||
} from '../content/healerAbilities'
|
} from '../content/healerAbilities'
|
||||||
import {
|
import {
|
||||||
|
buildIwt2SelfBuffChoices,
|
||||||
|
IWT2_REVIVE_PARTY_CHOICE,
|
||||||
type Iwt2RoguelikeChoice,
|
type Iwt2RoguelikeChoice,
|
||||||
type Iwt2RoguelikeContentType,
|
type Iwt2RoguelikeContentType,
|
||||||
type Iwt2RoguelikeOpponentDebuffId,
|
type Iwt2RoguelikeOpponentDebuffId,
|
||||||
@@ -51,9 +54,13 @@ import {
|
|||||||
IWT2_GEAR_SLOT_RECIPES,
|
IWT2_GEAR_SLOT_RECIPES,
|
||||||
IWT2_GEAR_SLOTS,
|
IWT2_GEAR_SLOTS,
|
||||||
IWT2_GEAR_STAT_LABELS,
|
IWT2_GEAR_STAT_LABELS,
|
||||||
|
IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||||
|
IWT2_MAX_GEAR_LEVEL,
|
||||||
iwt2GearUpgradeCosts,
|
iwt2GearUpgradeCosts,
|
||||||
iwt2InfusionCosts,
|
iwt2InfusionCosts,
|
||||||
isIwt2InfusionUnlocked,
|
isIwt2InfusionUnlocked,
|
||||||
|
isIwt2PassiveInfusionUnlocked,
|
||||||
|
type Iwt2PassiveInfusionId,
|
||||||
type Iwt2GearStatId,
|
type Iwt2GearStatId,
|
||||||
type Iwt2GearSlotId,
|
type Iwt2GearSlotId,
|
||||||
} from '../content/gear'
|
} from '../content/gear'
|
||||||
@@ -144,6 +151,7 @@ type Iwt2GearNavEntry =
|
|||||||
| { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId }
|
| { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId }
|
||||||
| { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean }
|
| { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean }
|
||||||
| { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, disabled: boolean }
|
| { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, disabled: boolean }
|
||||||
|
| { kind: 'passiveInfusion', key: string, row: number, column: number, passiveId: Iwt2PassiveInfusionId, disabled: boolean }
|
||||||
|
|
||||||
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
|
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
|
||||||
const IWT2_NAME_MAX_LENGTH = 18
|
const IWT2_NAME_MAX_LENGTH = 18
|
||||||
@@ -2005,16 +2013,22 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
const selectedSlot = classProgress.slots[selectedSlotId]
|
const selectedSlot = classProgress.slots[selectedSlotId]
|
||||||
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
|
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
|
||||||
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId)
|
const selectedBonus = gearBonusSummary(selectedRecipe.statId, selectedSlot.level, selectedClassId)
|
||||||
const nextLevel = Math.min(5, selectedSlot.level + 1)
|
const nextLevel = Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)
|
||||||
const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
|
const nextBonus = gearBonusSummary(selectedRecipe.statId, nextLevel, selectedClassId)
|
||||||
const selectedClassName = gearClassDisplayName(selectedClassId, save)
|
const selectedClassName = gearClassDisplayName(selectedClassId, save)
|
||||||
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
|
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
|
||||||
const canUpgrade = selectedSlot.level < 5 && canAffordIwt2Costs(save, upgradeCosts)
|
const canUpgrade = selectedSlot.level < IWT2_MAX_GEAR_LEVEL && canAffordIwt2Costs(save, upgradeCosts)
|
||||||
const infusionUnlocked = isIwt2InfusionUnlocked(classProgress)
|
const infusionUnlocked = isIwt2InfusionUnlocked(classProgress)
|
||||||
const infusionAnchorSlot = selectedSlot.level >= 5
|
const passiveInfusionUnlocked = isIwt2PassiveInfusionUnlocked(save.gearProgress)
|
||||||
|
const infusionAnchorSlot = selectedSlot.level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL
|
||||||
? selectedSlotId
|
? selectedSlotId
|
||||||
: IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= 5) ?? selectedSlotId
|
: IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) ?? selectedSlotId
|
||||||
const infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId)
|
const infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId)
|
||||||
|
const passiveInfusionChoices = useMemo<Array<Iwt2RoguelikeChoice<Iwt2PassiveInfusionId>>>(() => {
|
||||||
|
if (selectedClassId !== 'healer') return []
|
||||||
|
return buildIwt2SelfBuffChoices(abilitiesForHealer(save.character.healerStyle))
|
||||||
|
.filter((choice): choice is Iwt2RoguelikeChoice<Iwt2PassiveInfusionId> => choice.id !== IWT2_REVIVE_PARTY_CHOICE.id)
|
||||||
|
}, [save.character.healerStyle, selectedClassId])
|
||||||
const navEntries = useMemo<Iwt2GearNavEntry[]>(() => {
|
const navEntries = useMemo<Iwt2GearNavEntry[]>(() => {
|
||||||
const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }]
|
const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }]
|
||||||
IWT2_PARTY_ORDER.forEach((classId, index) => {
|
IWT2_PARTY_ORDER.forEach((classId, index) => {
|
||||||
@@ -2036,8 +2050,19 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs),
|
disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
passiveInfusionChoices.forEach((choice, index) => {
|
||||||
|
const selected = classProgress.passiveInfusionId === choice.id
|
||||||
|
entries.push({
|
||||||
|
kind: 'passiveInfusion',
|
||||||
|
key: `passive:${choice.id}`,
|
||||||
|
row: infusionAbilities.length + index + 1,
|
||||||
|
column: 2,
|
||||||
|
passiveId: choice.id,
|
||||||
|
disabled: selected || !passiveInfusionUnlocked,
|
||||||
|
})
|
||||||
|
})
|
||||||
return entries
|
return entries
|
||||||
}, [canUpgrade, classProgress.infusionAbilityId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, save, selectedClassId])
|
}, [canUpgrade, classProgress.infusionAbilityId, classProgress.passiveInfusionId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, passiveInfusionChoices, passiveInfusionUnlocked, save, selectedClassId])
|
||||||
|
|
||||||
const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0]
|
const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0]
|
||||||
|
|
||||||
@@ -2089,6 +2114,18 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : 'Infusion failed.')
|
setMessage(error instanceof Error ? error.message : 'Infusion failed.')
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (entry.kind === 'passiveInfusion') {
|
||||||
|
if (entry.disabled) return
|
||||||
|
try {
|
||||||
|
const nextSave = setIwt2PassiveInfusion(save, entry.passiveId)
|
||||||
|
onSaveUpdated(nextSave)
|
||||||
|
const passiveName = passiveInfusionChoices.find((choice) => choice.id === entry.passiveId)?.name ?? 'Passive'
|
||||||
|
setMessage(`${passiveName} set as passive infusion.`)
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : 'Passive infusion failed.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2112,6 +2149,7 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
const selected = selectedClassId === classId
|
const selected = selectedClassId === classId
|
||||||
const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId
|
const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId
|
||||||
const classInfusion = save.gearProgress[classId].infusionAbilityId
|
const classInfusion = save.gearProgress[classId].infusionAbilityId
|
||||||
|
const passiveInfusion = classId === 'healer' ? save.gearProgress.healer.passiveInfusionId : null
|
||||||
const highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level))
|
const highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level))
|
||||||
const className = gearClassDisplayName(classId, save)
|
const className = gearClassDisplayName(classId, save)
|
||||||
const classSubtitle = gearClassSubtitle(classId, save)
|
const classSubtitle = gearClassSubtitle(classId, save)
|
||||||
@@ -2129,7 +2167,7 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<div>
|
<div>
|
||||||
<strong>{className}</strong>
|
<strong>{className}</strong>
|
||||||
<small>{classSubtitle}</small>
|
<small>{classSubtitle}</small>
|
||||||
<small>Top +{highest}{classInfusion ? ' | Slot 6 set' : ''}</small>
|
<small>Top +{highest}{classInfusion ? ' | Active set' : ''}{passiveInfusion ? ' | Passive set' : ''}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -2172,7 +2210,7 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')}
|
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Upgrade to +{Math.min(5, selectedSlot.level + 1)}
|
Upgrade to +{Math.min(IWT2_MAX_GEAR_LEVEL, selectedSlot.level + 1)}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -2188,8 +2226,8 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<small>{selectedBonus.text}</small>
|
<small>{selectedBonus.text}</small>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>{selectedSlot.level >= 5 ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
|
<strong>{selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? 'Max rank' : `Upgrade preview +${selectedSlot.level} -> +${nextLevel}`}</strong>
|
||||||
<small>{selectedSlot.level >= 5 ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
|
<small>{selectedSlot.level >= IWT2_MAX_GEAR_LEVEL ? infusionAnchorText(classProgress.slots[selectedSlotId].level) : `${selectedBonus.label}: ${selectedBonus.value} -> ${nextBonus.value}`}</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="iwt2-gear-cost-list">
|
<div className="iwt2-gear-cost-list">
|
||||||
@@ -2227,12 +2265,40 @@ export function Iwt2GearUpgradeScreen({
|
|||||||
<div>
|
<div>
|
||||||
<strong>{ability.name}</strong>
|
<strong>{ability.name}</strong>
|
||||||
<small>{ability.description}</small>
|
<small>{ability.description}</small>
|
||||||
<small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : 'Unlock: any slot to +5'}</small>
|
<small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : `Unlock: any ${selectedClassName} slot to +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL}`}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{selectedClassId === 'healer' && (
|
||||||
|
<div className="iwt2-infusion-list">
|
||||||
|
{passiveInfusionChoices.map((choice) => {
|
||||||
|
const selected = classProgress.passiveInfusionId === choice.id
|
||||||
|
const focused = activeEntry?.kind === 'passiveInfusion' && activeEntry.passiveId === choice.id
|
||||||
|
const disabled = selected || !passiveInfusionUnlocked
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`iwt2-infusion-row ${selected ? 'active' : ''} ${focused ? 'game-selected' : ''}`}
|
||||||
|
data-controller-nav="skip"
|
||||||
|
data-game-selected={focused ? 'true' : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
key={choice.id}
|
||||||
|
onClick={() => activateEntry({ kind: 'passiveInfusion', key: `passive:${choice.id}`, row: 0, column: 2, passiveId: choice.id, disabled })}
|
||||||
|
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, `passive:${choice.id}`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>P</span>
|
||||||
|
<div>
|
||||||
|
<strong>{choice.name}</strong>
|
||||||
|
<small>{choice.description}</small>
|
||||||
|
<small>{selected ? 'Selected passive' : passiveInfusionUnlocked ? 'Passive infusion' : `Unlock: any gear slot to +${IWT2_MAX_GEAR_LEVEL}`}</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<footer className="iwt2-gear-message">{message}</footer>
|
<footer className="iwt2-gear-message">{message}</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -2276,7 +2342,9 @@ function gearBonus(label: string, value: string): { label: string, text: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
function infusionAnchorText(level: number): string {
|
function infusionAnchorText(level: number): string {
|
||||||
return level >= 5 ? 'Infusion anchor available.' : 'No further bonus.'
|
if (level >= IWT2_MAX_GEAR_LEVEL) return 'Active and passive infusion anchors available.'
|
||||||
|
if (level >= IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) return 'Active infusion anchor available.'
|
||||||
|
return 'No infusion anchor.'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPercent(value: number): string {
|
function formatPercent(value: number): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user