diff --git a/README.md b/README.md index 891feee..7750491 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,32 @@ outside the repository. Touch controls on lower display support party targeting, ability casting, map, and inventory. +## Character model rollout and rollback + +Healer characters use the modular `Rig_Medium` renderer by default. Version 1 composes +head, upper body, lower body, headwear, back item, main hand, and offhand slots while +continuing to use Aelia's shared animation set. + +Load a hunter, then open **Appearance Lab** from the main menu. The upper display shows +the real in-game healer renderer with idle, walk, and cast previews. The lower display +selects healer class and cycles every available part. **Save look** persists the current +class, **Reset** restores its authored default, and **Cancel** discards drafts. **Compare +legacy** shows the previous whole-character model without deleting the modular selection. + +Some source assets currently fuse related pieces, so version 1 exposes honest combined +slots such as face + hair, shirt + arms, and pants + shoes. These can split into finer +customization slots when compatible rigged assets are added. + +The previous whole-GLB renderer remains intact during rollout. Use either rollback: + +```text +?characterModels=legacy +VITE_CHARACTER_MODEL_MODE=legacy +``` + +The query changes one browser/app launch. The environment variable produces a legacy +build. Remove the switch to return to modular rendering. + ## Current game scope - Five-member AI party with Disc Priest healer diff --git a/android/app/src/main/java/com/phenomrom/iwanttoheal/ControllerBridgeActivity.java b/android/app/src/main/java/com/phenomrom/iwanttoheal/ControllerBridgeActivity.java index 267c8b0..0d07f2f 100644 --- a/android/app/src/main/java/com/phenomrom/iwanttoheal/ControllerBridgeActivity.java +++ b/android/app/src/main/java/com/phenomrom/iwanttoheal/ControllerBridgeActivity.java @@ -20,7 +20,7 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; -/** Routes every Thor controller event into one JavaScript input service. */ +/** Routes every Thor controller event into one JavaScript input service without WebView focus. */ public abstract class ControllerBridgeActivity extends BridgeActivity { private static final float AXIS_DEAD_ZONE = 0.45f; private static final long REPEAT_THROTTLE_MS = 55L; @@ -43,9 +43,6 @@ public abstract class ControllerBridgeActivity extends BridgeActivity { getWindow().setAttributes(attributes); if (bridge != null && bridge.getWebView() != null) { bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER); - bridge.getWebView().setFocusable(true); - bridge.getWebView().setFocusableInTouchMode(true); - bridge.getWebView().requestFocus(); } enterImmersiveMode(); } @@ -60,22 +57,6 @@ public abstract class ControllerBridgeActivity extends BridgeActivity { public void onResume() { super.onResume(); enterImmersiveMode(); - if (bridge != null && bridge.getWebView() != null) bridge.getWebView().requestFocus(); - } - - @Override - public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - if (hasFocus) enterImmersiveMode(); - else clearHeldControllerState(); - } - - @Override - public boolean dispatchTouchEvent(MotionEvent event) { - if (event.getActionMasked() == MotionEvent.ACTION_DOWN && bridge != null) { - bridge.getWebView().requestFocus(); - } - return super.dispatchTouchEvent(event); } @Override @@ -165,10 +146,7 @@ public abstract class ControllerBridgeActivity extends BridgeActivity { String script = "window.dispatchEvent(new CustomEvent('iwt-native-controller'," + "{detail:{token:'" + token + "',repeat:" + repeat + "}}));"; - bridge.getWebView().post(() -> { - bridge.getWebView().requestFocus(); - bridge.getWebView().evaluateJavascript(script, null); - }); + bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null)); } private void dispatchNativeControllerMotion(float moveX, float moveY, float lookX, float lookY) { diff --git a/db/schema.sql b/db/schema.sql index a391353..f2f135b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -66,3 +66,77 @@ CREATE TABLE IF NOT EXISTS rogue_trials_endless_records ( CREATE INDEX IF NOT EXISTS rogue_trials_endless_rank_idx ON rogue_trials_endless_records (highest_boss_kills DESC, updated_at ASC); + +CREATE TABLE IF NOT EXISTS hockey_healing_records ( + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3), + highest_returns INTEGER NOT NULL DEFAULT 0 CHECK (highest_returns >= 0), + duration_seconds REAL NOT NULL DEFAULT 0 CHECK (duration_seconds >= 0), + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account_id, slot_id), + FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS hockey_healing_rank_idx + ON hockey_healing_records (highest_returns DESC, duration_seconds DESC, updated_at ASC); + +CREATE TABLE IF NOT EXISTS hockey_pvp_records ( + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3), + wins INTEGER NOT NULL DEFAULT 0 CHECK (wins >= 0), + losses INTEGER NOT NULL DEFAULT 0 CHECK (losses >= 0), + boss_kills INTEGER NOT NULL DEFAULT 0 CHECK (boss_kills >= 0), + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account_id, slot_id), + FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS hockey_pvp_wins_rank_idx + ON hockey_pvp_records (wins DESC, losses ASC, updated_at ASC); + +CREATE INDEX IF NOT EXISTS hockey_pvp_boss_kills_rank_idx + ON hockey_pvp_records (boss_kills DESC, wins DESC, updated_at ASC); + +CREATE TABLE IF NOT EXISTS blockbreaker_records ( + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3), + highest_bricks INTEGER NOT NULL DEFAULT 0 CHECK (highest_bricks >= 0), + bricks_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + longest_seconds REAL NOT NULL DEFAULT 0 CHECK (longest_seconds >= 0), + time_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + highest_score INTEGER NOT NULL DEFAULT 0 CHECK (highest_score >= 0), + score_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account_id, slot_id), + FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS blockbreaker_bricks_rank_idx + ON blockbreaker_records (highest_bricks DESC, bricks_achieved_at ASC, account_id ASC, slot_id ASC); + +CREATE INDEX IF NOT EXISTS blockbreaker_time_rank_idx + ON blockbreaker_records (longest_seconds DESC, time_achieved_at ASC, account_id ASC, slot_id ASC); + +CREATE INDEX IF NOT EXISTS blockbreaker_score_rank_idx + ON blockbreaker_records (highest_score DESC, score_achieved_at ASC, account_id ASC, slot_id ASC); + +CREATE TABLE IF NOT EXISTS aether_assault_records ( + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3), + highest_score INTEGER NOT NULL DEFAULT 0 CHECK (highest_score >= 0), + wave_at_best INTEGER NOT NULL DEFAULT 0 CHECK (wave_at_best >= 0), + duration_at_best REAL NOT NULL DEFAULT 0 CHECK (duration_at_best >= 0), + score_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account_id, slot_id), + FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS aether_assault_rank_idx + ON aether_assault_records ( + highest_score DESC, + wave_at_best DESC, + score_achieved_at ASC, + account_id ASC, + slot_id ASC + ); diff --git a/server/game-api.mjs b/server/game-api.mjs index 273ed34..542f22d 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -180,14 +180,15 @@ function validateSlotId(value) { } function validateSave(value, slotId) { - if (!value || typeof value !== "object" || Number(value.schemaVersion) !== 5) { + const schemaVersion = Number(value?.schemaVersion); + if (!value || typeof value !== "object" || schemaVersion !== 5 && schemaVersion !== 6) { throw apiError("Save snapshot is invalid."); } if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request."); if (typeof value.hunterName !== "string" || !value.hunterName.trim()) { throw apiError("Save snapshot has no hunter name."); } - return value; + return { ...value, schemaVersion: 6 }; } function normalizeNonNegativeInteger(value) { @@ -195,6 +196,60 @@ function normalizeNonNegativeInteger(value) { return Number.isFinite(number) ? Math.max(0, number) : 0; } +function normalizeNonNegativeNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : 0; +} + +function mergeLeaderboardHighWater(database, accountId, slotId, save) { + const blockbreaker = database.prepare(` + SELECT highest_bricks AS highestBricks, longest_seconds AS longestSeconds, highest_score AS highestScore + FROM blockbreaker_records WHERE account_id = ? AND slot_id = ? + `).get(accountId, slotId); + const aether = database.prepare(` + SELECT highest_score AS highestScore, wave_at_best AS waveAtBest, duration_at_best AS durationAtBest + FROM aether_assault_records WHERE account_id = ? AND slot_id = ? + `).get(accountId, slotId); + const stats = save.stats && typeof save.stats === "object" ? save.stats : {}; + const candidateAether = { + score: normalizeNonNegativeInteger(stats.highestAetherAssaultScore), + wave: normalizeNonNegativeInteger(stats.highestAetherAssaultWaveAtBest), + duration: normalizeNonNegativeNumber(stats.longestAetherAssaultSecondsAtBest), + }; + const storedAether = { + score: normalizeNonNegativeInteger(aether?.highestScore), + wave: normalizeNonNegativeInteger(aether?.waveAtBest), + duration: normalizeNonNegativeNumber(aether?.durationAtBest), + }; + const aetherRecord = candidateAether.score > storedAether.score + || candidateAether.score === storedAether.score && candidateAether.wave > storedAether.wave + || candidateAether.score === storedAether.score && candidateAether.wave === storedAether.wave && candidateAether.duration > storedAether.duration + ? candidateAether + : storedAether; + return { + ...save, + schemaVersion: 6, + stats: { + ...stats, + highestBlockbreakerBricks: Math.max( + normalizeNonNegativeInteger(stats.highestBlockbreakerBricks), + normalizeNonNegativeInteger(blockbreaker?.highestBricks), + ), + longestBlockbreakerSeconds: Math.max( + normalizeNonNegativeNumber(stats.longestBlockbreakerSeconds), + normalizeNonNegativeNumber(blockbreaker?.longestSeconds), + ), + highestBlockbreakerScore: Math.max( + normalizeNonNegativeInteger(stats.highestBlockbreakerScore), + normalizeNonNegativeInteger(blockbreaker?.highestScore), + ), + highestAetherAssaultScore: aetherRecord.score, + highestAetherAssaultWaveAtBest: aetherRecord.wave, + longestAetherAssaultSecondsAtBest: aetherRecord.duration, + }, + }; +} + function syncLeaderboardStats(database, accountId, slotId, save) { database.prepare("DELETE FROM boss_kill_records WHERE account_id = ? AND slot_id = ?").run(accountId, slotId); const insertBoss = database.prepare(` @@ -225,14 +280,95 @@ function syncLeaderboardStats(database, accountId, slotId, save) { highest_boss_kills = excluded.highest_boss_kills, updated_at = CURRENT_TIMESTAMP `).run(accountId, slotId, highestEndlessKills); + const highestHockeyReturns = normalizeNonNegativeInteger(save.stats?.highestHockeyHealingReturns); + const hockeyDurationSeconds = normalizeNonNegativeNumber(save.stats?.longestHockeyHealingSecondsAtBest); + database.prepare(` + INSERT INTO hockey_healing_records (account_id, slot_id, highest_returns, duration_seconds, updated_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(account_id, slot_id) DO UPDATE SET + highest_returns = excluded.highest_returns, + duration_seconds = excluded.duration_seconds, + updated_at = CURRENT_TIMESTAMP + `).run(accountId, slotId, highestHockeyReturns, hockeyDurationSeconds); + + const hockeyPvpWins = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpWins); + const hockeyPvpLosses = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpLosses); + const hockeyPvpBossKills = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpBossKills); + database.prepare(` + INSERT INTO hockey_pvp_records (account_id, slot_id, wins, losses, boss_kills, updated_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(account_id, slot_id) DO UPDATE SET + wins = excluded.wins, + losses = excluded.losses, + boss_kills = excluded.boss_kills, + updated_at = CURRENT_TIMESTAMP + `).run(accountId, slotId, hockeyPvpWins, hockeyPvpLosses, hockeyPvpBossKills); + + const highestBlockbreakerBricks = normalizeNonNegativeInteger(save.stats?.highestBlockbreakerBricks); + const longestBlockbreakerSeconds = normalizeNonNegativeNumber(save.stats?.longestBlockbreakerSeconds); + const highestBlockbreakerScore = normalizeNonNegativeInteger(save.stats?.highestBlockbreakerScore); + database.prepare(` + INSERT INTO blockbreaker_records ( + account_id, slot_id, + highest_bricks, bricks_achieved_at, + longest_seconds, time_achieved_at, + highest_score, score_achieved_at, + updated_at + ) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(account_id, slot_id) DO UPDATE SET + bricks_achieved_at = CASE + WHEN excluded.highest_bricks > blockbreaker_records.highest_bricks THEN CURRENT_TIMESTAMP + ELSE blockbreaker_records.bricks_achieved_at + END, + highest_bricks = MAX(blockbreaker_records.highest_bricks, excluded.highest_bricks), + time_achieved_at = CASE + WHEN excluded.longest_seconds > blockbreaker_records.longest_seconds THEN CURRENT_TIMESTAMP + ELSE blockbreaker_records.time_achieved_at + END, + longest_seconds = MAX(blockbreaker_records.longest_seconds, excluded.longest_seconds), + score_achieved_at = CASE + WHEN excluded.highest_score > blockbreaker_records.highest_score THEN CURRENT_TIMESTAMP + ELSE blockbreaker_records.score_achieved_at + END, + highest_score = MAX(blockbreaker_records.highest_score, excluded.highest_score), + updated_at = CURRENT_TIMESTAMP + `).run(accountId, slotId, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore); + + const highestAetherScore = normalizeNonNegativeInteger(save.stats?.highestAetherAssaultScore); + const aetherWaveAtBest = normalizeNonNegativeInteger(save.stats?.highestAetherAssaultWaveAtBest); + const aetherDurationAtBest = normalizeNonNegativeNumber(save.stats?.longestAetherAssaultSecondsAtBest); + database.prepare(` + INSERT INTO aether_assault_records ( + account_id, slot_id, highest_score, wave_at_best, duration_at_best, score_achieved_at, updated_at + ) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(account_id, slot_id) DO UPDATE SET + highest_score = excluded.highest_score, + wave_at_best = excluded.wave_at_best, + duration_at_best = excluded.duration_at_best, + score_achieved_at = CASE + WHEN excluded.highest_score > aether_assault_records.highest_score + OR excluded.highest_score = aether_assault_records.highest_score + AND excluded.wave_at_best > aether_assault_records.wave_at_best + THEN CURRENT_TIMESTAMP + ELSE aether_assault_records.score_achieved_at + END, + updated_at = CURRENT_TIMESTAMP + WHERE excluded.highest_score > aether_assault_records.highest_score + OR excluded.highest_score = aether_assault_records.highest_score + AND excluded.wave_at_best > aether_assault_records.wave_at_best + OR excluded.highest_score = aether_assault_records.highest_score + AND excluded.wave_at_best = aether_assault_records.wave_at_best + AND excluded.duration_at_best > aether_assault_records.duration_at_best + `).run(accountId, slotId, highestAetherScore, aetherWaveAtBest, aetherDurationAtBest); } function writeSave(database, accountId, slotId, rawSave) { - const save = validateSave(rawSave, slotId); - const serialized = JSON.stringify(save); - if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413); + const validatedSave = validateSave(rawSave, slotId); database.exec("BEGIN IMMEDIATE"); try { + const save = mergeLeaderboardHighWater(database, accountId, slotId, validatedSave); + const serialized = JSON.stringify(save); + if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413); database.prepare(` INSERT INTO hunter_saves (account_id, slot_id, hunter_name, save_json, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) @@ -243,11 +379,11 @@ function writeSave(database, accountId, slotId, rawSave) { `).run(accountId, slotId, save.hunterName.trim().slice(0, 20), serialized); syncLeaderboardStats(database, accountId, slotId, save); database.exec("COMMIT"); + return save; } catch (error) { database.exec("ROLLBACK"); throw error; } - return save; } function readSave(database, accountId, slotId) { @@ -255,7 +391,12 @@ function readSave(database, accountId, slotId) { SELECT save_json AS saveJson FROM hunter_saves WHERE account_id = ? AND slot_id = ? `).get(accountId, slotId); if (!row) return null; - try { return JSON.parse(row.saveJson); } catch { return null; } + try { + const save = JSON.parse(row.saveJson); + return mergeLeaderboardHighWater(database, accountId, slotId, save); + } catch { + return null; + } } function listSaves(database, accountId) { @@ -263,18 +404,22 @@ function listSaves(database, accountId) { SELECT slot_id AS slotId, save_json AS saveJson, updated_at AS updatedAt FROM hunter_saves WHERE account_id = ? ORDER BY slot_id `).all(accountId).flatMap((row) => { - try { return [{ slotId: row.slotId, save: JSON.parse(row.saveJson), updatedAt: row.updatedAt }]; } + try { + const save = mergeLeaderboardHighWater(database, accountId, row.slotId, JSON.parse(row.saveJson)); + return [{ slotId: row.slotId, save, updatedAt: row.updatedAt }]; + } catch { return []; } }); } -function leaderboardEntry(row, valueKey) { +function leaderboardEntry(row, valueKey, secondaryValueKey) { return { rank: row.rank, username: row.username, hunterName: row.hunterName, slotId: row.slotId, value: row[valueKey], + ...(secondaryValueKey ? { secondaryValue: row[secondaryValueKey] } : {}), }; } @@ -358,11 +503,224 @@ function rogueTrialsEndlessLeaderboard(database, accountId, slotId) { }; } +function hockeyHealingLeaderboard(database, accountId, slotId) { + const rows = database.prepare(` + WITH ranked AS ( + SELECT + RANK() OVER (ORDER BY records.highest_returns DESC, records.duration_seconds DESC) AS rank, + records.account_id AS accountId, + records.slot_id AS slotId, + records.highest_returns AS highestReturns, + records.duration_seconds AS durationSeconds, + accounts.username, + saves.hunter_name AS hunterName, + records.updated_at AS updatedAt + FROM hockey_healing_records records + JOIN accounts ON accounts.id = records.account_id + JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id + WHERE records.highest_returns > 0 OR records.duration_seconds > 0 + ) + SELECT * FROM ranked + ORDER BY highestReturns DESC, durationSeconds DESC, updatedAt ASC, accountId ASC, slotId ASC + `).all(); + const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; + return { + kind: "hockey-healing", + top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestReturns", "durationSeconds")), + current: current ? leaderboardEntry(current, "highestReturns", "durationSeconds") : null, + }; +} + +function hockeyPvpLeaderboard(database, accountId, slotId, kind) { + const winsBoard = kind === "hockey-pvp-wins"; + const order = winsBoard + ? "records.wins DESC, records.losses ASC" + : "records.boss_kills DESC, records.wins DESC"; + const rows = database.prepare(` + WITH ranked AS ( + SELECT + RANK() OVER (ORDER BY ${order}) AS rank, + records.account_id AS accountId, + records.slot_id AS slotId, + records.wins, + records.losses, + records.boss_kills AS bossKills, + accounts.username, + saves.hunter_name AS hunterName, + records.updated_at AS updatedAt + FROM hockey_pvp_records records + JOIN accounts ON accounts.id = records.account_id + JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id + WHERE ${winsBoard ? "records.wins > 0 OR records.losses > 0" : "records.boss_kills > 0"} + ) + SELECT * FROM ranked + ORDER BY ${winsBoard ? "wins DESC, losses ASC" : "bossKills DESC, wins DESC"}, updatedAt ASC, accountId ASC, slotId ASC + `).all(); + const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; + const valueKey = winsBoard ? "wins" : "bossKills"; + const secondaryKey = winsBoard ? "losses" : undefined; + return { + kind, + top: rows.slice(0, 5).map((row) => leaderboardEntry(row, valueKey, secondaryKey)), + current: current ? leaderboardEntry(current, valueKey, secondaryKey) : null, + }; +} + +function blockbreakerLeaderboard(database, accountId, slotId, kind) { + const boards = { + "blockbreaker-bricks": { column: "highest_bricks", valueKey: "highestBricks", achieved: "bricks_achieved_at" }, + "blockbreaker-time": { column: "longest_seconds", valueKey: "longestSeconds", achieved: "time_achieved_at" }, + "blockbreaker-score": { column: "highest_score", valueKey: "highestScore", achieved: "score_achieved_at" }, + }; + const board = boards[kind]; + if (!board) throw apiError("Invalid Blockbreaker leaderboard."); + const rows = database.prepare(` + WITH ranked AS ( + SELECT + RANK() OVER (ORDER BY records.${board.column} DESC) AS rank, + records.account_id AS accountId, + records.slot_id AS slotId, + records.${board.column} AS ${board.valueKey}, + records.${board.achieved} AS achievedAt, + accounts.username, + saves.hunter_name AS hunterName + FROM blockbreaker_records records + JOIN accounts ON accounts.id = records.account_id + JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id + WHERE records.${board.column} > 0 + ) + SELECT * FROM ranked + ORDER BY ${board.valueKey} DESC, achievedAt ASC, accountId ASC, slotId ASC + `).all(); + const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; + return { + kind, + top: rows.slice(0, 5).map((row) => leaderboardEntry(row, board.valueKey)), + current: current ? leaderboardEntry(current, board.valueKey) : null, + }; +} + +function aetherAssaultLeaderboard(database, accountId, slotId) { + const rows = database.prepare(` + WITH ranked AS ( + SELECT + RANK() OVER ( + ORDER BY records.highest_score DESC, records.wave_at_best DESC, + records.score_achieved_at ASC, records.account_id ASC, records.slot_id ASC + ) AS rank, + records.account_id AS accountId, + records.slot_id AS slotId, + records.highest_score AS highestScore, + records.wave_at_best AS waveAtBest, + records.score_achieved_at AS achievedAt, + accounts.username, + saves.hunter_name AS hunterName + FROM aether_assault_records records + JOIN accounts ON accounts.id = records.account_id + JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id + WHERE records.highest_score > 0 + ) + SELECT * FROM ranked + ORDER BY highestScore DESC, waveAtBest DESC, achievedAt ASC, accountId ASC, slotId ASC + `).all(); + const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; + return { + kind: "aether-assault", + top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestScore", "waveAtBest")), + current: current ? leaderboardEntry(current, "highestScore", "waveAtBest") : null, + }; +} + export function createGameApiHandler(options = {}) { const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data"); mkdirSync(dataDirectory, { recursive: true }); const database = new DatabaseSync(resolve(dataDirectory, "game.db")); database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8")); + const hockeyPvpTickets = new Map(); + const hockeyPvpMatches = new Map(); + + function queueResult(ticket) { + const match = ticket.matchId ? hockeyPvpMatches.get(ticket.matchId) : null; + if (!match) return { ticketId: ticket.id, status: "waiting" }; + const opponentSide = ticket.side === "host" ? "guest" : "host"; + const opponent = match.players[opponentSide]; + return { + ticketId: ticket.id, + status: "matched", + match: { + id: match.id, + seed: match.seed, + opponentName: opponent.hunterName, + role: ticket.side, + }, + }; + } + + function joinHockeyPvpQueue(session, payload) { + const slotId = validateSlotId(payload?.slotId); + const hunterName = String(payload?.hunterName ?? "").trim().slice(0, 20); + if (!hunterName) throw apiError("Hunter name is required."); + const existing = [...hockeyPvpTickets.values()].find((ticket) => + ticket.accountId === session.accountId && !ticket.matchId && !ticket.cancelled && !ticket.completed); + if (existing) return queueResult(existing); + + const now = Date.now(); + for (const ticket of hockeyPvpTickets.values()) { + if (!ticket.matchId && now - ticket.createdAt > 30_000) { + ticket.cancelled = true; + ticket.completed = true; + } + } + const opponent = [...hockeyPvpTickets.values()].find((ticket) => + !ticket.matchId && !ticket.cancelled && !ticket.completed && ticket.accountId !== session.accountId); + const ticket = { + id: randomBytes(18).toString("base64url"), + accountId: session.accountId, + username: session.username, + slotId, + hunterName, + createdAt: now, + matchId: null, + side: null, + cancelled: false, + completed: false, + }; + hockeyPvpTickets.set(ticket.id, ticket); + if (!opponent) return queueResult(ticket); + + const matchId = randomBytes(18).toString("base64url"); + const match = { + id: matchId, + seed: randomBytes(4).readUInt32BE(0) || 1, + createdAt: now, + players: { host: opponent, guest: ticket }, + snapshots: { host: null, guest: null }, + }; + opponent.matchId = matchId; + opponent.side = "host"; + ticket.matchId = matchId; + ticket.side = "guest"; + hockeyPvpMatches.set(matchId, match); + return queueResult(ticket); + } + + function requireHockeyPvpTicket(session, ticketId) { + const ticket = hockeyPvpTickets.get(ticketId); + if (!ticket || ticket.accountId !== session.accountId || ticket.cancelled) throw apiError("Queue ticket not found.", 404); + return ticket; + } + + function requireHockeyPvpMatch(session, matchId) { + const match = hockeyPvpMatches.get(matchId); + if (!match) throw apiError("PVP match not found.", 404); + const side = match.players.host.accountId === session.accountId + ? "host" + : match.players.guest.accountId === session.accountId + ? "guest" + : null; + if (!side) throw apiError("PVP match access denied.", 403); + return { match, side }; + } async function handle(request, response, next) { if (!request.url?.startsWith("/api/")) return next(); @@ -400,6 +758,31 @@ export function createGameApiHandler(options = {}) { } const session = requireSession(database, request); + if (path === "/api/hockey-pvp/queue" && request.method === "POST") { + return sendJson(response, 200, joinHockeyPvpQueue(session, await readJson(request))); + } + const queueMatch = path.match(/^\/api\/hockey-pvp\/queue\/([A-Za-z0-9_-]+)$/); + if (queueMatch && request.method === "GET") { + return sendJson(response, 200, queueResult(requireHockeyPvpTicket(session, queueMatch[1]))); + } + if (queueMatch && request.method === "DELETE") { + const ticket = requireHockeyPvpTicket(session, queueMatch[1]); + if (ticket.matchId) throw apiError("Matched queue cannot be cancelled.", 409); + ticket.cancelled = true; + ticket.completed = true; + return sendJson(response, 200, { ok: true }); + } + const pvpStateMatch = path.match(/^\/api\/hockey-pvp\/matches\/([A-Za-z0-9_-]+)\/state$/); + if (pvpStateMatch && request.method === "PUT") { + const { match, side } = requireHockeyPvpMatch(session, pvpStateMatch[1]); + const payload = await readJson(request); + if (!payload?.snapshot || typeof payload.snapshot !== "object") throw apiError("PVP snapshot is invalid."); + match.snapshots[side] = payload.snapshot; + return sendJson(response, 200, { + opponentSnapshot: match.snapshots[side === "host" ? "guest" : "host"], + hostSnapshot: match.snapshots.host, + }); + } if (path === "/api/saves" && request.method === "GET") { return sendJson(response, 200, { slots: listSaves(database, session.accountId) }); } @@ -425,6 +808,34 @@ export function createGameApiHandler(options = {}) { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, rogueTrialsEndlessLeaderboard(database, session.accountId, slotId)); } + if (path === "/api/leaderboards/hockey-healing" && request.method === "GET") { + const slotId = validateSlotId(url.searchParams.get("slot")); + return sendJson(response, 200, hockeyHealingLeaderboard(database, session.accountId, slotId)); + } + if (path === "/api/leaderboards/hockey-pvp-wins" && request.method === "GET") { + const slotId = validateSlotId(url.searchParams.get("slot")); + return sendJson(response, 200, hockeyPvpLeaderboard(database, session.accountId, slotId, "hockey-pvp-wins")); + } + if (path === "/api/leaderboards/hockey-pvp-boss-kills" && request.method === "GET") { + const slotId = validateSlotId(url.searchParams.get("slot")); + return sendJson(response, 200, hockeyPvpLeaderboard(database, session.accountId, slotId, "hockey-pvp-boss-kills")); + } + if (path === "/api/leaderboards/blockbreaker-bricks" && request.method === "GET") { + const slotId = validateSlotId(url.searchParams.get("slot")); + return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-bricks")); + } + if (path === "/api/leaderboards/blockbreaker-time" && request.method === "GET") { + const slotId = validateSlotId(url.searchParams.get("slot")); + return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-time")); + } + if (path === "/api/leaderboards/blockbreaker-score" && request.method === "GET") { + const slotId = validateSlotId(url.searchParams.get("slot")); + return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-score")); + } + if (path === "/api/leaderboards/aether-assault" && request.method === "GET") { + const slotId = validateSlotId(url.searchParams.get("slot")); + return sendJson(response, 200, aetherAssaultLeaderboard(database, session.accountId, slotId)); + } return sendJson(response, 404, { error: "API route not found." }); } catch (error) { const status = Number(error?.status) || 500; @@ -434,5 +845,12 @@ export function createGameApiHandler(options = {}) { } } - return { handle, close: () => database.close() }; + return { + handle, + close: () => { + hockeyPvpTickets.clear(); + hockeyPvpMatches.clear(); + database.close(); + }, + }; } diff --git a/server/game-api.test.mjs b/server/game-api.test.mjs index e4015bb..afefb26 100644 --- a/server/game-api.test.mjs +++ b/server/game-api.test.mjs @@ -34,12 +34,12 @@ async function json(path, init = {}) { return { response, body }; } -function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills) { +function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins = 0, hockeyHealingPvpLosses = 0, hockeyHealingPvpBossKills = 0, highestBlockbreakerBricks = 0, longestBlockbreakerSeconds = 0, highestBlockbreakerScore = 0, highestAetherAssaultScore = 0, highestAetherAssaultWaveAtBest = 0, longestAetherAssaultSecondsAtBest = 0) { return { - schemaVersion: 5, + schemaVersion: 6, slotId, hunterName, - stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills }, + stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins, hockeyHealingPvpLosses, hockeyHealingPvpBossKills, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore, highestAetherAssaultScore, highestAetherAssaultWaveAtBest, longestAetherAssaultSecondsAtBest }, }; } @@ -63,13 +63,24 @@ test("accounts, server saves, and top-five plus current rankings work end to end const kills = 60 - index * 10; const highestRound = 30 - index * 4; const highestEndlessKills = 24 - index * 3; + const highestHockeyReturns = 30 - index * 4; + const hockeyDuration = 180 - index * 10; + const pvpWins = 30 - index * 4; + const pvpLosses = index + 1; + const pvpBossKills = 120 - index * 12; + const blockbreakerBricks = index < 2 ? 600 : 700 - index * 100; + const blockbreakerSeconds = 360 - index * 30; + const blockbreakerScore = 20_000 - index * 2_000; + const aetherScore = index < 2 ? 50_000 : 54_000 - index * 5_000; + const aetherWave = index < 2 ? 12 : 10 - index; + const aetherDuration = 300 - index * 20; const upload = await json("/api/saves/1", { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills) }), + body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration) }), }); assert.equal(upload.response.status, 200); - players.push({ token, kills, highestRound, highestEndlessKills }); + players.push({ token, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration }); } const current = players[5]; @@ -97,12 +108,150 @@ test("accounts, server saves, and top-five plus current rankings work end to end assert.equal(endlessBoard.body.current.rank, 6); assert.equal(endlessBoard.body.current.value, current.highestEndlessKills); + const hockeyBoard = await json("/api/leaderboards/hockey-healing?slot=1", { + headers: { Authorization: `Bearer ${current.token}` }, + }); + assert.equal(hockeyBoard.body.kind, "hockey-healing"); + assert.equal(hockeyBoard.body.top.length, 5); + assert.equal(hockeyBoard.body.top[0].value, 30); + assert.equal(hockeyBoard.body.current.rank, 6); + assert.equal(hockeyBoard.body.current.value, current.highestHockeyReturns); + assert.equal(hockeyBoard.body.current.secondaryValue, current.hockeyDuration); + + const pvpWinsBoard = await json("/api/leaderboards/hockey-pvp-wins?slot=1", { + headers: { Authorization: `Bearer ${current.token}` }, + }); + assert.equal(pvpWinsBoard.body.kind, "hockey-pvp-wins"); + assert.equal(pvpWinsBoard.body.top[0].value, 30); + assert.equal(pvpWinsBoard.body.current.value, current.pvpWins); + assert.equal(pvpWinsBoard.body.current.secondaryValue, current.pvpLosses); + + const pvpKillsBoard = await json("/api/leaderboards/hockey-pvp-boss-kills?slot=1", { + headers: { Authorization: `Bearer ${current.token}` }, + }); + assert.equal(pvpKillsBoard.body.kind, "hockey-pvp-boss-kills"); + assert.equal(pvpKillsBoard.body.top[0].value, 120); + assert.equal(pvpKillsBoard.body.current.value, current.pvpBossKills); + + const blockbreakerBricksBoard = await json("/api/leaderboards/blockbreaker-bricks?slot=1", { + headers: { Authorization: `Bearer ${current.token}` }, + }); + assert.equal(blockbreakerBricksBoard.body.kind, "blockbreaker-bricks"); + assert.equal(blockbreakerBricksBoard.body.top.length, 5); + assert.equal(blockbreakerBricksBoard.body.top[0].value, 600); + assert.equal(blockbreakerBricksBoard.body.top[0].rank, 1); + assert.equal(blockbreakerBricksBoard.body.top[1].rank, 1); + assert.equal(blockbreakerBricksBoard.body.current.rank, 6); + assert.equal(blockbreakerBricksBoard.body.current.value, current.blockbreakerBricks); + + const blockbreakerTimeBoard = await json("/api/leaderboards/blockbreaker-time?slot=1", { + headers: { Authorization: `Bearer ${current.token}` }, + }); + assert.equal(blockbreakerTimeBoard.body.kind, "blockbreaker-time"); + assert.equal(blockbreakerTimeBoard.body.top[0].value, 360); + assert.equal(blockbreakerTimeBoard.body.current.value, current.blockbreakerSeconds); + + const blockbreakerScoreBoard = await json("/api/leaderboards/blockbreaker-score?slot=1", { + headers: { Authorization: `Bearer ${current.token}` }, + }); + assert.equal(blockbreakerScoreBoard.body.kind, "blockbreaker-score"); + assert.equal(blockbreakerScoreBoard.body.top[0].value, 20_000); + assert.equal(blockbreakerScoreBoard.body.current.value, current.blockbreakerScore); + + const aetherBoard = await json("/api/leaderboards/aether-assault?slot=1", { + headers: { Authorization: `Bearer ${current.token}` }, + }); + assert.equal(aetherBoard.body.kind, "aether-assault"); + assert.equal(aetherBoard.body.top.length, 5); + assert.equal(aetherBoard.body.top[0].value, 50_000); + assert.equal(aetherBoard.body.top[0].secondaryValue, 12); + assert.equal(aetherBoard.body.top[0].username, "hunter_0"); + assert.equal(aetherBoard.body.top[0].rank, 1); + assert.equal(aetherBoard.body.top[1].value, 50_000); + assert.equal(aetherBoard.body.top[1].secondaryValue, 12); + assert.equal(aetherBoard.body.top[1].username, "hunter_1"); + assert.equal(aetherBoard.body.top[1].rank, 2); + assert.equal(aetherBoard.body.current.rank, 6); + assert.equal(aetherBoard.body.current.value, current.aetherScore); + assert.equal(aetherBoard.body.current.secondaryValue, current.aetherWave); + + const legacySnapshot = save(1, "Hero 5", current.kills, current.highestRound, current.highestEndlessKills, current.highestHockeyReturns, current.hockeyDuration, current.pvpWins, current.pvpLosses, current.pvpBossKills); + delete legacySnapshot.stats.highestBlockbreakerBricks; + delete legacySnapshot.stats.longestBlockbreakerSeconds; + delete legacySnapshot.stats.highestBlockbreakerScore; + delete legacySnapshot.stats.highestAetherAssaultScore; + delete legacySnapshot.stats.highestAetherAssaultWaveAtBest; + delete legacySnapshot.stats.longestAetherAssaultSecondsAtBest; + legacySnapshot.schemaVersion = 5; + const legacyUpload = await json("/api/saves/1", { + method: "PUT", + headers: { Authorization: `Bearer ${current.token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ save: legacySnapshot }), + }); + assert.equal(legacyUpload.body.save.stats.highestBlockbreakerBricks, current.blockbreakerBricks); + assert.equal(legacyUpload.body.save.stats.longestBlockbreakerSeconds, current.blockbreakerSeconds); + assert.equal(legacyUpload.body.save.stats.highestBlockbreakerScore, current.blockbreakerScore); + assert.equal(legacyUpload.body.save.schemaVersion, 6); + assert.equal(legacyUpload.body.save.stats.highestAetherAssaultScore, current.aetherScore); + assert.equal(legacyUpload.body.save.stats.highestAetherAssaultWaveAtBest, current.aetherWave); + assert.equal(legacyUpload.body.save.stats.longestAetherAssaultSecondsAtBest, current.aetherDuration); + const download = await json("/api/saves/1", { headers: { Authorization: `Bearer ${current.token}` }, }); assert.equal(download.body.save.hunterName, "Hero 5"); }); +test("Healing Hockey PVP queue pairs players and relays match snapshots", async () => { + const registerPlayer = async (username) => { + const registration = await json("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password: `long-password-${username}` }), + }); + return registration.body.token; + }; + const alphaToken = await registerPlayer("pvp_alpha"); + const betaToken = await registerPlayer("pvp_beta"); + const alphaQueue = await json("/api/hockey-pvp/queue", { + method: "POST", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ slotId: 1, hunterName: "Alpha" }), + }); + assert.equal(alphaQueue.body.status, "waiting"); + + const betaQueue = await json("/api/hockey-pvp/queue", { + method: "POST", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ slotId: 1, hunterName: "Beta" }), + }); + assert.equal(betaQueue.body.status, "matched"); + assert.equal(betaQueue.body.match.role, "guest"); + assert.equal(betaQueue.body.match.opponentName, "Alpha"); + + const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, { + headers: { Authorization: `Bearer ${alphaToken}` }, + }); + assert.equal(alphaMatched.body.status, "matched"); + assert.equal(alphaMatched.body.match.role, "host"); + assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id); + assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed); + + const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } }; + await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ snapshot: hostSnapshot }), + }); + const guestExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, { + method: "PUT", + headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ snapshot: { sequence: 1, party: [] } }), + }); + assert.deepEqual(guestExchange.body.opponentSnapshot, hostSnapshot); + assert.deepEqual(guestExchange.body.hostSnapshot, hostSnapshot); +}); + test("invalid credentials cannot access server saves", async () => { const login = await json("/api/auth/login", { method: "POST", diff --git a/src/App.tsx b/src/App.tsx index 9d9c5f7..95b204a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,15 +3,19 @@ import packageJson from "../package.json"; import { DualDisplayFrame } from "./components/DualDisplayFrame"; import { FrontEnd } from "./components/FrontEnd"; import { useActiveHunter, useFrontendStore } from "./frontend/store"; -import { useGameStore } from "./game/store"; +import { getHockeyPvpNetworkSnapshot, useGameStore } from "./game/store"; import type { BossId } from "./game/types"; import type { DifficultySlug } from "./game/progression/loot"; import { useActionBindings } from "./game/useGameLoop"; import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen"; import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync"; +import { startSaveSyncCoordinator } from "./frontend/saveSync"; +import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp"; +import { onlineRepository } from "./frontend/onlineRepository"; const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen }))); const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); +const HealerModelGallery = lazy(() => import("./components/GameScene").then((module) => ({ default: module.HealerModelGallery }))); function GameLoadingScreen() { return ( @@ -22,10 +26,11 @@ function GameLoadingScreen() { ); } -export default function App() { +function MainApp() { useForcedThorDisplays(); useAuthoritativeDualScreenSync(); const screen = useFrontendStore((state) => state.screen); + const accountId = useFrontendStore((state) => state.accountId); const hunter = useActiveHunter(); const settings = useFrontendStore((state) => state.settings); const navigate = useFrontendStore((state) => state.navigate); @@ -34,36 +39,65 @@ export default function App() { const recordBossVictory = useFrontendStore((state) => state.recordBossVictory); const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat); const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat); + const recordHockeyHealingDefeat = useFrontendStore((state) => state.recordHockeyHealingDefeat); + const recordHockeyPvpResult = useFrontendStore((state) => state.recordHockeyPvpResult); + const recordHockeyPvpBossKill = useFrontendStore((state) => state.recordHockeyPvpBossKill); + const recordBlockbreakerDefeat = useFrontendStore((state) => state.recordBlockbreakerDefeat); + const recordAetherAssaultDefeat = useFrontendStore((state) => state.recordAetherAssaultDefeat); const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards); const rewardedBossInstances = useRef(new Set()); const screenRef = useRef(screen); screenRef.current = screen; const leaveGame = useCallback(() => { const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState(); - updateActiveHealerInventory(useGameStore.getState().inventory); + const game = useGameStore.getState(); + // RPG Roguelike equipment belongs only to its current run. Never leak it + // into the hunter's permanent inventory when leaving the expedition. + if (game.runMode !== "rpg-roguelike") updateActiveHealerInventory(game.inventory); touchActiveSave(); navigate("home"); if (accountId && activeSlotId) void uploadSlot(activeSlotId); }, [navigate, touchActiveSave, updateActiveHealerInventory]); - const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => { + const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => { if (!hunter) return; const progress = hunter.healers[hunter.activeClassId]; const selectedMode = useFrontendStore.getState().selectedMode; - const runMode = selectedMode === "roguelike-pve" ? "roguelike" : selectedMode === "rogue-trials" ? "rogue-trials" : "encounter"; + const runMode = selectedMode === "roguelike-pve" + ? "rpg-roguelike" + : selectedMode === "rogue-trials" + ? "rogue-trials" + : selectedMode === "hockey-healing" + ? "hockey-healing" + : selectedMode === "hockey-healing-pvp" + ? "hockey-healing-pvp" + : selectedMode === "blockbreaker" + ? "blockbreaker" + : selectedMode === "aether-assault" + ? "aether-assault" + : "encounter"; const launchDifficulty = runMode !== "encounter" ? "initiate" : requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug; rewardedBossInstances.current.clear(); clearRecentRewards(); - useGameStore.getState().configureHealer(hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds, runMode, hunter.gearProgress, launchDifficulty); + useGameStore.getState().configureHealer( + hunter.activeClassId, + hunter.hunterName, + progress.inventory, + bossIds, + runMode, + hunter.gearProgress, + launchDifficulty, + hockeyPvpMatch, + ); touchActiveSave(); navigate("game"); }, [clearRecentRewards, hunter, navigate, touchActiveSave]); useEffect(() => { const onDualScreenLaunch = (event: Event) => { - const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug } | readonly BossId[]>).detail; - if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug); + const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig } | readonly BossId[]>).detail; + if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug, detail.hockeyPvpMatch); else launchGame(detail); }; window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch); @@ -75,6 +109,44 @@ export default function App() { return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame); }, [leaveGame]); + useEffect(() => { + if (!accountId) return; + return startSaveSyncCoordinator((slotId) => useFrontendStore.getState().uploadSlot(slotId)); + }, [accountId]); + + useEffect(() => { + if (screen !== "game") return; + let stopped = false; + let exchangeActive = false; + const exchange = async () => { + if (stopped || exchangeActive) return; + const state = useGameStore.getState(); + if (state.runMode !== "hockey-healing-pvp" || !state.hockeyPvp.matchId || state.hockeyPvp.role === "cpu") return; + const snapshot = getHockeyPvpNetworkSnapshot(); + if (!snapshot) return; + exchangeActive = true; + try { + const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, snapshot); + if (!stopped && result.opponentSnapshot) { + useGameStore.getState().applyHockeyPvpRemoteSnapshot( + result.opponentSnapshot, + result.hostSnapshot?.puck, + ); + } + } catch { + // Last authoritative snapshot remains playable through short network gaps. + } finally { + exchangeActive = false; + } + }; + void exchange(); + const timer = window.setInterval(() => { void exchange(); }, 120); + return () => { + stopped = true; + window.clearInterval(timer); + }; + }, [screen]); + useActionBindings(screen === "game", leaveGame); useEffect(() => { @@ -97,9 +169,31 @@ export default function App() { if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") { recordRoguelikeDefeat(state.round); } - if (state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") { + if (state.runMode === "rpg-roguelike" + && (state.phase === "defeat" || state.phase === "victory") + && state.phase !== previousState.phase) { + recordRoguelikeDefeat(Math.max(1, state.rpgRun?.bossesDefeated ?? 0)); + } + if (state.runMode === "rogue-trials" && state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") { recordRogueTrialsEndlessDefeat(state.endlessBossKills); } + if (state.runMode === "hockey-healing" && state.phase === "defeat" && previousState.phase !== "defeat") { + recordHockeyHealingDefeat(state.hockey.returns, state.time); + } + if (state.runMode === "hockey-healing-pvp" + && (state.phase === "victory" || state.phase === "defeat") + && state.phase !== previousState.phase) { + recordHockeyPvpResult(state.phase === "victory"); + } + if (state.runMode === "blockbreaker" && state.phase === "defeat" && previousState.phase !== "defeat") { + recordBlockbreakerDefeat(state.blockbreaker.bricksBroken, state.time, state.blockbreaker.score); + } + if (state.runMode === "aether-assault" && state.phase === "defeat" && previousState.phase !== "defeat") { + recordAetherAssaultDefeat(state.aetherAssault.score, state.aetherAssault.wave, state.time); + } + // RPG rewards are generated inside the run reducer. Permanent boss loot + // here would duplicate its chest and break run-only progression. + if (state.runMode === "rpg-roguelike") return; const bossCount = 1 + state.additionalBosses.length; if (state.boss.hp <= 0 && previousState.boss.hp > 0) { const primaryInstanceId = state.bossInstanceId; @@ -108,6 +202,7 @@ export default function App() { const defeatedBefore = (state.round - 1) * bossCount; const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug; recordBossVictory(state.boss.id, rewardDifficulty); + if (state.runMode === "hockey-healing-pvp") recordHockeyPvpBossKill(); } } for (let index = 0; index < state.additionalBosses.length; index += 1) { @@ -121,7 +216,7 @@ export default function App() { recordBossVictory(entry.boss.id, rewardDifficulty); } }); - }, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]); + }, [clearRecentRewards, recordAetherAssaultDefeat, recordBlockbreakerDefeat, recordBossVictory, recordHockeyHealingDefeat, recordHockeyPvpBossKill, recordHockeyPvpResult, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]); return (
@@ -130,8 +225,15 @@ export default function App() {

Offline-first healer roguelike v{packageJson.version}

{screen === "game" - ? }>} bottom={} /> + ? }>} bottom={} /> : }
); } + +export default function App() { + if (import.meta.env.DEV && new URLSearchParams(window.location.search).get("preview") === "healer-models") { + return ; + } + return ; +} diff --git a/src/assets/game/models/claudecraft/weapons/adv_axe_1handed.glb b/src/assets/game/models/claudecraft/weapons/adv_axe_1handed.glb new file mode 100644 index 0000000..2da3b35 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/adv_axe_1handed.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/adv_axe_2handed.glb b/src/assets/game/models/claudecraft/weapons/adv_axe_2handed.glb new file mode 100644 index 0000000..eb3da1e Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/adv_axe_2handed.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/adv_staff.glb b/src/assets/game/models/claudecraft/weapons/adv_staff.glb new file mode 100644 index 0000000..f8a3948 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/adv_staff.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/adv_sword_2handed.glb b/src/assets/game/models/claudecraft/weapons/adv_sword_2handed.glb new file mode 100644 index 0000000..8e3397d Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/adv_sword_2handed.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/adv_sword_2handed_color.glb b/src/assets/game/models/claudecraft/weapons/adv_sword_2handed_color.glb new file mode 100644 index 0000000..5050cec Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/adv_sword_2handed_color.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/axe_1handed.glb b/src/assets/game/models/claudecraft/weapons/axe_1handed.glb new file mode 100644 index 0000000..9f7023e Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/axe_1handed.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/axe_2handed.glb b/src/assets/game/models/claudecraft/weapons/axe_2handed.glb new file mode 100644 index 0000000..01e9474 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/axe_2handed.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/axe_a.glb b/src/assets/game/models/claudecraft/weapons/axe_a.glb new file mode 100644 index 0000000..43e713b Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/axe_a.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/axe_b.glb b/src/assets/game/models/claudecraft/weapons/axe_b.glb new file mode 100644 index 0000000..528de9b Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/axe_b.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/axe_c.glb b/src/assets/game/models/claudecraft/weapons/axe_c.glb new file mode 100644 index 0000000..60cb683 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/axe_c.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/axe_d.glb b/src/assets/game/models/claudecraft/weapons/axe_d.glb new file mode 100644 index 0000000..80ca55e Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/axe_d.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/crossbow_1handed.glb b/src/assets/game/models/claudecraft/weapons/crossbow_1handed.glb new file mode 100644 index 0000000..a463d5e Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/crossbow_1handed.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/dagger.glb b/src/assets/game/models/claudecraft/weapons/dagger.glb new file mode 100644 index 0000000..4b0ea4b Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/dagger.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/dagger_a.glb b/src/assets/game/models/claudecraft/weapons/dagger_a.glb new file mode 100644 index 0000000..fa27dab Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/dagger_a.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/dagger_b.glb b/src/assets/game/models/claudecraft/weapons/dagger_b.glb new file mode 100644 index 0000000..069b08a Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/dagger_b.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/dagger_c.glb b/src/assets/game/models/claudecraft/weapons/dagger_c.glb new file mode 100644 index 0000000..ce5cf58 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/dagger_c.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/halberd.glb b/src/assets/game/models/claudecraft/weapons/halberd.glb new file mode 100644 index 0000000..fdcb041 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/halberd.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/hammer_a.glb b/src/assets/game/models/claudecraft/weapons/hammer_a.glb new file mode 100644 index 0000000..5dbb4cb Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/hammer_a.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/hammer_b.glb b/src/assets/game/models/claudecraft/weapons/hammer_b.glb new file mode 100644 index 0000000..67c844b Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/hammer_b.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/hammer_c.glb b/src/assets/game/models/claudecraft/weapons/hammer_c.glb new file mode 100644 index 0000000..e8b9c10 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/hammer_c.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/hammer_d.glb b/src/assets/game/models/claudecraft/weapons/hammer_d.glb new file mode 100644 index 0000000..a59ff67 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/hammer_d.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/quiver.glb b/src/assets/game/models/claudecraft/weapons/quiver.glb new file mode 100644 index 0000000..2f5afe0 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/quiver.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/scythe.glb b/src/assets/game/models/claudecraft/weapons/scythe.glb new file mode 100644 index 0000000..3b6fe1b Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/scythe.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/shield_round.glb b/src/assets/game/models/claudecraft/weapons/shield_round.glb new file mode 100644 index 0000000..c9855ba Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/shield_round.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/shield_square.glb b/src/assets/game/models/claudecraft/weapons/shield_square.glb new file mode 100644 index 0000000..1614b51 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/shield_square.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/skeleton_axe.glb b/src/assets/game/models/claudecraft/weapons/skeleton_axe.glb new file mode 100644 index 0000000..60de995 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/skeleton_axe.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/skeleton_blade.glb b/src/assets/game/models/claudecraft/weapons/skeleton_blade.glb new file mode 100644 index 0000000..9367df5 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/skeleton_blade.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/skeleton_crossbow.glb b/src/assets/game/models/claudecraft/weapons/skeleton_crossbow.glb new file mode 100644 index 0000000..8493a92 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/skeleton_crossbow.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/skeleton_shield_large_a.glb b/src/assets/game/models/claudecraft/weapons/skeleton_shield_large_a.glb new file mode 100644 index 0000000..bef369e Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/skeleton_shield_large_a.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/skeleton_staff.glb b/src/assets/game/models/claudecraft/weapons/skeleton_staff.glb new file mode 100644 index 0000000..1c702ba Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/skeleton_staff.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/spear_a.glb b/src/assets/game/models/claudecraft/weapons/spear_a.glb new file mode 100644 index 0000000..9f15883 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/spear_a.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/staff.glb b/src/assets/game/models/claudecraft/weapons/staff.glb new file mode 100644 index 0000000..ad4b3af Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/staff.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/staff_a.glb b/src/assets/game/models/claudecraft/weapons/staff_a.glb new file mode 100644 index 0000000..0cc0a33 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/staff_a.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/staff_b.glb b/src/assets/game/models/claudecraft/weapons/staff_b.glb new file mode 100644 index 0000000..9289805 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/staff_b.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/staff_c.glb b/src/assets/game/models/claudecraft/weapons/staff_c.glb new file mode 100644 index 0000000..ab03aeb Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/staff_c.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/staff_d.glb b/src/assets/game/models/claudecraft/weapons/staff_d.glb new file mode 100644 index 0000000..f1b2ab5 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/staff_d.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/sword_1handed.glb b/src/assets/game/models/claudecraft/weapons/sword_1handed.glb new file mode 100644 index 0000000..f450878 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/sword_1handed.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/sword_2handed.glb b/src/assets/game/models/claudecraft/weapons/sword_2handed.glb new file mode 100644 index 0000000..2262172 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/sword_2handed.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/sword_a.glb b/src/assets/game/models/claudecraft/weapons/sword_a.glb new file mode 100644 index 0000000..b9ae905 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/sword_a.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/sword_b.glb b/src/assets/game/models/claudecraft/weapons/sword_b.glb new file mode 100644 index 0000000..4ebc30e Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/sword_b.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/sword_c.glb b/src/assets/game/models/claudecraft/weapons/sword_c.glb new file mode 100644 index 0000000..d5cfd91 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/sword_c.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/sword_d.glb b/src/assets/game/models/claudecraft/weapons/sword_d.glb new file mode 100644 index 0000000..934a6a3 Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/sword_d.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/sword_e.glb b/src/assets/game/models/claudecraft/weapons/sword_e.glb new file mode 100644 index 0000000..dc85b3d Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/sword_e.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/sword_f.glb b/src/assets/game/models/claudecraft/weapons/sword_f.glb new file mode 100644 index 0000000..4307b8a Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/sword_f.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/sword_g.glb b/src/assets/game/models/claudecraft/weapons/sword_g.glb new file mode 100644 index 0000000..98e1f5c Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/sword_g.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/wand.glb b/src/assets/game/models/claudecraft/weapons/wand.glb new file mode 100644 index 0000000..afb3f4c Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/wand.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/wand_a.glb b/src/assets/game/models/claudecraft/weapons/wand_a.glb new file mode 100644 index 0000000..553cc5a Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/wand_a.glb differ diff --git a/src/assets/game/models/claudecraft/weapons/wand_b.glb b/src/assets/game/models/claudecraft/weapons/wand_b.glb new file mode 100644 index 0000000..4777dfa Binary files /dev/null and b/src/assets/game/models/claudecraft/weapons/wand_b.glb differ diff --git a/src/components/BossRoom.tsx b/src/components/BossRoom.tsx index 55a5cfa..ac149e6 100644 --- a/src/components/BossRoom.tsx +++ b/src/components/BossRoom.tsx @@ -3,9 +3,37 @@ import { useGLTF } from "@react-three/drei"; import { Component, Suspense, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react"; import * as THREE from "three"; import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena"; +import { + BLOCKBREAKER_BIOMES, + blockbreakerBiomeForSeed, + type BlockbreakerArenaBiome, + type BlockbreakerBiomeFixture, +} from "../game/blockbreakerBiomes"; import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms"; +import { + HOCKEY_ARENA_CENTER_Z, + HOCKEY_ARENA_LENGTH, + HOCKEY_ARENA_MAX_X, + HOCKEY_ARENA_MAX_Z, + HOCKEY_ARENA_MIN_X, + HOCKEY_ARENA_MIN_Z, + HOCKEY_ARENA_WIDTH, + HOCKEY_GOAL_HALF_WIDTH, + HOCKEY_HEALER_GOAL_Z, + HOCKEY_MIDLINE_Z, + HOCKEY_NPC_GOAL_Z, +} from "../game/hockeyHealing"; import { useGameStore } from "../game/store"; import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider"; +import { + HOCKEY_PVP_ARENA_MAX_X, + HOCKEY_PVP_ARENA_MAX_Z, + HOCKEY_PVP_ARENA_MIN_X, + HOCKEY_PVP_ARENA_MIN_Z, + HOCKEY_PVP_GOAL_HALF_WIDTH, + HOCKEY_PVP_GOAL_Z, +} from "../game/hockeyHealingPvp"; +import { RpgRoomPortals } from "./rpgRoguelike/RpgRoomPortals"; const ROOM_CENTER_Z = ARENA_CENTER[1]; const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href; @@ -170,15 +198,15 @@ function LegacyDungeonAssetInstances({ return ; } -function arenaWalls(room: BossRoomDefinition) { - return ARENA_WALL_SEGMENTS.map((fixture) => ({ +function arenaWalls(room: BossRoomDefinition, portalOpenings = false) { + return ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8).map((fixture) => ({ ...fixture, scaleY: room.wallHeight / 4, })); } -function LegacyArenaArchitecture({ room }: { room: BossRoomDefinition }) { - const walls = useMemo(() => arenaWalls(room), [room]); +function LegacyArenaArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) { + const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]); return ( @@ -196,9 +224,9 @@ function namedMesh(scene: THREE.Object3D, name: string) { throw new Error(`Dungeon kit is missing mesh ${name}.`); } -function DungeonKitArchitecture({ room }: { room: BossRoomDefinition }) { +function DungeonKitArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) { const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL); - const walls = useMemo(() => arenaWalls(room), [room]); + const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]); const meshes = useMemo(() => ({ pillar: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.pillar), wall: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.wall), @@ -232,9 +260,13 @@ class DungeonAssetErrorBoundary extends Component<{ } } -function RoomWallFallback({ room }: { room: BossRoomDefinition }) { +function RoomWallFallback({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) { const walls = useRef(null); const previousCameraPosition = useRef(null); + const fixtures = useMemo( + () => ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8), + [portalOpenings], + ); useFrame(({ camera }) => { if (!walls.current) return; @@ -252,7 +284,7 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) { return ( - {ARENA_WALL_SEGMENTS.map((fixture, index) => ( + {fixtures.map((fixture, index) => ( }> - + }> + ); } -function OptimizedRoomWalls({ room }: { room: BossRoomDefinition }) { +function OptimizedRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) { return ( - }> - }> - + }> + }> + ); } -function RoomWalls({ room }: { room: BossRoomDefinition }) { +function RoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) { return LEGACY_GAME_ASSETS_FORCED - ? - : ; + ? + : ; } function RoomMarks({ room }: { room: BossRoomDefinition }) { @@ -384,7 +416,7 @@ function RoomScenery({ room }: { room: BossRoomDefinition }) { ); } -function RoomFloor({ room }: { room: BossRoomDefinition }) { +function RoomFloor({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) { return ( @@ -401,14 +433,384 @@ function RoomFloor({ room }: { room: BossRoomDefinition }) { - + ); } +function HockeyGoal({ z, color }: { z: number; color: string }) { + const goalWidth = HOCKEY_GOAL_HALF_WIDTH * 2; + return ( + + + + + + + + + + + + + + + + + + + + ); +} + +function HockeyHealingRoom() { + const goalSideWidth = (HOCKEY_ARENA_WIDTH - HOCKEY_GOAL_HALF_WIDTH * 2) * 0.5; + const leftGoalSideX = HOCKEY_ARENA_MIN_X + goalSideWidth * 0.5; + const rightGoalSideX = HOCKEY_ARENA_MAX_X - goalSideWidth * 0.5; + const wallMaterial = ; + return ( + + + + + + + + + + + + + + + + + + + + + + + + + {wallMaterial} + + + + {wallMaterial} + + {[HOCKEY_ARENA_MIN_Z - 0.15, HOCKEY_ARENA_MAX_Z + 0.15].flatMap((z) => [leftGoalSideX, rightGoalSideX].map((x) => ( + + + + + )))} + + + + ); +} + +const BLOCKBREAKER_BIOME_FIXTURES = [ + [-12.75, -12.5, 0.94], + [-12.75, -1, 1.08], + [-12.75, 10.5, 0.9], + [12.75, -12.5, 1.02], + [12.75, -1, 0.88], + [12.75, 10.5, 1.12], +] as const; + +function BlockbreakerFixtureGeometry({ fixture }: { fixture: BlockbreakerBiomeFixture }) { + if (fixture === "crystal") return ; + if (fixture === "forge") return ; + if (fixture === "spire") return ; + if (fixture === "monolith") return ; + return ; +} + +function BlockbreakerBiomeFixtures({ biome }: { biome: BlockbreakerArenaBiome }) { + const mesh = useRef(null); + const transform = useMemo(() => new THREE.Object3D(), []); + + useLayoutEffect(() => { + if (!mesh.current) return; + BLOCKBREAKER_BIOME_FIXTURES.forEach(([x, z, scale], index) => { + transform.position.set(x, biome.fixture === "reactor" ? 1.45 : 1.75, z); + transform.rotation.set( + biome.fixture === "reactor" ? 0 : index % 2 === 0 ? -0.08 : 0.08, + index * 0.73, + biome.fixture === "reactor" ? 0 : index % 2 === 0 ? 0.06 : -0.06, + ); + transform.scale.setScalar(scale); + transform.updateMatrix(); + mesh.current!.setMatrixAt(index, transform.matrix); + }); + mesh.current.instanceMatrix.needsUpdate = true; + mesh.current.computeBoundingSphere(); + }, [biome.fixture, transform]); + + return ( + + + + + ); +} + +function BrightArcadeRoom({ + variant, + biome = BLOCKBREAKER_BIOMES[0], +}: { + variant: "blockbreaker" | "aether-assault"; + biome?: BlockbreakerArenaBiome; +}) { + const roomWidth = HOCKEY_ARENA_WIDTH + 10; + const roomLength = HOCKEY_ARENA_LENGTH + 12; + const roomMinX = HOCKEY_ARENA_MIN_X - 5; + const roomMaxX = HOCKEY_ARENA_MAX_X + 5; + const roomMinZ = HOCKEY_ARENA_MIN_Z - 6; + const roomMaxZ = HOCKEY_ARENA_MAX_Z + 6; + const wallHeight = 4.8; + const wallMaterial = ( + + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + {[HOCKEY_ARENA_MIN_X, HOCKEY_ARENA_MAX_X].map((x) => ( + + + + + ))} + {[HOCKEY_ARENA_MIN_Z, HOCKEY_ARENA_MAX_Z].map((z) => ( + + + + + ))} + {variant === "blockbreaker" ? ( + + + + + ) : ( + + {[-6.7, -3.35, 0, 3.35, 6.7].map((x) => ( + + + + + ))} + {[-10.7, -8.45, -6.2, -3.95].map((z) => ( + + + + + ))} + + )} + + {[roomMinX - 0.18, roomMaxX + 0.18].map((x) => ( + + + {wallMaterial} + + ))} + {[roomMinZ - 0.18, roomMaxZ + 0.18].map((z) => ( + + + {wallMaterial} + + ))} + {[roomMinX - 0.36, roomMaxX + 0.36].map((x) => ( + + + + + ))} + {[roomMinZ - 0.36, roomMaxZ + 0.36].map((z) => ( + + + + + ))} + {variant === "blockbreaker" && } + + ); +} + +function HealingHockeyPvpRoom() { + const width = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_ARENA_MIN_X; + const length = HOCKEY_PVP_ARENA_MAX_Z - HOCKEY_PVP_ARENA_MIN_Z; + const goalSideWidth = (width - HOCKEY_PVP_GOAL_HALF_WIDTH * 2) * 0.5; + const sideCenters = [ + HOCKEY_PVP_ARENA_MIN_X + goalSideWidth * 0.5, + HOCKEY_PVP_ARENA_MAX_X - goalSideWidth * 0.5, + ]; + return ( + + + + + + + + + + + + + + + + + + + + {[-11, 11].map((z) => + + 0 ? "#64e7ff" : "#ff7688"} transparent opacity={0.38} /> + )} + {[HOCKEY_PVP_ARENA_MIN_X - 0.15, HOCKEY_PVP_ARENA_MAX_X + 0.15].map((x) => ( + + + + + ))} + {[-HOCKEY_PVP_ARENA_MAX_Z - 0.15, HOCKEY_PVP_ARENA_MAX_Z + 0.15].flatMap((z) => sideCenters.map((x) => ( + + + + + )))} + + + + + ); +} + +function HockeyPvpScoreboards() { + const localGoals = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded); + const opponentGoals = useGameStore((state) => state.hockeyPvp.localGoalsConceded); + const texture = useMemo(() => { + const canvas = document.createElement("canvas"); + canvas.width = 512; + canvas.height = 192; + const next = new THREE.CanvasTexture(canvas); + next.colorSpace = THREE.SRGBColorSpace; + next.minFilter = THREE.LinearMipmapLinearFilter; + next.magFilter = THREE.LinearFilter; + return next; + }, []); + + useEffect(() => { + const canvas = texture.image as HTMLCanvasElement; + const context = canvas.getContext("2d"); + if (!context) return; + const gradient = context.createLinearGradient(0, 0, canvas.width, canvas.height); + gradient.addColorStop(0, "#061d28"); + gradient.addColorStop(0.5, "#05080d"); + gradient.addColorStop(1, "#2b0a17"); + context.fillStyle = gradient; + context.fillRect(0, 0, canvas.width, canvas.height); + context.strokeStyle = "#89efff"; + context.lineWidth = 5; + context.strokeRect(5, 5, canvas.width - 10, canvas.height - 10); + context.fillStyle = "#a8c4c9"; + context.font = "700 20px Inter, sans-serif"; + context.textAlign = "center"; + context.fillText("HEALING HOCKEY", canvas.width / 2, 31); + context.fillStyle = "#74ecff"; + context.font = "700 18px Inter, sans-serif"; + context.fillText("YOU", 132, 58); + context.fillStyle = "#ff819f"; + context.fillText("RIVAL", 380, 58); + context.font = "700 94px Impact, Inter, sans-serif"; + context.fillStyle = "#eaffff"; + context.fillText(String(Math.min(99, localGoals)).padStart(2, "0"), 132, 151); + context.fillStyle = "#ffedf4"; + context.fillText(String(Math.min(99, opponentGoals)).padStart(2, "0"), 380, 151); + context.fillStyle = "#f3d87c"; + context.font = "700 56px Inter, sans-serif"; + context.fillText("–", 256, 137); + texture.needsUpdate = true; + }, [localGoals, opponentGoals, texture]); + + useEffect(() => () => texture.dispose(), [texture]); + + return <>{([-1, 1] as const).map((side) => ( + + + + + + + + + + + ))}; +} + /** Main-display room projection. Gameplay stays in the shared arena domain. */ export function BossRoom() { const bossId = useGameStore((state) => state.boss.id); + const blockbreakerSeed = useGameStore((state) => state.blockbreaker.seed); + const hockeyMode = useGameStore((state) => state.activityMode === "hockey-healing"); + const blockbreakerMode = useGameStore((state) => state.activityMode === "blockbreaker"); + const aetherAssaultMode = useGameStore((state) => state.activityMode === "aether-assault"); + const hockeyPvpMode = useGameStore((state) => state.activityMode === "hockey-healing-pvp"); + const rpgPhase = useGameStore((state) => state.rpgRun?.phase ?? null); + const rpgBossRoom = useGameStore((state) => state.runMode === "rpg-roguelike" && state.activityMode === "boss"); + if (hockeyPvpMode) return ; + if (aetherAssaultMode) return ; + if (blockbreakerMode) return ; + if (hockeyMode) return ; const room = bossRoomFor(bossId); return ( @@ -418,7 +820,15 @@ export function BossRoom() { - + + {rpgBossRoom && rpgPhase && ( + + )} ); } diff --git a/src/components/BottomScreen.tsx b/src/components/BottomScreen.tsx index d8281d0..ae4cec8 100644 --- a/src/components/BottomScreen.tsx +++ b/src/components/BottomScreen.tsx @@ -1,12 +1,38 @@ import { ABILITY_ORDER } from "../game/data"; -import { HEALER_CLASSES } from "../game/healers"; +import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers"; import { BOSS_DEFINITIONS } from "../game/bossCatalog"; -import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store"; +import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store"; import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike"; import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat"; import type { BottomTab, PartyMember } from "../game/types"; import { useActiveHunter, useFrontendStore } from "../frontend/store"; import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; +import { + HOCKEY_ARENA_CENTER_Z, + HOCKEY_GOAL_HALF_WIDTH, + HOCKEY_HEALER_GOAL_Z, + HOCKEY_NPC_PADDLE_HALF_WIDTH, + HOCKEY_NPC_PADDLE_Z, + HOCKEY_NPC_GOAL_Z, +} from "../game/hockeyHealing"; +import { + HOCKEY_PVP_GOAL_DAMAGE, + HOCKEY_PVP_GOAL_HALF_WIDTH, + HOCKEY_PVP_GOAL_Z, + HOCKEY_PVP_SIDE_OFFSET_Z, +} from "../game/hockeyHealingPvp"; +import { bottomTabsFor } from "../game/bottomTabs"; +import { + BLOCKBREAKER_BREACH_DAMAGE, + BLOCKBREAKER_BRICK_COLORS, + BLOCKBREAKER_DANGER_Z, + blockbreakerColumnX, + blockbreakerRowZ, + blockbreakerTimeMultiplier, +} from "../game/blockbreaker"; +import { aetherShipColor } from "./aetherAssaultVisuals"; +import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings"; +import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel"; function RewardSummary() { const rewards = useFrontendStore((state) => state.recentRewards); @@ -30,14 +56,15 @@ function PartyFrame({ member }: { member: PartyMember }) { const selected = useGameStore((state) => state.selectedMemberId === member.id); const selectMember = useGameStore((state) => state.selectMember); const time = useGameStore((state) => state.time); - const renewRemaining = Math.max(0, member.renewExpiresAt - time); + const activeHealingEffects = member.healingEffects.filter((effect) => effect.expiresAt > time).slice(0, 3); const knockedRemaining = Math.max(0, member.knockedUntil - time); const barrier = useGameStore((state) => state.barrier); const tankAura = useGameStore((state) => state.partyCombat.tankAura); - const tankPosition = useGameStore((state) => state.partyPositions.brann); + const tankPosition = useGameStore((state) => state.partyPositions[state.partyCombat.tankAura.sourceId]); const combatant = useGameStore((state) => member.id === "aelia" ? undefined : state.partyCombat.combatants[member.id]); const position = useGameStore((state) => state.partyPositions[member.id]); const protectedByBarrier = barrierProtects(position, barrier, time); + const linkedBySpirit = barrier.kind === "spirit-link" && healerFieldContains(position, barrier, time); const protectedByTank = tankAuraProtects(position, tankPosition, tankAura, time); const currentAction = combatant?.visualAction && combatant.visualAction.endsAt > time ? PARTY_ABILITY_NAMES[combatant.visualAction.abilityId] @@ -56,9 +83,14 @@ function PartyFrame({ member }: { member: PartyMember }) { {member.absorb > 0 && } - {renewRemaining > 0 && {Math.ceil(renewRemaining)}} + {activeHealingEffects.map((effect) => { + const label = effect.id === "renew" ? "R" : effect.id === "regrowth" ? "G" : effect.id === "rejuvenation" ? "J" : effect.id === "lifebloom" ? `L${effect.stacks}` : effect.id === "wild-growth" ? "W" : "T"; + return {label}; + })} + {member.reactiveHeal && member.reactiveHeal.expiresAt > time && {member.reactiveHeal.charges}} {member.debuffs.length > 0 && !} {protectedByBarrier && B} + {linkedBySpirit && S} {protectedByTank && T} {knockedRemaining > 0 && KD} @@ -78,7 +110,7 @@ function PartyList() { function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) { const healerClassId = useGameStore((state) => state.healerClassId); - const ability = HEALER_CLASSES[healerClassId].abilities[abilityId]; + const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId)); const time = useGameStore((state) => state.time); const cooldowns = useGameStore((state) => state.cooldowns); const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil); @@ -89,15 +121,21 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number const activeCast = useGameStore((state) => state.activeCast); const castAbility = useGameStore((state) => state.castAbility); const runModifiers = useGameStore((state) => state.runModifiers); + const healerMechanic = useGameStore((state) => state.healerMechanic); + if (!ability) { + return ; + } const remaining = abilityRemaining(abilityId, time, cooldowns); const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers); - const castTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0; + const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0; + const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime; const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers); const globalRemaining = Math.max(0, globalCooldownUntil - time); - const noDispel = abilityId === "purify" && selected.debuffs.length === 0; + const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0; const invalidTarget = ability.targeting === "ally" && selected.hp <= 0; const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget; - const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`; + const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase(); + const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`; return ( +

{hockeyMode ? "Defend the wide goal while healing through two bosses. Left-stick direction sets every puck return; the moving enemy paddle tracks it and strikes it back. Each fallen boss awards loot and rolls its pet chance before replacement." : pvpMode ? `Face ${opponentName}. Both parties use normalized base gear and fight the same boss order. Every boss kill adds 5% global healing Dampening. Aim returns with left stick. A goal deals ${HOCKEY_PVP_GOAL_DAMAGE} damage to every member of the conceding party. Boss kills still award loot and pet chances.` : blockbreakerMode ? `Aim the puck into advancing five-column color rows while healing through two bosses. Orthogonal matching clusters break together. Rows start every 10 seconds and accelerate. Misses safely re-serve; each breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member.` : aetherMode ? "Move freely through the full runway while your arcane focus fires automatically. Heal with your normal kit. Dodge enemy volleys and diving ships; ship hits only threaten the healer. Bosses and rewards continue independently." : <>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}}

+
Prepared skills6 equipped
@@ -195,6 +259,7 @@ function EndPanel({ onExit }: { onExit?: () => void }) { const hunter = useActiveHunter(); const phase = useGameStore((state) => state.phase); const runMode = useGameStore((state) => state.runMode); + const activityMode = useGameStore((state) => state.activityMode); const round = useGameStore((state) => state.round); const endlessMode = useGameStore((state) => state.endlessMode); const endlessBossKills = useGameStore((state) => state.endlessBossKills); @@ -207,38 +272,44 @@ function EndPanel({ onExit }: { onExit?: () => void }) { const startEncounter = useGameStore((state) => state.startEncounter); const totalHp = party.reduce((sum, member) => sum + member.hp, 0); const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0); + const hockey = useGameStore((state) => state.hockey); + const hockeyPvp = useGameStore((state) => state.hockeyPvp); + const blockbreaker = useGameStore((state) => state.blockbreaker); + const aetherAssault = useGameStore((state) => state.aetherAssault); const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode; - const endlessDefeat = phase === "defeat" && endlessMode; + const hockeyDefeat = phase === "defeat" && activityMode === "hockey-healing"; + const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker"; + const aetherDefeat = phase === "defeat" && activityMode === "aether-assault"; + const pvpMatch = activityMode === "hockey-healing-pvp"; + const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat; const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0; const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`; return (
{phase === "victory" ? "✦" : "×"} - {showEndlessChoice ? "ROGUE TRIALS CLEARED" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"} -

{showEndlessChoice ? "The trial can continue" : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}

+ {showEndlessChoice ? "ROGUE TRIALS CLEARED" : hockeyDefeat ? "HOCKEY HEALING COMPLETE" : blockbreakerDefeat ? "BLOCKBREAKER RUN COMPLETE" : aetherDefeat ? "AETHER ASSAULT COMPLETE" : pvpMatch ? phase === "victory" ? "PVP MATCH WON" : "PVP MATCH LOST" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"} +

{showEndlessChoice ? "The trial can continue" : hockeyDefeat ? `${hockey.returns} pucks returned` : blockbreakerDefeat ? `${blockbreaker.score} points scored` : aetherDefeat ? `${aetherAssault.score} points scored` : pvpMatch ? phase === "victory" ? `${hockeyPvp.opponentName} fell first` : `${hockeyPvp.opponentName} wins` : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}

Duration{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")} - Party vitality{Math.round((totalHp / totalMax) * 100)}% - {endlessDefeat ? "Endless kills" : "Boss"}{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"} + {hockeyDefeat ? "Puck returns" : blockbreakerDefeat ? "Bricks broken" : aetherDefeat ? "Wave reached" : pvpMatch ? "Goals" : "Party vitality"}{hockeyDefeat ? hockey.returns : blockbreakerDefeat ? blockbreaker.bricksBroken : aetherDefeat ? aetherAssault.wave : pvpMatch ? `${hockeyPvp.opponentGoalsConceded}–${hockeyPvp.localGoalsConceded}` : `${Math.round((totalHp / totalMax) * 100)}%`} + {hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Boss kills" : pvpMatch ? "Boss kills" : endlessDefeat ? "Endless kills" : "Boss"}{hockeyDefeat || blockbreakerDefeat || aetherDefeat || endlessDefeat ? endlessBossKills : pvpMatch ? `${endlessBossKills}–${hockeyPvp.opponentBossKills}` : phase === "victory" ? "Defeated" : "Standing"}
- {phase === "victory" && } + {(phase === "victory" || hockeyDefeat || blockbreakerDefeat || aetherDefeat) && } {showEndlessChoice ?
:
- - + +
}
); @@ -275,10 +346,182 @@ function MapPanel() { const time = useGameStore((state) => state.time); const phase = useGameStore((state) => state.phase); const bossId = useGameStore((state) => state.bossId); + const activityMode = useGameStore((state) => state.activityMode); + const hockey = useGameStore((state) => state.hockey); + const hockeyPvp = useGameStore((state) => state.hockeyPvp); + const blockbreaker = useGameStore((state) => state.blockbreaker); + const aetherAssault = useGameStore((state) => state.aetherAssault); + const hockeyPvpOpponent = useGameStore((state) => state.hockeyPvpOpponent); const bossDefinition = BOSS_DEFINITIONS[bossId]; const playerX = 120 + playerPosition[0] * 7; const playerY = 143 + playerPosition[1] * 5.3; const bossMotions = [bossMotion, ...additionalBosses.map((entry) => entry.motion)]; + if (activityMode === "aether-assault") { + const mapX = (x: number) => 120 + x * 8.5; + const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8; + return ( +
+
+ Aether Assault +

Arcane Formation Runway

+

Focus fire stays automatic. Move anywhere in the rink, heal freely, and evade red volleys plus amber dive warnings.

+
Party Bosses Ships Shots
+
+
+ + + + + {aetherAssault.ships.map((ship, index) => { + const color = aetherShipColor(aetherAssault.seed, aetherAssault.wave, index, ship.kind); + return + {ship.phase === "diving" && } + + ; + })} + {aetherAssault.playerShots.map((shot) => )} + {aetherAssault.enemyShots.map((shot) => )} + {bossMotions.map((motion, index) => )} + {(["brann", "nia", "orin", "vale"] as const).map((memberId) => )} + + + {barrier.expiresAt > time && } + + {phase === "combat" ? `WAVE ${aetherAssault.wave} · ${aetherAssault.score} SCORE` : "FORMATION PREVIEW"} +
+
+ ); + } + if (activityMode === "blockbreaker") { + const mapX = (x: number) => 120 + x * 8.5; + const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8; + const colors: Record<(typeof BLOCKBREAKER_BRICK_COLORS)[number], string> = { + cyan: "#36d9ef", + amber: "#f1b74f", + magenta: "#e85aa9", + lime: "#93db54", + }; + return ( +
+
+ Blockbreaker +

Advancing Color Wall

+

Match orthogonal colors. Crossing bricks disappear and deal {BLOCKBREAKER_BREACH_DAMAGE} partywide damage.

+
Party Bosses Bricks Puck
+
+
+ + + + + + {blockbreaker.bricks.map((brick) => ( + + ))} + {bossMotions.map((motion, index) => )} + + + {(["brann", "nia", "orin", "vale"] as const).map((memberId) => )} + + {barrier.expiresAt > time && } + + {phase === "combat" ? `${blockbreaker.bricks.length} BRICKS · ${blockbreaker.score} SCORE` : "WALL PREVIEW"} +
+
+ ); + } + if (activityMode === "hockey-healing-pvp") { + const mapX = (x: number) => 120 + x * 8.2; + const mapY = (z: number) => 140 + z * 4.85; + const localWorldZ = (z: number) => z + HOCKEY_PVP_SIDE_OFFSET_Z; + const opponentWorldX = (x: number) => -x; + const opponentWorldZ = (z: number) => -z - HOCKEY_PVP_SIDE_OFFSET_Z; + return ( +
+
+ Healing Hockey PVP +

You vs {hockeyPvp.opponentName}

+

Matching boss order. Each goal hits all five allies for {HOCKEY_PVP_GOAL_DAMAGE} damage.

+
Your party Bosses Rival Puck
+
+
+ + + + + + + + + + + {(["brann", "nia", "orin", "vale"] as const).map((memberId) => )} + {(["aelia", "brann", "nia", "orin", "vale"] as const).map((memberId) => )} + + + {phase === "combat" ? `GOALS ${hockeyPvp.opponentGoalsConceded}–${hockeyPvp.localGoalsConceded} · LIVE` : "VERSUS RINK"} +
+
+ ); + } + if (activityMode === "hockey-healing") { + const mapX = (x: number) => 120 + x * 8.5; + const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8; + const npcGoalLeft = mapX(-HOCKEY_GOAL_HALF_WIDTH); + const npcGoalRight = mapX(HOCKEY_GOAL_HALF_WIDTH); + return ( +
+
+ Hockey Healing +

Rectangular Boss Rink

+

Party fights enemy half. Moving Pong paddle tracks each return and strikes it back toward healer.

+
Party Boss Paddle Puck
+
+
+ + + + + + + + + + {bossMotions.map((motion, index) => ( + + + + + ))} + + + {(["brann", "nia", "orin", "vale"] as const).map((memberId) => ( + + ))} + + {barrier.expiresAt > time && } + + {phase === "combat" ? `${hockey.returns} RETURNS · LIVE` : "RINK PREVIEW"} +
+
+ ); + } return (
@@ -362,33 +605,132 @@ function PackPanel() { ); } -const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [ - { id: "combat", label: "Heal", icon: "✦", key: "" }, - { id: "map", label: "Map", icon: "⌁", key: "M" }, - { id: "pack", label: "Pack", icon: "▧", key: "I" }, -]; +function PvpPanel() { + const opponentName = useGameStore((state) => state.hockeyPvp.opponentName); + const opponentParty = useGameStore((state) => state.hockeyPvpOpponent.party); + const opponentGoalsConceded = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded); + const localGoalsConceded = useGameStore((state) => state.hockeyPvp.localGoalsConceded); + const opponentBossKills = useGameStore((state) => state.hockeyPvp.opponentBossKills); + const living = opponentParty.filter((member) => member.hp > 0).length; + const currentHealth = opponentParty.reduce((total, member) => total + Math.max(0, member.hp), 0); + const maximumHealth = opponentParty.reduce((total, member) => total + member.maxHp, 0); + + return ( +
+
+ Opponent party{opponentName} +
Goals{opponentGoalsConceded}–{localGoalsConceded}
+
Boss KOs{opponentBossKills}
+
Standing{living} / {opponentParty.length}
+
+
+
Rival health feed{Math.ceil(currentHealth)} / {maximumHealth} total
+ {opponentParty.map((member) => ( +
+ {member.name[0]} + + {member.name}{Math.ceil(member.hp)} / {member.maxHp} + + {member.className} + + {member.hp <= 0 ? "DOWN" : `${Math.ceil((member.hp / member.maxHp) * 100)}%`} +
+ ))} +
+
+ ); +} + +const tabPresentation: Record = { + combat: { label: "Heal", icon: "✦", key: "" }, + map: { label: "Map", icon: "⌁", key: "M" }, + pack: { label: "Pack", icon: "▧", key: "I" }, + pvp: { label: "PVP", icon: "⚔", key: "P" }, +}; + +function RpgBottomDisplay({ run, focusedId, paused, onExit }: { + readonly run: NonNullable["rpgRun"]>; + readonly focusedId: string | null; + readonly paused: boolean; + readonly onExit?: () => void; +}) { + const party = useGameStore((state) => state.party); + const selectedMemberId = useGameStore((state) => state.selectedMemberId); + const mana = useGameStore((state) => state.mana); + const maxMana = useGameStore((state) => state.maxMana); + const cooldowns = useGameStore((state) => state.cooldowns); + const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil); + const time = useGameStore((state) => state.time); + const activeCast = useGameStore((state) => state.activeCast); + const spellResources = useGameStore((state) => state.rpgSpellResources); + const selectMember = useGameStore((state) => state.selectMember); + const castAbility = useGameStore((state) => state.castAbility); + const dispatchRpgAction = useGameStore((state) => state.dispatchRpgAction); + const setRpgFocusId = useGameStore((state) => state.setRpgFocusId); + const restart = useGameStore((state) => state.restart); + return ( +
+ + {paused && ( + + )} +
+ ); +} export function BottomScreen({ onExit }: { onExit?: () => void } = {}) { const activeTab = useGameStore((state) => state.activeTab); const setActiveTab = useGameStore((state) => state.setActiveTab); const phase = useGameStore((state) => state.phase); const paused = useGameStore((state) => state.paused); + const runMode = useGameStore((state) => state.runMode); + const activityMode = useGameStore((state) => state.activityMode); + const rpgRun = useGameStore((state) => state.rpgRun); + const rpgFocusId = useGameStore((state) => state.rpgFocusId); + const tabs = bottomTabsFor(runMode); + if (runMode === "rpg-roguelike" && rpgRun) { + return ; + } return (
IHI Want To Heal{phase === "combat" ? "Encounter live" : "Field console"}
-
{activeTab === "combat" && } {activeTab === "map" && } - {activeTab === "pack" && } + {activeTab === "pack" && activityMode !== "hockey-healing-pvp" && } + {activeTab === "pvp" && activityMode === "hockey-healing-pvp" && }
{paused && (
- + {continuation === "create" ? "Create hunter" : resolvingOnline ? "Loading online save…" : "Continue"} {continuation === "create" ? `Use slot ${selectedSlotId}` : continuation === "online" ? "Download online copy" : continuation === "choose" ? "Choose online or device copy" : `Slot ${selectedSlotId} · ${selected.local?.hunterName}`} - - uploadSlot(selectedSlotId)}>UploadDevice → server - downloadSlot(selectedSlotId)}>DownloadServer → device - openSaveDialog("copy")}>CopyDuplicate save - openSaveDialog("delete")}>DeleteErase device copy - navigate("login")}>BackLogin screen + + uploadSlot(selectedSlotId)}>UploadDevice → server + downloadSlot(selectedSlotId)}>DownloadServer → device + openSaveDialog("copy")}>CopyDuplicate save + openSaveDialog("delete")}>DeleteErase device copy + navigate("login")}>BackLogin screen
Autosave OFFLINE FIRST
{notice || "Lower display shows selected save details."}
{dialog && ( @@ -381,9 +413,9 @@ function SaveScreen() {
- { void continueWithOnline(); }}>{resolvingOnline ? "Loading…" : "Continue online copy"}{formatSaveTimestamp(selected.online.updatedAt)} - { setDialog(null); playSlot(selectedSlotId); }}>Continue device copy{formatSaveTimestamp(selected.local.updatedAt)} - setDialog(null)}>Cancel + { void continueWithOnline(); }}>{resolvingOnline ? "Loading…" : "Continue online copy"}{formatSaveTimestamp(selected.online.updatedAt)} + { setDialog(null); playSlot(selectedSlotId); }}>Continue device copy{formatSaveTimestamp(selected.local.updatedAt)} + setDialog(null)}>Cancel
{versionError &&
{versionError}
}
@@ -392,19 +424,21 @@ function SaveScreen() { New offline save

Name your hunter

This name identifies the character in local and online save lists.

controller.select("hunter-name")} onChange={(event) => setHunterName(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") setDialog(null); }} placeholder="Enter name" /> {normalizeHunterName(hunterName).length}/{MAX_HUNTER_NAME_LENGTH}
- Create hunter - setDialog(null)}>Cancel + Create hunter + setDialog(null)}>Cancel
) : dialog === "copy" ? ( @@ -412,9 +446,9 @@ function SaveScreen() { Copy local save

Choose destination

Destination local save will be overwritten. Online copies stay unchanged.

{slots.filter((slot) => slot.id !== selectedSlotId).map((slot) => ( - { copySlot(selectedSlotId, slot.id); setDialog(null); }}> + { copySlot(selectedSlotId, slot.id); setDialog(null); }}> Slot {slot.id}{slot.local ? "Overwrite" : "Empty"} - + ))}
@@ -422,8 +456,8 @@ function SaveScreen() { <> Delete local save

Erase slot {selectedSlotId}?

Device copy will be removed. Existing online version remains available for download.

- { deleteSlot(selectedSlotId); setDialog(null); }}>Delete local - setDialog(null)}>Cancel + { deleteSlot(selectedSlotId); setDialog(null); }}>Delete local + setDialog(null)}>Cancel
)} @@ -470,9 +504,13 @@ function SaveScreen() { } const HOME_MODES: { id: GameModeId; icon: string; label: string; copy: string }[] = [ - { id: "roguelike-pve", icon: "✦", label: "PVE", copy: "Randomized roguelike runs" }, + { id: "roguelike-pve", icon: "✦", label: "RPG Roguelike", copy: "Draft party, spells, gear, and route" }, { id: "rogue-trials", icon: "Ⅲ", label: "Rogue Trials", copy: "Four rounds, then a boss trio" }, { id: "dungeons", icon: "♜", label: "Dungeons", copy: "Choose your boss encounter" }, + { id: "hockey-healing", icon: "◌", label: "Hockey Healing", copy: "Defend goal under boss pressure" }, + { id: "hockey-healing-pvp", icon: "◇", label: "Healing Hockey PVP", copy: "Online mirrored healer duel" }, + { id: "blockbreaker", icon: "▦", label: "Blockbreaker", copy: "Break color walls while healing" }, + { id: "aether-assault", icon: "⌁", label: "Aether Assault", copy: "Auto-fire through arcane formations" }, { id: "roguelike-pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" }, { id: "stadium-pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" }, ]; @@ -482,28 +520,34 @@ function HomeScreen() { const accountId = useFrontendStore((state) => state.accountId); const selectMode = useFrontendStore((state) => state.selectMode); const selectHealerClass = useFrontendStore((state) => state.selectHealerClass); + const openAppearanceLab = useFrontendStore((state) => state.openAppearanceLab); const navigate = useFrontendStore((state) => state.navigate); const actions = useMemo(() => [ - { id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "rogue-trials", down: "roguelike-pvp" } }, - { id: "rogue-trials", run: () => selectMode("rogue-trials"), neighbors: { left: "roguelike-pve", right: "dungeons", down: "stadium-pvp" } }, - { id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "rogue-trials", down: "stadium-pvp" } }, - { id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { left: "roguelike-pve", right: "stadium-pvp", up: "roguelike-pve", down: "profile" } }, - { id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", up: "rogue-trials", down: "settings" } }, - { id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "gear", down: "class-priest" } }, - { id: "gear", run: () => navigate("gear"), neighbors: { up: "roguelike-pvp", left: "profile", right: "settings", down: "class-druid" } }, - { id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "gear", down: "class-shaman" } }, + { id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "rogue-trials", down: "hockey-healing" } }, + { id: "rogue-trials", run: () => selectMode("rogue-trials"), neighbors: { left: "roguelike-pve", right: "dungeons", down: "hockey-healing-pvp" } }, + { id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "rogue-trials", down: "blockbreaker" } }, + { id: "hockey-healing", run: () => selectMode("hockey-healing"), neighbors: { right: "hockey-healing-pvp", up: "roguelike-pve", down: "roguelike-pvp" } }, + { id: "hockey-healing-pvp", run: () => selectMode("hockey-healing-pvp"), neighbors: { left: "hockey-healing", right: "blockbreaker", up: "rogue-trials", down: "stadium-pvp" } }, + { id: "blockbreaker", run: () => selectMode("blockbreaker"), neighbors: { left: "hockey-healing-pvp", up: "dungeons", down: "aether-assault" } }, + { id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { right: "stadium-pvp", up: "hockey-healing", down: "profile" } }, + { id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", right: "aether-assault", up: "hockey-healing-pvp", down: "gear" } }, + { id: "aether-assault", run: () => selectMode("aether-assault"), neighbors: { left: "stadium-pvp", up: "blockbreaker", down: "settings" } }, + { id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "gear", down: "appearance" } }, + { id: "gear", run: () => navigate("gear"), neighbors: { up: "stadium-pvp", left: "profile", down: "settings" } }, + { id: "appearance", run: openAppearanceLab, neighbors: { up: "profile", right: "settings", down: "class-priest" } }, + { id: "settings", run: () => navigate("settings"), neighbors: { up: "gear", left: "appearance", down: "class-druid" } }, ...HEALER_CLASS_ORDER.map((classId, index) => ({ id: `class-${classId}`, run: () => selectHealerClass(classId), neighbors: { - left: `class-${HEALER_CLASS_ORDER[(index + HEALER_CLASS_ORDER.length - 1) % HEALER_CLASS_ORDER.length]}`, - right: `class-${HEALER_CLASS_ORDER[(index + 1) % HEALER_CLASS_ORDER.length]}`, - up: index === 0 ? "profile" : index === 1 ? "gear" : "settings", - down: "change-save", + left: `class-${HEALER_CLASS_ORDER[index % 3 === 0 ? Math.min(index + 2, HEALER_CLASS_ORDER.length - 1) : index - 1]}`, + right: `class-${HEALER_CLASS_ORDER[index % 3 === 2 || index === HEALER_CLASS_ORDER.length - 1 ? index - (index % 3) : index + 1]}`, + up: index < 3 ? (["appearance", "settings", "settings"] as const)[index] : `class-${HEALER_CLASS_ORDER[index - 3]}`, + down: index + 3 < HEALER_CLASS_ORDER.length ? `class-${HEALER_CLASS_ORDER[index + 3]}` : "change-save", }, })), - { id: "change-save", run: () => navigate("saves"), neighbors: { up: "class-druid" } }, - ], [navigate, selectHealerClass, selectMode]); + { id: "change-save", run: () => navigate("saves"), neighbors: { up: "class-chronomancer" } }, + ], [navigate, openAppearanceLab, selectHealerClass, selectMode]); const controller = useMenuController(actions, { columns: 2, onBack: () => navigate("saves") }); if (!hunter) return null; const activeHealer = HEALER_CLASSES[hunter.activeClassId]; @@ -516,15 +560,16 @@ function HomeScreen() {
Welcome back, {hunter.hunterName}{accountId ? "● SYNC READY" : "○ OFFLINE"}
{HOME_MODES.map((mode) => ( - selectMode(mode.id)}> + selectMode(mode.id)}> {mode.icon}{mode.copy}{mode.label} - + ))}
- navigate("profile")}>Hunter ProfileStats & collection log - navigate("gear")}>Gear UpgradeSpend group drops - navigate("settings")}>SettingsAudio, display, controls + navigate("profile")}>Hunter ProfileStats & collection log + navigate("gear")}>Gear UpgradeSpend group drops + Appearance LabBuild and preview your healer + navigate("settings")}>SettingsAudio, display, controls
@@ -544,105 +589,301 @@ function HomeScreen() {
Choose healer
{HEALER_CLASS_ORDER.map((classId) => { const healer = HEALER_CLASSES[classId]; const progress = hunter.healers[classId]; - return selectHealerClass(classId)}> + return selectHealerClass(classId)}> {healer.icon}{healer.name}Level {progress.level} · {progress.inventory.length} items{classId === hunter.activeClassId ? "✓" : ""} - ; + ; })}
- navigate("saves")}>Change save slotLast saved {formatSaveTimestamp(hunter.updatedAt)} + navigate("saves")}>Change save slotLast saved {formatSaveTimestamp(hunter.updatedAt)} } /> ); } -type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless"; - function ProfileScreen() { const hunter = useActiveHunter(); const accountId = useFrontendStore((state) => state.accountId); const navigate = useFrontendStore((state) => state.navigate); + const uploadSlot = useFrontendStore((state) => state.uploadSlot); + const collectionView = useFrontendStore((state) => state.profileCollectionView); + const groupId = useFrontendStore((state) => state.selectedProfileGroupId); + const selectedStat = useFrontendStore((state) => state.selectedProfileStatId); + const setCollectionView = useFrontendStore((state) => state.selectProfileCollectionView); + const setGroupId = useFrontendStore((state) => state.selectProfileGroup); + const setSelectedStat = useFrontendStore((state) => state.selectProfileStat); const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]); - const [groupId, setGroupId] = useState(collections[0]?.groupId ?? ""); - const [collectionView, setCollectionView] = useState<"loot" | "trophies" | "stats">("trophies"); + const bosses = useMemo(() => alphabeticalBosses(collections), [collections]); const collection = collections.find((group) => group.groupId === groupId) ?? collections[0]; - const [selectedStat, setSelectedStat] = useState("roguelike"); + const profileView = collectionView === "loot" ? "loot" : "stats"; + const activeSection = profileSectionForStat(selectedStat); + const activeSectionDefinition = PROFILE_SECTIONS.find((section) => section.id === activeSection) ?? PROFILE_SECTIONS[0]; + const selectedBoss = isBossProfileStat(selectedStat) ? bosses.find((boss) => boss.bossId === selectedStat) : undefined; const [leaderboard, setLeaderboard] = useState(null); const [leaderboardStatus, setLeaderboardStatus] = useState(""); + const [leaderboardUpdatedAt, setLeaderboardUpdatedAt] = useState(null); + const [leaderboardOwner, setLeaderboardOwner] = useState(null); + const [refreshingLeaderboard, setRefreshingLeaderboard] = useState(false); + const leaderboardRequestId = useRef(0); + const leaderboardRefreshActive = useRef(false); + const displaySurface = document.documentElement.dataset.displaySurface; + const rendersTopSurface = displaySurface !== "bottom"; + const rendersBottomSurface = displaySurface !== "top"; useEffect(() => { - if (selectedStat === "roguelike" || selectedStat === "rogue-trials-endless" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return; - setSelectedStat(collection?.bosses[0]?.bossId ?? "roguelike"); - }, [collection, selectedStat]); + if (!isBossProfileStat(selectedStat) || bosses.some((boss) => boss.bossId === selectedStat)) return; + setSelectedStat(bosses[0]?.bossId ?? "roguelike"); + }, [bosses, selectedStat, setSelectedStat]); useEffect(() => { - if (!hunter || collectionView !== "stats") return; + if (!rendersTopSurface || !hunter || profileView !== "stats" || activeSection === "bosses") return; + const requestId = ++leaderboardRequestId.current; + leaderboardRefreshActive.current = false; + setRefreshingLeaderboard(false); + const cached = leaderboardCache.read(hunter.slotId, selectedStat, hunter.hunterName, accountId); + setLeaderboard(cached?.result ?? null); + setLeaderboardUpdatedAt(cached?.updatedAt ?? null); + setLeaderboardOwner(cached?.accountId ?? accountId); + setLeaderboardStatus(cached + ? "" + : accountId + ? "No cached rankings. Refresh when online." + : "Sign in once, then refresh rankings for offline viewing."); + return () => { + if (leaderboardRequestId.current === requestId) leaderboardRequestId.current += 1; + }; + }, [accountId, activeSection, hunter, profileView, rendersTopSurface, selectedStat]); + const refreshLeaderboard = useCallback(async () => { + if (!rendersTopSurface || !hunter || leaderboardRefreshActive.current) return; if (!accountId) { - setLeaderboard(null); - setLeaderboardStatus("Sign in to view overall rankings."); + setLeaderboardStatus("Sign in to refresh online rankings."); return; } - let cancelled = false; - setLeaderboardStatus("Loading overall rankings…"); + if (!networkAppearsOnline()) { + setLeaderboardStatus("Offline. Cached rankings remain available."); + return; + } + const requestId = ++leaderboardRequestId.current; + leaderboardRefreshActive.current = true; + setRefreshingLeaderboard(true); + if (hasPendingSaveSync(hunter.slotId)) { + setLeaderboardStatus("Publishing local records…"); + const uploaded = await uploadSlot(hunter.slotId); + if (leaderboardRequestId.current !== requestId) return; + if (!uploaded) { + setLeaderboardStatus("Local records queued. Refresh when connection returns."); + leaderboardRefreshActive.current = false; + setRefreshingLeaderboard(false); + return; + } + } + setLeaderboardStatus("Refreshing online rankings…"); const request = selectedStat === "roguelike" ? onlineRepository.roguelikeLeaderboard(hunter.slotId) : selectedStat === "rogue-trials-endless" ? onlineRepository.rogueTrialsEndlessLeaderboard(hunter.slotId) + : selectedStat === "hockey-healing" + ? onlineRepository.hockeyHealingLeaderboard(hunter.slotId) + : selectedStat === "hockey-pvp-wins" + ? onlineRepository.hockeyPvpWinsLeaderboard(hunter.slotId) + : selectedStat === "hockey-pvp-boss-kills" + ? onlineRepository.hockeyPvpBossKillsLeaderboard(hunter.slotId) + : selectedStat === "blockbreaker-bricks" + ? onlineRepository.blockbreakerBricksLeaderboard(hunter.slotId) + : selectedStat === "blockbreaker-time" + ? onlineRepository.blockbreakerTimeLeaderboard(hunter.slotId) + : selectedStat === "blockbreaker-score" + ? onlineRepository.blockbreakerScoreLeaderboard(hunter.slotId) + : selectedStat === "aether-assault" + ? onlineRepository.aetherAssaultLeaderboard(hunter.slotId) : onlineRepository.bossLeaderboard(selectedStat, hunter.slotId); - void request.then((result) => { - if (cancelled) return; + try { + const result = await request; + const cached = leaderboardCache.write(accountId, hunter.hunterName, hunter.slotId, selectedStat, result); + if (leaderboardRequestId.current !== requestId) return; setLeaderboard(result); + setLeaderboardUpdatedAt(cached.updatedAt); + setLeaderboardOwner(accountId); setLeaderboardStatus(""); - }).catch((error) => { - if (cancelled) return; - setLeaderboard(null); + } catch (error) { + if (leaderboardRequestId.current !== requestId) return; setLeaderboardStatus(error instanceof Error ? error.message : "Leaderboard unavailable."); - }); - return () => { cancelled = true; }; - }, [accountId, collectionView, hunter, selectedStat]); + } finally { + if (leaderboardRequestId.current === requestId) { + leaderboardRefreshActive.current = false; + setRefreshingLeaderboard(false); + } + } + }, [accountId, hunter, rendersTopSurface, selectedStat, uploadSlot]); + const sectionMetricIds = activeSectionDefinition.statIds; + const bossGridColumns = 7; const actions = useMemo(() => [ - { id: "view-trophies", run: () => setCollectionView("trophies"), neighbors: { right: "view-stats" } }, - { id: "view-stats", run: () => setCollectionView("stats"), neighbors: { left: "view-trophies", right: "view-loot", down: collectionView === "stats" ? "stat-roguelike" : undefined } }, - { id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } }, - ...(collectionView === "stats" ? [ - { id: "stat-roguelike", run: () => setSelectedStat("roguelike"), neighbors: { up: "view-stats", down: "stat-rogue-trials-endless" } }, - { id: "stat-rogue-trials-endless", run: () => setSelectedStat("rogue-trials-endless"), neighbors: { up: "stat-roguelike", down: `stat-${collection.bosses[0].bossId}` } }, - ...collection.bosses.map((boss, index) => ({ - id: `stat-${boss.bossId}`, - run: () => setSelectedStat(boss.bossId), - neighbors: { - up: index === 0 ? "stat-rogue-trials-endless" : `stat-${collection.bosses[index - 1].bossId}`, - down: index === collection.bosses.length - 1 ? `group-${collection.groupId}` : `stat-${collection.bosses[index + 1].bossId}`, - }, - })), + ...(rendersTopSurface ? [ + { id: "view-stats", run: () => setCollectionView("stats"), neighbors: { right: "view-loot", down: profileView === "stats" ? "section-roguelike" : undefined } }, + { id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } }, + ...(profileView === "stats" ? [ + ...PROFILE_SECTIONS.map((section, index) => ({ + id: `section-${section.id}`, + run: () => setSelectedStat(defaultStatForSection(section.id, bosses)), + neighbors: { + up: index === 0 ? "view-stats" : `section-${PROFILE_SECTIONS[index - 1].id}`, + down: index === PROFILE_SECTIONS.length - 1 ? "section-roguelike" : `section-${PROFILE_SECTIONS[index + 1].id}`, + right: section.id === "bosses" ? `boss-card-${bosses[0]?.bossId}` : `metric-${section.statIds[0]}`, + }, + })), + ...(activeSection === "bosses" ? bosses.map((boss, index) => { + const rowStart = index % bossGridColumns === 0; + const rowEnd = index % bossGridColumns === bossGridColumns - 1 || index === bosses.length - 1; + return { + id: `boss-card-${boss.bossId}`, + run: () => setSelectedStat(boss.bossId), + neighbors: { + left: rowStart ? "section-bosses" : `boss-card-${bosses[index - 1].bossId}`, + right: rowEnd ? `boss-card-${boss.bossId}` : `boss-card-${bosses[index + 1].bossId}`, + up: index < bossGridColumns ? "view-stats" : `boss-card-${bosses[index - bossGridColumns].bossId}`, + down: bosses[index + bossGridColumns] ? `boss-card-${bosses[index + bossGridColumns].bossId}` : `boss-card-${boss.bossId}`, + }, + }; + }) : [ + ...sectionMetricIds.map((statId, index) => ({ + id: `metric-${statId}`, + run: () => setSelectedStat(statId), + neighbors: { + left: index === 0 ? `section-${activeSection}` : `metric-${sectionMetricIds[index - 1]}`, + right: index === sectionMetricIds.length - 1 ? "refresh-leaderboard" : `metric-${sectionMetricIds[index + 1]}`, + up: "view-stats", + down: "refresh-leaderboard", + }, + })), + { id: "refresh-leaderboard", run: () => { void refreshLeaderboard(); }, enabled: Boolean(accountId), neighbors: { left: `metric-${selectedStat}`, up: `metric-${selectedStat}` } }, + ]), + ] : []), + { id: "back", run: () => navigate("home") }, ] : []), - ...collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })), - { id: "back", run: () => navigate("home") }, - ], [collection, collectionView, collections, navigate]); + ...(rendersBottomSurface && profileView === "loot" ? collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })) : []), + ], [accountId, activeSection, bosses, collections, navigate, profileView, refreshLeaderboard, rendersBottomSurface, rendersTopSurface, sectionMetricIds, selectedStat, setCollectionView, setGroupId, setSelectedStat]); const controller = useMenuController(actions, { onBack: () => navigate("home") }); + useEffect(() => { + const hoveredSection = PROFILE_SECTIONS.find((section) => `section-${section.id}` === controller.selectedId); + if (hoveredSection) { + const nextStat = defaultStatForSection(hoveredSection.id, bosses); + if (nextStat !== selectedStat) setSelectedStat(nextStat); + return; + } + const hoveredMetric = sectionMetricIds.find((statId) => `metric-${statId}` === controller.selectedId); + if (hoveredMetric && hoveredMetric !== selectedStat) { + setSelectedStat(hoveredMetric); + return; + } + const hoveredBoss = bosses.find((boss) => `boss-card-${boss.bossId}` === controller.selectedId); + if (hoveredBoss && hoveredBoss.bossId !== selectedStat) setSelectedStat(hoveredBoss.bossId); + }, [bosses, controller.selectedId, sectionMetricIds, selectedStat, setSelectedStat]); if (!hunter || !collection) return null; const activeHealer = HEALER_CLASSES[hunter.activeClassId]; const activeProgress = hunter.healers[hunter.activeClassId]; const earned = collection.drops.filter((drop) => drop.count > 0).length; - const trophiesEarned = collection.bosses.filter((boss) => boss.pet.count > 0).length; + const petsOwned = bosses.filter((boss) => boss.pet.count > 0).length; + const hockeyDuration = `${Math.floor(hunter.stats.longestHockeyHealingSecondsAtBest / 60)}:${String(Math.floor(hunter.stats.longestHockeyHealingSecondsAtBest % 60)).padStart(2, "0")}`; + const blockbreakerDuration = `${Math.floor(hunter.stats.longestBlockbreakerSeconds / 60)}:${String(Math.floor(hunter.stats.longestBlockbreakerSeconds % 60)).padStart(2, "0")}`; + const aetherDuration = `${Math.floor(hunter.stats.longestAetherAssaultSecondsAtBest / 60)}:${String(Math.floor(hunter.stats.longestAetherAssaultSecondsAtBest % 60)).padStart(2, "0")}`; + const metrics: { id: ProfileStatId; label: string; value: string; copy: string }[] = activeSection === "roguelike" + ? [{ id: "roguelike", label: "Highest round", value: hunter.stats.highestRoguelikeRound.toLocaleString(), copy: "Best run before defeat" }] + : activeSection === "rogue-trials" + ? [{ id: "rogue-trials-endless", label: "Endless best", value: hunter.stats.highestRogueTrialsEndlessKills.toLocaleString(), copy: "Bosses defeated in one run" }] + : activeSection === "hockey" + ? [{ id: "hockey-healing", label: "Hockey record", value: `${hunter.stats.highestHockeyHealingReturns} returns`, copy: `${hockeyDuration} longest survival` }] + : activeSection === "hockey-pvp" + ? [ + { id: "hockey-pvp-wins", label: "Match record", value: `${hunter.stats.hockeyHealingPvpWins}W · ${hunter.stats.hockeyHealingPvpLosses}L`, copy: "Lifetime PVP results" }, + { id: "hockey-pvp-boss-kills", label: "Boss race kills", value: hunter.stats.hockeyHealingPvpBossKills.toLocaleString(), copy: "Lifetime PVP bosses" }, + ] + : activeSection === "aether-assault" + ? [{ id: "aether-assault", label: "Overall score", value: hunter.stats.highestAetherAssaultScore.toLocaleString(), copy: `Wave ${hunter.stats.highestAetherAssaultWaveAtBest} · ${aetherDuration}` }] + : [ + { id: "blockbreaker-score", label: "Overall score", value: hunter.stats.highestBlockbreakerScore.toLocaleString(), copy: "Highest single-run score" }, + { id: "blockbreaker-bricks", label: "Bricks broken", value: hunter.stats.highestBlockbreakerBricks.toLocaleString(), copy: "Most in one run" }, + { id: "blockbreaker-time", label: "Time survived", value: blockbreakerDuration, copy: "Longest run" }, + ]; const selectedStatValue = selectedStat === "roguelike" ? hunter.stats.highestRoguelikeRound : selectedStat === "rogue-trials-endless" ? hunter.stats.highestRogueTrialsEndlessKills - : hunter.stats.bossKills[selectedStat] ?? 0; + : selectedStat === "hockey-healing" + ? hunter.stats.highestHockeyHealingReturns + : selectedStat === "hockey-pvp-wins" + ? hunter.stats.hockeyHealingPvpWins + : selectedStat === "hockey-pvp-boss-kills" + ? hunter.stats.hockeyHealingPvpBossKills + : selectedStat === "blockbreaker-bricks" + ? hunter.stats.highestBlockbreakerBricks + : selectedStat === "blockbreaker-time" + ? hunter.stats.longestBlockbreakerSeconds + : selectedStat === "blockbreaker-score" + ? hunter.stats.highestBlockbreakerScore + : selectedStat === "aether-assault" + ? hunter.stats.highestAetherAssaultScore + : isBossProfileStat(selectedStat) + ? hunter.stats.bossKills[selectedStat] ?? 0 + : 0; const selectedStatLabel = selectedStat === "roguelike" ? "Roguelike rounds" : selectedStat === "rogue-trials-endless" ? "Rogue Trials endless kills" - : BOSS_DEFINITIONS[selectedStat].name; + : selectedStat === "hockey-healing" + ? "Hockey Healing returns" + : selectedStat === "hockey-pvp-wins" + ? "Healing Hockey PVP wins" + : selectedStat === "hockey-pvp-boss-kills" + ? "Healing Hockey PVP boss kills" + : selectedStat === "blockbreaker-bricks" + ? "Blockbreaker bricks broken" + : selectedStat === "blockbreaker-time" + ? "Blockbreaker survival time" + : selectedStat === "blockbreaker-score" + ? "Blockbreaker overall score" + : selectedStat === "aether-assault" + ? "Aether Assault score" + : isBossProfileStat(selectedStat) + ? BOSS_DEFINITIONS[selectedStat].name + : "Hunter record"; + const leaderboardValue = (value: number, secondaryValue?: number) => selectedStat === "hockey-healing" + ? `${value} · ${Math.floor((secondaryValue ?? 0) / 60)}:${String(Math.floor((secondaryValue ?? 0) % 60)).padStart(2, "0")}` + : selectedStat === "hockey-pvp-wins" + ? `${value}W · ${secondaryValue ?? 0}L` + : selectedStat === "blockbreaker-time" + ? `${Math.floor(value / 60)}:${String(Math.floor(value % 60)).padStart(2, "0")}` + : selectedStat === "aether-assault" + ? `${value.toLocaleString()} · Wave ${secondaryValue ?? 0}` + : value.toLocaleString(); + const selectedStatSummary = selectedStat === "hockey-healing" + ? `${selectedStatValue} returns · ${hockeyDuration}` + : selectedStat === "hockey-pvp-wins" + ? `${hunter.stats.hockeyHealingPvpWins}W · ${hunter.stats.hockeyHealingPvpLosses}L` + : selectedStat === "blockbreaker-time" + ? `${Math.floor(selectedStatValue / 60)}:${String(Math.floor(selectedStatValue % 60)).padStart(2, "0")} survived` + : selectedStat === "blockbreaker-score" + ? `${selectedStatValue.toLocaleString()} points` + : selectedStat === "aether-assault" + ? `${selectedStatValue.toLocaleString()} points · Wave ${hunter.stats.highestAetherAssaultWaveAtBest}` + : selectedStat === "blockbreaker-bricks" + ? `${selectedStatValue.toLocaleString()} bricks` + : `${selectedStatValue.toLocaleString()} ${selectedStat === "roguelike" ? "round" : "kills"}`; + const selectedSecondaryValue = selectedStat === "hockey-healing" + ? hunter.stats.longestHockeyHealingSecondsAtBest + : selectedStat === "hockey-pvp-wins" + ? hunter.stats.hockeyHealingPvpLosses + : selectedStat === "aether-assault" + ? hunter.stats.highestAetherAssaultWaveAtBest + : undefined; + const leaderboardMeta = leaderboardStatus + || (leaderboardUpdatedAt ? `Cached ${formatSaveTimestamp(leaderboardUpdatedAt)}` : "Offline cache empty"); return ( -
Hunter profile

Collection log

- setCollectionView("trophies")}>Trophy Case - setCollectionView("stats")}>Boss Stats - setCollectionView("loot")}>Group Loot -
navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
- {collectionView === "loot" ? <> +
Hunter profile

Records

+ setCollectionView("stats")}>Records + setCollectionView("loot")}>Group Loot +
navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
+ {profileView === "loot" ? <>
Shared group drops · Core: {collection.coreMechanic}

Group {collection.groupLetter} · {collection.groupName}

{earned} / {collection.drops.length} discovered
{collection.drops.map((drop) => ( @@ -654,56 +895,77 @@ function ProfileScreen() { ))}
-
Boss pets stay individual.Open Trophy Case to inspect every guardian pet.
- : collectionView === "trophies" ? <> -
Boss pets · 1 in 500 per victory

Group {collection.groupLetter} · {collection.groupName}

{trophiesEarned} / {collection.bosses.length} trophies lit
-
- {collection.bosses.map((boss) => { - const owned = boss.pet.count > 0; - return
- {BOSS_DEFINITIONS[boss.bossId].icon}
}> -
{owned ? "Pet secured" : "Pet undiscovered"}{boss.bossName}{boss.kills} kills · {boss.pet.chance}
- {owned ? `Owned${boss.pet.count > 1 ? ` ×${boss.pet.count}` : ""}` : "Locked"} - ; - })} -
-
Each guardian keeps its own trophy.Defeat that boss for a 1 in 500 pet roll.
+
Boss pets live in the Bosses record grid.Every boss, kill count, and pet status stays together.
: <> -
Lifetime records · Overall leaderboards

Boss Stats

Endless best {hunter.stats.highestRogueTrialsEndlessKills} kills
-
-
- setSelectedStat("roguelike")}>RoguelikeHighest round before defeat{hunter.stats.highestRoguelikeRound} - setSelectedStat("rogue-trials-endless")}>Trials EndlessMost bosses in one run{hunter.stats.highestRogueTrialsEndlessKills} - {collection.bosses.map((boss) => setSelectedStat(boss.bossId)}>{BOSS_DEFINITIONS[boss.bossId].icon}{boss.bossName}Lifetime boss kills{boss.kills})} -
-
-
Overall Top 5{selectedStatLabel}{selectedStatValue} {selectedStat === "roguelike" ? "round" : "kills"}
- {leaderboardStatus ?
{leaderboardStatus}
:
- {leaderboard?.top.length ? leaderboard.top.map((entry) =>
#{entry.rank}{entry.hunterName}{entry.username}{entry.value}
) :
No ranked hunters yet.
} -
} -
{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}Your rank · {hunter.hunterName}{accountId ?? "Offline hunter"}{selectedStatValue}
+
Lifetime records · Controller-first collection log

Hunter Records

{petsOwned} / {bosses.length} boss pets found
+
+
+ {PROFILE_SECTIONS.map((section) => setSelectedStat(defaultStatForSection(section.id, bosses))}>{section.icon}{section.label}{section.copy})}
+ {activeSection === "bosses" ?
+
Boss pets · A–ZEvery guardian{hunter.stats.totalBossKills.toLocaleString()} total kills
+
+ {bosses.map((boss) => { + const owned = boss.pet.count > 0; + return setSelectedStat(boss.bossId)}> + + Kills{boss.kills.toLocaleString()} + {boss.bossName} + {owned ? `Pet owned${boss.pet.count > 1 ? ` ×${boss.pet.count}` : ""}` : "Pet not found"} + ; + })} +
+
:
+
All {activeSectionDefinition.label} stats{activeSectionDefinition.copy}{metrics.length} record{metrics.length === 1 ? "" : "s"}
+
+ {metrics.map((metric) => setSelectedStat(metric.id)}>{metric.label}{metric.value}{metric.copy})} +
+
+
{leaderboardMeta}{selectedStatLabel}
{selectedStatSummary} { void refreshLeaderboard(); }}>{refreshingLeaderboard ? "Refreshing…" : "Refresh online"}
+ {leaderboard ?
+ {leaderboard.top.length ? leaderboard.top.slice(0, 4).map((entry) =>
#{entry.rank}{entry.hunterName}{entry.username}{leaderboardValue(entry.value, entry.secondaryValue)}
) :
No ranked hunters yet.
} +
:
{leaderboardStatus || "No cached rankings."}
} +
{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}Your rank · {hunter.hunterName}{leaderboardOwner ?? "Offline hunter"}{leaderboardValue(selectedStatValue, selectedSecondaryValue)}
+
+
}
-
Rankings update with server saves.Top five always shown; your row stays visible at any rank.
+
{activeSection === "bosses" ? "♛" : "◆"}{activeSection === "bosses" ? "Move through the grid to inspect a boss below." : "Every stat in this section stays visible together."}{activeSection === "bosses" ? "The lower screen shows kills, pet ownership, and collection details." : "Select a record card to change the online leaderboard."}
} } bottom={ - -
{hunter.hunterName} · {activeHealer.name} statsLEVEL {activeProgress.level}
-
- Total boss kills{hunter.stats.totalBossKills} - Flawless clears{hunter.stats.flawlessClears} - Allies saved{hunter.stats.alliesSaved} - Healing done{hunter.stats.healingDone.toLocaleString()} - Highest roguelike round{hunter.stats.highestRoguelikeRound} - Endless best{hunter.stats.highestRogueTrialsEndlessKills} -
-
Mechanic groups{collections.map((group) => ( - setGroupId(group.groupId)}> - {group.defeated ? group.groupLetter : "?"}Group {group.groupLetter} · {group.groupName}{group.bosses.reduce((sum, boss) => sum + boss.kills, 0)} kills · {group.coreMechanic}{group.drops.filter((drop) => drop.count > 0).length}/{group.drops.length} - - ))}
+ + {profileView === "stats" && activeSection === "bosses" && selectedBoss ? <> +
Boss record · {selectedBoss.bossName}{selectedBoss.pet.count > 0 ? "PET OWNED" : "PET NOT FOUND"}
+
+
0 ? "is-owned" : "is-unowned"}`}>{selectedBoss.pet.icon}Boss pet icon
+
{BOSS_DEFINITIONS[selectedBoss.bossId].title}

{selectedBoss.bossName}

{BOSS_DEFINITIONS[selectedBoss.bossId].summary}

+
+
+ Times killed{selectedBoss.kills.toLocaleString()} + Boss pet{selectedBoss.pet.count > 0 ? "Owned" : "Not found"} + Pet copies{selectedBoss.pet.count.toLocaleString()} + Drop chance{selectedBoss.pet.chance} +
+
Encounter read{BOSS_DEFINITIONS[selectedBoss.bossId].briefing}Move on upper-screen grid to inspect another boss.
+ : <> +
{hunter.hunterName} · {activeHealer.name} statsLEVEL {activeProgress.level}
+
+ Total boss kills{hunter.stats.totalBossKills} + Flawless clears{hunter.stats.flawlessClears} + Allies saved{hunter.stats.alliesSaved} + Healing done{hunter.stats.healingDone.toLocaleString()} + Highest roguelike round{hunter.stats.highestRoguelikeRound} + Endless best{hunter.stats.highestRogueTrialsEndlessKills} + Hockey PVP record{hunter.stats.hockeyHealingPvpWins}W · {hunter.stats.hockeyHealingPvpLosses}L + Boss pets found{petsOwned}/{bosses.length} +
+ {profileView === "loot" &&
Mechanic groups{collections.map((group) => ( + setGroupId(group.groupId)}> + {group.defeated ? group.groupLetter : "?"}Group {group.groupLetter} · {group.groupName}{group.bosses.reduce((sum, boss) => sum + boss.kills, 0)} kills · {group.coreMechanic}{group.drops.filter((drop) => drop.count > 0).length}/{group.drops.length} + + ))}
} + }
} /> @@ -755,9 +1017,9 @@ function GearScreen() { const infusionEquipped = hunter?.gearProgress[selectedOwnerId].infusionAbilityId === selectedInfusion.id; const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts)); const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress)); - const healerOwner = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman"; - const passiveHealerClassId = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman" ? selectedOwnerId : "priest"; - const passiveChoices = PASSIVE_INFUSIONS.filter((passive) => passive.abilityId === selectedPassiveAbilityId); + const healerOwner = isHealerClassId(selectedOwnerId); + const passiveHealerClassId = isHealerClassId(selectedOwnerId) ? selectedOwnerId : "priest"; + const passiveChoices = PASSIVE_INFUSIONS.filter((passive) => passive.abilitySlotId === selectedPassiveAbilityId); const selectedPassive = RUN_BUFFS[selectedPassiveInfusionId]; const healerAbilities = HEALER_CLASSES[passiveHealerClassId].abilities; const previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`; @@ -823,7 +1085,7 @@ function GearScreen() { { id: "back", run: () => navigate("home"), neighbors: { left: "workshop-infusion", down: `owner-${GEAR_OWNER_ORDER[0]}` } }, ], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveChoices, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectPassiveAbility, selectPassiveInfusion, selectSlot, selectWorkshopMode, selectedOwnerId, selectedPassiveAbilityId, selectedSlotId, upgrade]); const controller = useMenuController(actions, { onBack: () => navigate("home") }); - const passiveContext = workshopMode === "infusion" && healerOwner && controller.focusedId.startsWith("passive-"); + const passiveContext = workshopMode === "infusion" && healerOwner && controller.selectedId.startsWith("passive-"); if (!hunter || !slot) return null; const currentBonus = gearBonusText(recipe.statId, slot.level); const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1)); @@ -832,13 +1094,13 @@ function GearScreen() { -
Group drop workshop

Gear & Infusions

selectWorkshopMode("upgrade")}>Upgrade selectWorkshopMode("infusion")}>Infusion
navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
+
Group drop workshop

Gear & Infusions

selectWorkshopMode("upgrade")}>Upgrade selectWorkshopMode("infusion")}>Infusion
navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
{GEAR_OWNER_ORDER.map((ownerId) => { const highest = Math.max(...GEAR_SLOT_ORDER.map((slotId) => hunter.gearProgress[ownerId].slots[slotId].level)); const upgradeReady = upgradeReadiness.owners.has(ownerId); - return selectOwner(ownerId)}>{GEAR_OWNER_LABELS[ownerId]}Highest slot +{highest}{ownerId === selectedOwnerId ? "✓" : ""}; + return selectOwner(ownerId)}>{GEAR_OWNER_LABELS[ownerId]}Highest slot +{highest}{ownerId === selectedOwnerId ? "✓" : ""}; })}
@@ -846,7 +1108,7 @@ function GearScreen() { const progress = hunter.gearProgress[selectedOwnerId].slots[slotId]; const slotRecipe = GEAR_RECIPES[selectedOwnerId][slotId]; const upgradeReady = upgradeReadiness.slots.has(`${selectedOwnerId}:${slotId}`); - return selectSlot(slotId)}>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}{GEAR_SLOT_LABELS[slotId]}{GEAR_STAT_LABELS[slotRecipe.statId]}+{progress.level}; + return selectSlot(slotId)}>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}{GEAR_SLOT_LABELS[slotId]}{GEAR_STAT_LABELS[slotRecipe.statId]}+{progress.level}; })}
{workshopMode === "upgrade" ?
@@ -859,25 +1121,24 @@ function GearScreen() {

{selectedInfusion.icon} {selectedInfusion.name}

{selectedInfusion.description} Anchor purchase to a +{ACTIVE_INFUSION_MIN_GEAR_LEVEL} slot.

- {infusionChoices.map((infusion) => selectInfusion(infusion.id)}>{infusion.icon}{infusion.name}{infusion.description}{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""})} + {infusionChoices.map((infusion) => selectInfusion(infusion.id)}>{infusion.icon}{infusion.name}{infusion.description}{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""})}
{healerOwner &&
Passive blessing · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}
- {ABILITY_ORDER.map((abilityId) => selectPassiveAbility(abilityId)}>{healerAbilities[abilityId].shortName})} + {ABILITY_ORDER.map((abilityId) => selectPassiveAbility(abilityId)}>{healerAbilities[abilityId].shortName})}
- {passiveChoices.map((passive) => selectPassiveInfusion(passive.id)} onPointerEnter={() => selectPassiveInfusion(passive.id)} onClick={() => { selectPassiveInfusion(passive.id); installPassive(passive.id); }} - >{passive.icon}{healerAbilities[passive.abilityId].shortName}: {passive.name}{formatRunBuffEffect(passive.id, 1)}{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""})} + >{passive.icon}{healerAbilities[passive.abilitySlotId].shortName}: {passive.name}{formatRunBuffEffect(passive.id, 1, healerAbilities[passive.abilitySlotId].shortName)}{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""})}
}
} @@ -887,15 +1148,15 @@ function GearScreen() { } bottom={ -
{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : passiveContext ? `${healerAbilities[selectedPassive.abilityId].name}: ${selectedPassive.name}` : `${selectedInfusion.name} infusion`}{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS
+
{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : passiveContext ? `${healerAbilities[selectedPassive.abilitySlotId].name}: ${selectedPassive.name}` : `${selectedInfusion.name} infusion`}{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS
{workshopMode === "upgrade" ? "Upgrade requirements" : passiveContext ? "Passive blessing · Rank 1" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`} - {passiveContext ?
{passiveUnlocked ? "✓" : "×"}{formatRunBuffEffect(selectedPassive.id, 1)}{selectedPassive.detail}{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "EQUIPPED" : "RANK 1"}
: (workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => { + {passiveContext ?
{passiveUnlocked ? "✓" : "×"}{formatRunBuffEffect(selectedPassive.id, 1, healerAbilities[selectedPassive.abilitySlotId].shortName)}{selectedPassive.detail}{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "EQUIPPED" : "RANK 1"}
: (workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => { const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0; return
= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}>{owned >= cost.quantity ? "✓" : "×"}{cost.itemName}{owned} owned · {cost.quantity} needed{owned}/{cost.quantity}
; }) :
Maximum rank reachedNo more materials required.+{MAX_GEAR_LEVEL}
}
- {workshopMode === "upgrade" ? {slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"} : passiveContext ?
{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : `${DEFAULT_CONTROLLER_GLYPHS.confirm} · Equip selected passive`}Applies at rank 1 next encounter.
: {infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}} + {workshopMode === "upgrade" ? {slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"} : passiveContext ?
{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : `${DEFAULT_CONTROLLER_GLYPHS.confirm} · Equip selected passive`}Applies at rank 1 next encounter.
: {infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}}
{notice || "Gear changes save locally and apply when next encounter starts."}
} @@ -903,8 +1164,186 @@ function GearScreen() { ); } -function SettingToggle({ id, label, copy, value, focusedId, focus, onClick }: { id: string; label: string; copy: string; value: boolean; focusedId: string; focus: (id: string) => void; onClick: () => void }) { - return {label}{copy}{value ? "ON" : "OFF"}; +const APPEARANCE_PREVIEW_ANIMATIONS = [ + { id: "idle", label: "Idle" }, + { id: "walk", label: "Walk" }, + { id: "cast", label: "Cast" }, +] as const; + +function appearanceControlId(slotId: AppearanceSlotId, direction: "previous" | "next") { + return `appearance-${slotId}-${direction}`; +} + +function AppearanceScreen() { + const hunter = useActiveHunter(); + const classId = useFrontendStore((state) => state.appearanceClassId); + const drafts = useFrontendStore((state) => state.appearanceDrafts); + const previewMode = useFrontendStore((state) => state.previewMode); + const previewAnimation = useFrontendStore((state) => state.previewAnimation); + const notice = useFrontendStore((state) => state.notice); + const selectAppearanceClass = useFrontendStore((state) => state.selectAppearanceClass); + const updateAppearanceDraft = useFrontendStore((state) => state.updateAppearanceDraft); + const resetAppearanceDraft = useFrontendStore((state) => state.resetAppearanceDraft); + const saveAppearanceDraft = useFrontendStore((state) => state.saveAppearanceDraft); + const closeAppearanceLab = useFrontendStore((state) => state.closeAppearanceLab); + const setAppearancePreviewMode = useFrontendStore((state) => state.setAppearancePreviewMode); + const setAppearancePreviewAnimation = useFrontendStore((state) => state.setAppearancePreviewAnimation); + const draft = drafts[classId] ?? createDefaultHealerAppearance(classId); + const saved = hunter?.healers[classId].appearance ?? createDefaultHealerAppearance(classId); + const dirtyClassIds = HEALER_CLASS_ORDER.filter((candidate) => !appearancesMatch( + drafts[candidate] ?? createDefaultHealerAppearance(candidate), + hunter?.healers[candidate].appearance ?? createDefaultHealerAppearance(candidate), + )); + const currentDirty = !appearancesMatch(draft, saved); + const dirtyCount = dirtyClassIds.length; + const [discardArmed, setDiscardArmed] = useState(false); + const effectivePreviewMode = CHARACTER_MODEL_MODE === "legacy" ? "legacy" : previewMode; + const changeSlot = useCallback((slotId: AppearanceSlotId, direction: -1 | 1) => { + updateAppearanceDraft(cycleAppearanceSlot(draft, slotId, direction)); + }, [draft, updateAppearanceDraft]); + const requestClose = useCallback(() => { + if (dirtyCount > 0 && !discardArmed) { + setDiscardArmed(true); + return; + } + closeAppearanceLab(); + }, [closeAppearanceLab, dirtyCount, discardArmed]); + useEffect(() => setDiscardArmed(false), [classId, drafts, previewAnimation, previewMode]); + const actions = useMemo(() => { + const classActions = HEALER_CLASS_ORDER.map((candidate, index) => ({ + id: `appearance-class-${candidate}`, + run: () => selectAppearanceClass(candidate), + neighbors: { + left: `appearance-class-${HEALER_CLASS_ORDER[(index - 1 + HEALER_CLASS_ORDER.length) % HEALER_CLASS_ORDER.length]}`, + right: `appearance-class-${HEALER_CLASS_ORDER[(index + 1) % HEALER_CLASS_ORDER.length]}`, + down: "appearance-animation-idle", + }, + })); + const animationActions = APPEARANCE_PREVIEW_ANIMATIONS.map((animation, index) => ({ + id: `appearance-animation-${animation.id}`, + run: () => setAppearancePreviewAnimation(animation.id), + neighbors: { + left: `appearance-animation-${APPEARANCE_PREVIEW_ANIMATIONS[(index - 1 + APPEARANCE_PREVIEW_ANIMATIONS.length) % APPEARANCE_PREVIEW_ANIMATIONS.length].id}`, + right: `appearance-animation-${APPEARANCE_PREVIEW_ANIMATIONS[(index + 1) % APPEARANCE_PREVIEW_ANIMATIONS.length].id}`, + up: `appearance-class-${classId}`, + down: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[0].id, index === 0 ? "previous" : "next"), + }, + })); + const slotActions = APPEARANCE_SLOT_DEFINITIONS.flatMap((slot, index) => { + const previousRow = index === 0 ? "appearance-animation-idle" : appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[index - 1].id, "previous"); + const nextRow = index === APPEARANCE_SLOT_DEFINITIONS.length - 1 ? "appearance-compare" : appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[index + 1].id, "previous"); + const previous = appearanceControlId(slot.id, "previous"); + const next = appearanceControlId(slot.id, "next"); + const enabled = appearanceSlotEnabled(draft, slot.id); + return [ + { id: previous, run: () => changeSlot(slot.id, -1), enabled, neighbors: { left: next, right: next, up: previousRow, down: nextRow } }, + { id: next, run: () => changeSlot(slot.id, 1), enabled, neighbors: { left: previous, right: previous, up: index === 0 ? "appearance-animation-cast" : appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[index - 1].id, "next"), down: index === APPEARANCE_SLOT_DEFINITIONS.length - 1 ? "appearance-save" : appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[index + 1].id, "next") } }, + ]; + }); + return [ + ...classActions, + ...animationActions, + ...slotActions, + { id: "appearance-compare", run: () => setAppearancePreviewMode(previewMode === "modular" ? "legacy" : "modular"), enabled: CHARACTER_MODEL_MODE !== "legacy", neighbors: { left: "appearance-close", right: "appearance-reset", up: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS.at(-1)!.id, "previous") } }, + { id: "appearance-reset", run: resetAppearanceDraft, neighbors: { left: "appearance-compare", right: "appearance-save", up: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS.at(-1)!.id, "previous") } }, + { id: "appearance-save", run: () => { saveAppearanceDraft(); }, neighbors: { left: "appearance-reset", right: "appearance-close", up: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS.at(-1)!.id, "next") } }, + { id: "appearance-close", run: requestClose, neighbors: { left: "appearance-save", right: "appearance-compare", up: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS.at(-1)!.id, "next") } }, + ]; + }, [changeSlot, classId, draft, previewMode, requestClose, resetAppearanceDraft, saveAppearanceDraft, selectAppearanceClass, setAppearancePreviewAnimation, setAppearancePreviewMode]); + const controller = useMenuController(actions, { initialId: `appearance-class-${classId}`, onBack: requestClose }); + if (!hunter) return null; + const healer = HEALER_CLASSES[classId]; + const legacyActive = effectivePreviewMode === "legacy"; + + return ( + +
+ +
Character workshop

Appearance Lab

+ {DEFAULT_CONTROLLER_GLYPHS.back} · {discardArmed ? "Confirm discard" : dirtyCount > 0 ? `Cancel (${dirtyCount} unsaved)` : "Close"} +
+
+ Assembling shared rig
}> + + +
+ {legacyActive ? "LEGACY WHOLE MODEL" : "MODULAR LIVE PREVIEW"} + {legacyActive ? "Saved parts preserved but intentionally ignored" : `${previewAnimation} animation · shared Rig_Medium`} +
+
+ {healer.specialization} + {healer.name} + {currentDirty ? "UNSAVED CHANGES" : "SAVED LOOK"} +
+
+
+ {DEFAULT_CONTROLLER_GLYPHS.select} / TAB Open lower-screen controls + Every choice previews on same animation skeleton +
+
+ } + bottom={ + +
Appearance controls{dirtyCount > 0 ? `${dirtyCount} UNSAVED` : "SAVED LOCALLY"}
+
+ {HEALER_CLASS_ORDER.map((candidate) => { + const candidateHealer = HEALER_CLASSES[candidate]; + return selectAppearanceClass(candidate)} + >{candidateHealer.icon}{candidateHealer.name}; + })} +
+
+ {APPEARANCE_PREVIEW_ANIMATIONS.map((animation) => setAppearancePreviewAnimation(animation.id)} + >{animation.label})} +
+
+ {APPEARANCE_SLOT_DEFINITIONS.map((slot) => { + const active = controller.selectedId.startsWith(`appearance-${slot.id}-`); + const enabled = appearanceSlotEnabled(draft, slot.id); + return
+ {slot.label}{slot.assetNote} + changeSlot(slot.id, -1)}>‹ + {appearanceSlotLabel(draft, slot.id)} + changeSlot(slot.id, 1)}>› +
; + })} +
+
+ setAppearancePreviewMode(previewMode === "modular" ? "legacy" : "modular")}>{legacyActive ? "Show custom" : "Compare legacy"}{CHARACTER_MODEL_MODE === "legacy" ? "Rollout locked" : "No save change"} + ResetClass default + { saveAppearanceDraft(); }}>Save lookCurrent healer + {discardArmed ? "Confirm" : "Cancel"}{discardArmed ? "Press again" : dirtyCount > 0 ? `Discard ${dirtyCount}` : "Close lab"} +
+
0 ? "is-dirty" : ""}`}>{discardArmed ? `Discard ${dirtyCount} unsaved healer ${dirtyCount === 1 ? "look" : "looks"}? Press Cancel or Back again.` : CHARACTER_MODEL_MODE === "legacy" ? "Legacy rollout active. Modular choices remain saved for later." : currentDirty ? "Preview changed. Save look to use it in gameplay." : dirtyCount > 0 ? `${dirtyCount} other healer ${dirtyCount === 1 ? "look is" : "looks are"} still unsaved. Switch classes to save them.` : notice || "Saved look will load in the next encounter."}
+
+ } + /> + ); +} + +function SettingToggle({ id, label, copy, value, selectedId, select, onClick }: { id: string; label: string; copy: string; value: boolean; selectedId: string; select: (id: string) => void; onClick: () => void }) { + return {label}{copy}{value ? "ON" : "OFF"}; } function SettingsScreen() { @@ -926,10 +1365,10 @@ function SettingsScreen() { -
Field configuration

Settings

navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
+
Field configuration

Settings

navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
-
Audio
Master volumeAll music, effects, and voice
updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}>−{settings.masterVolume}% updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}>+
-
Display & accessibility updateSetting("reducedMotion", !settings.reducedMotion)} /> updateSetting("damageNumbers", !settings.damageNumbers)} /> updateSetting("largeText", !settings.largeText)} />
+
Audio
Master volumeAll music, effects, and voice
updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}>−{settings.masterVolume}% updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}>+
+
Display & accessibility updateSetting("reducedMotion", !settings.reducedMotion)} /> updateSetting("damageNumbers", !settings.damageNumbers)} /> updateSetting("largeText", !settings.largeText)} />
{notice || "Settings write to offline storage immediately."}
@@ -942,15 +1381,16 @@ function SettingsScreen() {
{DEFAULT_CONTROLLER_GLYPHS.faceTop}{DEFAULT_CONTROLLER_GLYPHS.faceLeft}{DEFAULT_CONTROLLER_GLYPHS.faceRight}{DEFAULT_CONTROLLER_GLYPHS.faceBottom}
{DEFAULT_CONTROLLER_GLYPHS.confirm} Confirm / cast Purify{DEFAULT_CONTROLLER_GLYPHS.back} Back / cast ShieldD-Pad Navigate / target partyRight stick Rotate camera{DEFAULT_CONTROLLER_GLYPHS.start} Pause / menu
-
No click-to-focus requiredController input routes through app-level actions.
+
No app focus requiredNative controller input routes through app-level actions.
} /> ); } -function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => void }) { +function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"], hockeyPvpMatch?: HockeyPvpMatchConfig) => void }) { const hunter = useActiveHunter(); + const accountId = useFrontendStore((state) => state.accountId); const modeId = useFrontendStore((state) => state.selectedMode); const selectedBossId = useFrontendStore((state) => state.selectedBossId); const selectedDifficultySlug = useFrontendStore((state) => state.selectedDifficultySlug); @@ -958,6 +1398,13 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi const selectDifficulty = useFrontendStore((state) => state.selectDifficulty); const navigate = useFrontendStore((state) => state.navigate); const [message, setMessage] = useState(""); + const [queueing, setQueueing] = useState(false); + const [queueElapsed, setQueueElapsed] = useState(0); + const queueActive = useRef(false); + const queueTicket = useRef(null); + const queuePollTimer = useRef(null); + const queueCpuTimer = useRef(null); + const queueClockTimer = useRef(null); const mode = MODE_COPY[modeId]; const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest; const progress = hunter?.healers[hunter.activeClassId]; @@ -967,14 +1414,118 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi const isRogueTrials = modeId === "rogue-trials"; const isPveRun = isPve || isRogueTrials; const isDungeon = modeId === "dungeons"; + const isHockey = modeId === "hockey-healing"; + const isHockeyPvp = modeId === "hockey-healing-pvp"; + const isBlockbreaker = modeId === "blockbreaker"; + const isAetherAssault = modeId === "aether-assault"; const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId]; const visibleBossIds = selectedBossGroup.bossIds; const bossGridColumns = Math.min(2, visibleBossIds.length); const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => { selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]); }; + const clearQueueTimers = () => { + if (queuePollTimer.current !== null) window.clearTimeout(queuePollTimer.current); + if (queueCpuTimer.current !== null) window.clearTimeout(queueCpuTimer.current); + if (queueClockTimer.current !== null) window.clearInterval(queueClockTimer.current); + queuePollTimer.current = null; + queueCpuTimer.current = null; + queueClockTimer.current = null; + }; + const completePvpQueue = (match: HockeyPvpMatchConfig) => { + if (!queueActive.current) return; + queueActive.current = false; + clearQueueTimers(); + setQueueing(false); + setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`); + onLaunch([hockeyPvpBossAt(match.seed, 0)], "initiate", match); + }; + const fallbackToCpu = () => { + if (!queueActive.current) return; + const ticketId = queueTicket.current; + queueTicket.current = null; + if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined); + completePvpQueue({ + matchId: null, + seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)), + opponentName: randomHockeyPvpCpuName(), + role: "cpu", + }); + }; + const cancelPvpQueue = () => { + if (!queueActive.current) return; + queueActive.current = false; + clearQueueTimers(); + const ticketId = queueTicket.current; + queueTicket.current = null; + if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined); + setQueueing(false); + setQueueElapsed(0); + setMessage("Matchmaking cancelled."); + }; + const startPvpQueue = async () => { + if (!hunter || queueActive.current) return; + queueActive.current = true; + setQueueing(true); + setQueueElapsed(0); + setMessage(accountId ? "Searching online queue…" : "Offline queue: searching before CPU fallback…"); + const startedAt = Date.now(); + queueClockTimer.current = window.setInterval(() => setQueueElapsed(Date.now() - startedAt), 100); + queueCpuTimer.current = window.setTimeout(fallbackToCpu, HOCKEY_PVP_QUEUE_TIMEOUT_MS); + if (!accountId || !networkAppearsOnline()) return; + try { + const joined = await onlineRepository.joinHockeyPvpQueue(hunter.slotId, hunter.hunterName); + if (!queueActive.current) return; + queueTicket.current = joined.ticketId; + if (joined.match) { + completePvpQueue({ + matchId: joined.match.id, + seed: joined.match.seed, + opponentName: joined.match.opponentName, + role: joined.match.role, + }); + return; + } + const poll = async () => { + if (!queueActive.current || !queueTicket.current) return; + try { + const result = await onlineRepository.pollHockeyPvpQueue(queueTicket.current); + if (!queueActive.current) return; + if (result.match) { + completePvpQueue({ + matchId: result.match.id, + seed: result.match.seed, + opponentName: result.match.opponentName, + role: result.match.role, + }); + return; + } + } catch { + // Five-second CPU fallback remains authoritative during transient outages. + } + if (queueActive.current) queuePollTimer.current = window.setTimeout(poll, 350); + }; + queuePollTimer.current = window.setTimeout(poll, 350); + } catch { + setMessage("Online queue unavailable. CPU fallback still searching…"); + } + }; + useEffect(() => () => { + queueActive.current = false; + clearQueueTimers(); + const ticketId = queueTicket.current; + if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined); + }, []); + const leaveMode = () => { + cancelPvpQueue(); + navigate("home"); + }; const launch = () => { if (isPveRun) return onLaunch(selectRandomBossPair(), "initiate"); + if (isHockey) return onLaunch(selectRandomBossPair(), "initiate"); + if (isBlockbreaker) return onLaunch(selectRandomBossPair(), "initiate"); + if (isAetherAssault) return onLaunch(selectRandomBossPair(), "initiate"); + if (isHockeyPvp) return queueing ? cancelPvpQueue() : void startPvpQueue(); if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug); setMessage("Online matchmaking is not available for this mode yet."); }; @@ -1025,16 +1576,54 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi }, })) : []), { id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } }, - { id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } }, - ], [bossGridColumns, isDungeon, isPveRun, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]); - const controller = useMenuController(actions, { onBack: () => navigate("home") }); - const launchLabel = isRogueTrials ? "Begin Rogue Trials" : isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking"; + { id: "back", run: leaveMode, neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } }, + ], [bossGridColumns, isAetherAssault, isBlockbreaker, isDungeon, isHockey, isHockeyPvp, isPveRun, modeId, navigate, onLaunch, queueing, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]); + const controller = useMenuController(actions, { onBack: leaveMode }); + const launchLabel = isRogueTrials + ? "Begin Rogue Trials" + : isPve + ? "Begin RPG Roguelike" + : isHockey + ? "Begin Hockey Healing" + : isBlockbreaker + ? "Begin Blockbreaker" + : isAetherAssault + ? "Begin Aether Assault" + : isHockeyPvp + ? queueing ? "Cancel matchmaking" : "Enter online queue" + : isDungeon + ? `Challenge ${selectedBoss.name}` + : "Enter matchmaking"; const contextRules = isDungeon ? [ [selectedBoss.name, selectedBoss.summary], [bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing], [bossMechanicName(selectedBoss.mechanicIds[1]), "Controller-ready party behavior and full lower-display support."], ] + : isAetherAssault + ? [ + ["Movement is the only arcade input", "Spellfire launches automatically down the five runway lanes while every healing and targeting control stays unchanged."], + ["Formation pressure", "Eight arcane ships enter the first wave. Later formations grow to twenty, add armor, fire faster, and peel into diving attacks."], + ["Heal through every hit", "Ship bolts and dive collisions damage only the healer. Endless bosses keep attacking the full party until the formation falls."], + ] + : isBlockbreaker + ? [ + ["Break linked colors", "Aim the puck into five-column rows. A hit removes its full orthogonally connected color cluster."], + ["Accelerating wall", "Rows begin every 10 seconds, accelerate 10% each minute, and push survivors toward the danger line."], + ["Heal under pressure", `Two bosses attack without pause. Each brick-wall breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member. The run ends when all four allies fall.`], + ] + : isHockeyPvp + ? [ + ["Normalized rivals", "Both parties use default base gear with all upgrade and infusion bonuses disabled. Boss order remains identical."], + ["Escalating pressure", `Every boss kill by either party adds 5% global healing Dampening. Every net breach still deals ${HOCKEY_PVP_GOAL_DAMAGE} partywide damage.`], + ["Online or CPU", "Queue searches online for five seconds. If no rival answers, a randomly named CPU healer takes far goal."], + ] + : isHockey + ? [ + ["Wide goal defense", "Healer owns near half. Intercept every incoming puck before it reaches the wide blue goal."], + ["Pong rally", "Held left-stick direction controls return angle. Moving enemy paddle tracks the puck and strikes it back."], + ["Unbroken boss fight", "Party fights two bosses on enemy half. Every kill awards loot and pet chance before replacement arrives."], + ] : isRogueTrials ? [ ["Four dual rounds", "Clear four randomized pairs while drafting one stacking buff after each win."], @@ -1043,9 +1632,9 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi ] : isPve ? [ - ["Randomized pair", "Two distinct bosses are selected only when the run begins."], - ["Dual-boss pressure", "Both guardians fight simultaneously and must be defeated."], - ["Buff intermission", "Choose one of three stacking buffs after every cleared round."], + ["Draft every run", "Choose four companions from three five-card waves, then build a six-slot spellbook from every enabled healer class."], + ["Challenge hallways", "Brickbreaker, Hockey, and Aether Assault objectives connect boss rooms. Repeats raise their targets; failure still advances."], + ["Run-only growth", "Boss chests upgrade owned spells, companions, or +0–+5 gear. Shop after each three-boss act, then face a finale."], ] : [ ["Draft a healing path", "Choose rites after every completed room."], @@ -1056,24 +1645,24 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi -
Game mode

{mode.title}

navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back
+
Game mode

{mode.title}

{DEFAULT_CONTROLLER_GLYPHS.back} · Back
{!isDungeon &&
{mode.eyebrow}

{mode.title}

{mode.description}

{mode.detail}
} {isDungeon && (
Choose a mechanic group
{BOSS_GROUPS.map((group) => ( - selectBossGroup(group.id)} > {group.letter}Group {group.letter}{group.name} - + ))}
Group {selectedBossGroup.letter} · {selectedBossGroup.name}{selectedBossGroup.coreMechanic} mechanics · {visibleBossIds.length} guardians
@@ -1081,18 +1670,18 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi {visibleBossIds.map((bossId) => { const boss = BOSS_DEFINITIONS[bossId]; return ( - selectBoss(bossId)} > {boss.icon}{boss.name}{boss.mechanicIds.filter((id) => !bossMechanicIsPassive(id)).map(bossMechanicName).join(" · ")}{selectedBossId === bossId ? "✓" : ""} - + ); })}
@@ -1101,10 +1690,10 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi {isDungeon && (
Difficulty - {DIFFICULTIES.map((difficulty) => selectDifficulty(difficulty.slug)}>{difficulty.name}iLvl {difficulty.itemLevel})} + {DIFFICULTIES.map((difficulty) => selectDifficulty(difficulty.slug)}>{difficulty.name}iLvl {difficulty.itemLevel})}
)} - {launchLabel}{mode.status} · {DEFAULT_CONTROLLER_GLYPHS.confirm} + {launchLabel}{queueing ? `CPU fallback in ${Math.max(0, ((HOCKEY_PVP_QUEUE_TIMEOUT_MS - queueElapsed) / 1000)).toFixed(1)}s` : `${mode.status} · ${DEFAULT_CONTROLLER_GLYPHS.confirm}`} {message &&
{message}
} } @@ -1112,21 +1701,26 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
Run preparation{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}
{contextRules.map(([title, copy], index) =>
0{index + 1}{title}{copy}
)} -
Equipped role{healer.specialization} · Level {progress?.level ?? 1}6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready
+
Equipped role{healer.specialization} · Level {progress?.level ?? 1}{isHockeyPvp ? "6 abilities · Base gear normalized · Controller ready" : `6 abilities · ${progress?.inventory.length ?? 0} class items · Controller ready`}
{isDungeon &&
Guaranteed reward{bossGroupDrop(selectedBossId, selectedDifficultySlug).name}1–3 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500
} + {isHockey &&
Every boss killNormal boss loot awardedGuaranteed 1–3 group drops · Independent pet chance 1 in 500
} + {isBlockbreaker &&
Ranked recordsOverall score · Bricks · Survival10 points per brick ladder · +0.1× every 30 seconds
} + {isAetherAssault &&
Ranked recordOverall score · Wave at bestKill streak raises multiplier · Ship damage resets it
} + {isHockeyPvp &&
Ranked recordsWins / losses · Lifetime PVP boss killsOnline leaderboards publish through active hunter save
}
} /> ); } -export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => void }) { +export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"], hockeyPvpMatch?: HockeyPvpMatchConfig) => void }) { const screen = useFrontendStore((state) => state.screen); if (screen === "login") return ; if (screen === "saves") return ; if (screen === "home") return ; if (screen === "profile") return ; if (screen === "gear") return ; + if (screen === "appearance") return ; if (screen === "settings") return ; if (screen === "mode") return ; return null; diff --git a/src/components/GameScene.tsx b/src/components/GameScene.tsx index 822c42f..1b93a7d 100644 --- a/src/components/GameScene.tsx +++ b/src/components/GameScene.tsx @@ -4,10 +4,25 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type Mutab import * as THREE from "three"; import { getControllerMovement } from "../input/controller"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; -import { ARENA_CENTER, clampToArena } from "../game/arena"; -import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog"; -import { ALTERNATE_BOSS_CONFIG, BULL_URL, type AlternateBossKind } from "../game/bossVisuals"; +import { ARENA_CENTER, clampToArena, clampToHockeyArena, clampToHockeyHealerHalf } from "../game/arena"; +import { BOSS_ARCHETYPE_BY_ID, type BossArchetype } from "../game/bossCatalog"; +import { ALTERNATE_BOSS_CONFIG, BULL_BOSS_ANIMATION_CONFIG, BULL_URL, type AlternateBossKind } from "../game/bossVisuals"; import { bossAnimationCue } from "../game/bosses/mechanicPool"; +import { selectMeleeTargetIndex } from "../game/bosses/shared"; +import { + BOSS_HIT_ANIMATION_SECONDS, + BOSS_HIT_REACTION_COOLDOWN_SECONDS, + BOSS_MELEE_ANIMATION_SECONDS, + bossAnimationClipName, + bossAnimationTrigger, + isBossAnimationOneShot, + selectBossAnimationState, + shouldStartBossAnimation, + writeBossProceduralPose, + type BossAnimationClips, + type BossAnimationState, + type BossProceduralPose, +} from "../game/bossAnimation"; import { CAMERA_FOCUS_HEIGHT, CAMERA_LOOK_AHEAD, @@ -25,15 +40,76 @@ import { type ActorAnimationState, } from "../game/actorAnimation"; import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance"; -import type { PartyAbilityId } from "../game/partyCombat"; +import { + HOCKEY_ARENA_MAX_Z, + HOCKEY_NPC_PADDLE_WIDTH, + HOCKEY_NPC_PADDLE_Z, + HOCKEY_PUCK_RADIUS, + hockeyAimPreviewVisible, + hockeyReturnDirection, +} from "../game/hockeyHealing"; +import type { AiCombatantId, PartyAbilityId } from "../game/partyCombat"; import { partyAttackVfxProfile } from "../game/partyAttackVisuals"; +import { + STAFF_CAST_AFTERGLOW_SECONDS, + STAFF_CAST_GLOW_PROFILES, + isHealerPulseKind, + staffCastGlowStrength, +} from "../game/staffCastGlow"; +import { + HEALER_VISUAL_PROFILES, + type HealerVisualProfile, +} from "../game/healerVisuals"; +import { + CHARACTER_MODEL_MODE, + type CharacterAppearanceV1, + type CharacterModelMode, +} from "../game/characterAppearance"; +import { + weaponDefinition, + weaponUsesBothHands, + type CharacterWeaponGrip, + type CharacterWeaponModelId, +} from "../game/weaponCatalog"; +import { resolveCharacterEquipment } from "../game/characterEquipment"; +import { HEALER_CLASS_ORDER } from "../game/healers"; import { useGameStore } from "../game/store"; -import type { BossId, MemberId, PulseKind } from "../game/types"; +import type { BossId, HealerClassId, MemberId, PulseKind } from "../game/types"; import { BossRoom } from "./BossRoom"; +import { HealerClassAccessory } from "./HealerClassAccessory"; +import { ModularCharacterBody } from "./ModularCharacterBody"; import { BossMechanicIndicators } from "./boss/BossMechanicIndicators"; import { bossBurrowPositionY, bossIsBurrowing } from "./boss/bossBurrowVisuals"; import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals"; import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, selectedGameAssetUrl, useGameGLTF } from "./GameAssetProvider"; +import { characterEquipmentAssetUrl } from "./CharacterEquipmentAssets"; +import { + HOCKEY_PVP_PUCK_RADIUS, + HOCKEY_PVP_SIDE_OFFSET_Z, + hockeyPvpLocalToWorld, +} from "../game/hockeyHealingPvp"; +import { + BLOCKBREAKER_BRICK_DEPTH, + BLOCKBREAKER_BRICK_WIDTH, + BLOCKBREAKER_DANGER_Z, + BLOCKBREAKER_MAX_BRICKS, + BLOCKBREAKER_PUCK_RADIUS, + blockbreakerAimPreviewVisible, + blockbreakerColumnX, + blockbreakerRowZ, + type BlockbreakerBrickColor, +} from "../game/blockbreaker"; +import { blockbreakerBiomeForSeed } from "../game/blockbreakerBiomes"; +import { + AETHER_MAX_ENEMY_SHOTS, + AETHER_MAX_PLAYER_SHOTS, + AETHER_MAX_SHIPS, +} from "../game/aetherAssault"; +import { + AETHER_STANDARD_SHIP_COLORS, + aetherShipColorIndex, +} from "./aetherAssaultVisuals"; +import { clampToBossArenaWithPortals } from "../game/rpgRoguelike/playSpace"; const PARTY_MODEL_LEGACY_URLS: Record = { aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href, @@ -166,6 +242,7 @@ function useBossDeathFade( materials: readonly BossFadeMaterial[], defeated: boolean, baseLightIntensity: number, + bossId: BossId, ) { const elapsed = useRef(0); const lastOpacity = useRef(1); @@ -181,7 +258,7 @@ function useBossDeathFade( return; } elapsed.current += delta; - const opacity = bossDeathOpacity(elapsed.current); + const opacity = bossDeathOpacity(elapsed.current, bossId); if (opacity === lastOpacity.current) return; lastOpacity.current = opacity; if (group.current) group.current.visible = opacity > 0; @@ -190,12 +267,149 @@ function useBossDeathFade( }); } -function encounterBossAt(state: GameStoreState, bossIndex: number) { +function encounterBossAt(state: GameStoreState, bossIndex: number, opponent = false) { + if (opponent) return { boss: state.hockeyPvpOpponent.boss, motion: state.hockeyPvpOpponent.bossMotion }; return bossIndex === 0 ? { boss: state.boss, motion: state.bossMotion } : state.additionalBosses[bossIndex - 1]; } +function useBossAnimationPlayback({ + actions, + clips, + archetype, + bossIndex, + opponent, + modelRoot, +}: { + actions: Record; + clips: BossAnimationClips; + archetype: BossArchetype; + bossIndex: number; + opponent: boolean; + modelRoot: RefObject; +}) { + const activeClip = useRef(undefined); + const activeState = useRef(undefined); + const activeTrigger = useRef(Number.NaN); + const previousHp = useRef(undefined); + const observedMeleeAt = useRef(-1); + const meleeElapsed = useRef(Number.POSITIVE_INFINITY); + const hitElapsed = useRef(Number.POSITIVE_INFINITY); + const hitReadyAt = useRef(0); + const hitTrigger = useRef(0); + const observedPhaseStartedAt = useRef(Number.NaN); + const phaseElapsed = useRef(0); + const pose = useRef({ + x: 0, + y: 0, + z: 0, + pitch: 0, + yaw: 0, + roll: 0, + scaleX: 1, + scaleY: 1, + scaleZ: 1, + }); + + useFrame((_, delta) => { + const state = useGameStore.getState(); + const current = encounterBossAt(state, bossIndex, opponent); + if (!current) return; + const { boss, motion } = current; + + if (previousHp.current !== undefined + && boss.hp > 0 + && boss.hp < previousHp.current + && state.time >= hitReadyAt.current) { + hitElapsed.current = 0; + hitReadyAt.current = state.time + BOSS_HIT_REACTION_COOLDOWN_SECONDS; + hitTrigger.current += 1; + } + previousHp.current = boss.hp; + + if (motion.lastMeleeAt >= 0 && motion.lastMeleeAt !== observedMeleeAt.current) { + observedMeleeAt.current = motion.lastMeleeAt; + meleeElapsed.current = 0; + } + if (motion.phaseStartedAt !== observedPhaseStartedAt.current) { + observedPhaseStartedAt.current = motion.phaseStartedAt; + phaseElapsed.current = 0; + } + + const mechanicCue = bossAnimationCue(motion); + const animationState = selectBossAnimationState({ + defeated: boss.hp <= 0 || state.phase === "victory", + activeMechanic: motion.activeMechanicId !== null, + mechanicCue, + meleeElapsed: meleeElapsed.current, + hitElapsed: hitElapsed.current, + }); + const trigger = bossAnimationTrigger( + animationState, + motion.phaseStartedAt, + motion.lastMeleeAt, + hitTrigger.current, + ); + const clipName = bossAnimationClipName(clips, animationState); + + if (shouldStartBossAnimation(activeState.current, activeTrigger.current, animationState, trigger)) { + const next = actions[clipName]; + if (next) { + const clipChanged = activeClip.current !== clipName; + if (clipChanged && activeClip.current) actions[activeClip.current]?.fadeOut(0.16); + const timeScale = archetype === "duelist" && motion.mode === "mantis_line_telegraph" + ? 0.55 + : archetype === "duelist" && motion.mode === "mantis_cross_telegraph" + ? 0.6 + : motion.mode === "charging" + ? 1.3 + : 1; + next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale); + if (clipChanged) next.fadeIn(0.16); + if (isBossAnimationOneShot(animationState)) { + next.setLoop(THREE.LoopOnce, 1); + next.clampWhenFinished = true; + } else { + next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY); + next.clampWhenFinished = false; + } + next.play(); + activeClip.current = clipName; + activeState.current = animationState; + activeTrigger.current = trigger; + } + } + + const animationElapsed = animationState === "melee" + ? meleeElapsed.current + : animationState === "hit" + ? hitElapsed.current + : phaseElapsed.current; + const phaseDuration = Number.isFinite(motion.phaseEndsAt) && motion.phaseEndsAt > motion.phaseStartedAt + ? motion.phaseEndsAt - motion.phaseStartedAt + : 1; + writeBossProceduralPose( + pose.current, + archetype, + motion.mode, + animationState, + animationElapsed, + phaseDuration, + ); + if (modelRoot.current) { + const nextPose = pose.current; + modelRoot.current.position.set(nextPose.x, nextPose.y, nextPose.z); + modelRoot.current.rotation.set(nextPose.pitch, nextPose.yaw, nextPose.roll); + modelRoot.current.scale.set(nextPose.scaleX, nextPose.scaleY, nextPose.scaleZ); + } + + meleeElapsed.current = Math.min(BOSS_MELEE_ANIMATION_SECONDS, meleeElapsed.current + delta); + hitElapsed.current = Math.min(BOSS_HIT_ANIMATION_SECONDS, hitElapsed.current + delta); + phaseElapsed.current += delta; + }); +} + function targetBossMotion(state: GameStoreState) { if (state.boss.hp > 0) return state.bossMotion; return state.additionalBosses.find((entry) => entry.boss.hp > 0)?.motion ?? state.bossMotion; @@ -206,21 +420,27 @@ function targetBossMotionByInstance(state: GameStoreState, instanceId?: string) return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state); } -type WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop"; - -const PARTY_WEAPON_GRIPS: Record = { - aelia: { right: "staff" }, - brann: { right: "sword", left: "prop" }, - nia: { right: "crossbow" }, - orin: { right: "wand", left: "prop" }, - vale: { right: "dagger", left: "dagger" }, +const PARTY_WEAPON_MODEL_IDS: Record = { + aelia: { right: "cc/adv_druid_staff" }, + brann: { right: "cc/adv_sword_1handed", left: "cc/shield_badge" }, + nia: { right: "cc/crossbow_2handed" }, + orin: { right: "cc/adv_wand", left: "cc/spellbook_open" }, + vale: { right: "cc/adv_dagger", left: "cc/adv_dagger" }, }; +const CROSSBOW_QUIVER_URL = characterEquipmentAssetUrl("cc/quiver"); -const VARIANT_GRIPS: Record, { lift: number; maxHeight: number }> = { - sword: { lift: 0.04, maxHeight: 2 }, +const VARIANT_GRIPS: Record, { lift: number; maxHeight: number }> = { + upright: { lift: 0.04, maxHeight: 2 }, dagger: { lift: 0.04, maxHeight: 1.4 }, staff: { lift: 0.18, maxHeight: 2.4 }, wand: { lift: 0.04, maxHeight: 1.2 }, + polearm: { lift: 0.12, maxHeight: 2.75 }, +}; + +const CROSSBOW_MOUNTS: Record, { x: number; y: number; scale: number }> = { + "cc/crossbow_1handed": { x: 0.255, y: 0.04, scale: 0.6109 }, + "cc/crossbow_2handed": { x: 0.3381, y: 0.058, scale: 0.7204 }, + "cc/skeleton_crossbow": { x: 0.33, y: 0.064, scale: 0.7094 }, }; function resolveRigNode(root: THREE.Object3D, authoredName: string) { @@ -228,7 +448,7 @@ function resolveRigNode(root: THREE.Object3D, authoredName: string) { ?? root.getObjectByName(authoredName.replace(/[[\].:/]/g, "")); } -function flattenWeaponScene(scene: THREE.Object3D) { +function flattenCrossbowScene(scene: THREE.Object3D) { if (scene.children.length !== 1) return scene; const holder = new THREE.Group(); const child = scene.children[0]; @@ -241,58 +461,402 @@ function flattenWeaponScene(scene: THREE.Object3D) { return holder; } -function prepareHeldWeapon(scene: THREE.Object3D, grip: WeaponGrip, side: "r" | "l") { +function prepareHeldWeapon( + scene: THREE.Object3D, + modelId: CharacterWeaponModelId, + grip: CharacterWeaponGrip, + side: "r" | "l", +) { // Shields and spellbooks carry useful authored offsets, so keep their scene transform. if (grip === "prop") return scene; - const weapon = flattenWeaponScene(scene); if (grip === "crossbow") { - weapon.position.set(0.3381, 0.058, 0); + const weapon = flattenCrossbowScene(scene); + const mount = CROSSBOW_MOUNTS[modelId as keyof typeof CROSSBOW_MOUNTS] + ?? CROSSBOW_MOUNTS["cc/crossbow_2handed"]; + weapon.position.set(mount.x, mount.y, 0); weapon.quaternion.set(0, 0.7071068, 0, 0.7071067); - weapon.scale.setScalar(0.7204); + weapon.scale.setScalar(mount.scale); return weapon; } - const { lift, maxHeight } = VARIANT_GRIPS[grip]; + const weapon = new THREE.Group(); + weapon.add(scene); + const profile = VARIANT_GRIPS[grip]; + const maxHeight = grip === "upright" && weaponUsesBothHands(modelId) + ? 2.75 + : profile.maxHeight; const bounds = new THREE.Box3().setFromObject(weapon); const height = bounds.max.y - bounds.min.y; const scale = height > 0.001 ? Math.min(1, maxHeight / height) : 1; - weapon.position.set(0, lift, 0); + weapon.position.set(0, profile.lift, 0); weapon.quaternion.set(0, side === "l" ? 0 : 1, 0, side === "l" ? 1 : 0); - weapon.scale.setScalar(scale); + weapon.scale.multiplyScalar(scale); return weapon; } +function prepareBackQuiver(scene: THREE.Object3D) { + const quiver = new THREE.Group(); + quiver.add(scene); + const bounds = new THREE.Box3().setFromObject(quiver); + const height = bounds.max.y - bounds.min.y; + quiver.scale.multiplyScalar(height > 0.001 ? 0.92 / height : 1); + quiver.position.set(0.16, 0.08, -0.24); + quiver.rotation.set(0.08, Math.PI, -0.16); + return quiver; +} + +interface WeaponAssetReference { + count: number; + scene: THREE.Object3D; + releaseTimer: ReturnType | null; +} + +const WEAPON_ASSET_REFERENCES = new Map(); +const PENDING_WEAPON_ASSET_RELEASE_MS = 5_000; +const MAX_PENDING_WEAPON_ASSETS = 16; +const PENDING_WEAPON_ASSETS = new Map>(); + +function releasePendingWeaponAsset(url: string) { + const releaseTimer = PENDING_WEAPON_ASSETS.get(url); + if (releaseTimer === undefined) return; + clearTimeout(releaseTimer); + PENDING_WEAPON_ASSETS.delete(url); + // An abandoned Suspense render never reaches useEffect, but useGLTF still caches + // its request/result. Clear that cache entry once no committed consumer owns it. + if (!WEAPON_ASSET_REFERENCES.has(url)) useGLTF.clear(url); +} + +function registerPendingWeaponAsset(url: string) { + if (WEAPON_ASSET_REFERENCES.has(url) || PENDING_WEAPON_ASSETS.has(url)) return; + PENDING_WEAPON_ASSETS.set(url, setTimeout( + () => releasePendingWeaponAsset(url), + PENDING_WEAPON_ASSET_RELEASE_MS, + )); + while (PENDING_WEAPON_ASSETS.size > MAX_PENDING_WEAPON_ASSETS) { + const oldestUrl = PENDING_WEAPON_ASSETS.keys().next().value as string | undefined; + if (!oldestUrl) break; + releasePendingWeaponAsset(oldestUrl); + } +} + +function commitPendingWeaponAsset(url: string) { + const releaseTimer = PENDING_WEAPON_ASSETS.get(url); + if (releaseTimer === undefined) return; + clearTimeout(releaseTimer); + PENDING_WEAPON_ASSETS.delete(url); +} + +function disposeWeaponAssetScene(scene: THREE.Object3D) { + const geometries = new Set(); + const materials = new Set(); + const textures = new Set(); + scene.traverse((object) => { + if (!(object instanceof THREE.Mesh)) return; + geometries.add(object.geometry); + for (const material of Array.isArray(object.material) ? object.material : [object.material]) { + materials.add(material); + for (const value of Object.values(material)) { + if (value instanceof THREE.Texture) textures.add(value); + } + } + }); + for (const geometry of geometries) geometry.dispose(); + for (const material of materials) material.dispose(); + for (const texture of textures) texture.dispose(); +} + +/** + * Drei caches parsed GLBs forever by default. Weapon browsing can touch eleven large + * embedded atlases, so release an asset after its last mounted user disappears. + */ +function useWeaponGLTF(url: string) { + registerPendingWeaponAsset(url); + const gltf = useGameGLTF(url); + useEffect(() => { + commitPendingWeaponAsset(url); + const existing = WEAPON_ASSET_REFERENCES.get(url); + if (existing) { + existing.count += 1; + if (existing.releaseTimer !== null) { + clearTimeout(existing.releaseTimer); + existing.releaseTimer = null; + } + } else { + WEAPON_ASSET_REFERENCES.set(url, { count: 1, scene: gltf.scene, releaseTimer: null }); + } + return () => { + const reference = WEAPON_ASSET_REFERENCES.get(url); + if (!reference) return; + reference.count = Math.max(0, reference.count - 1); + if (reference.count > 0 || reference.releaseTimer !== null) return; + reference.releaseTimer = setTimeout(() => { + const current = WEAPON_ASSET_REFERENCES.get(url); + if (!current || current.count > 0) return; + disposeWeaponAssetScene(current.scene); + useGLTF.clear(url); + WEAPON_ASSET_REFERENCES.delete(url); + }, 0); + }; + }, [gltf.scene, url]); + return gltf; +} + +interface StaffGlowMaterialBinding { + material: THREE.Material & { emissive: THREE.Color; emissiveIntensity: number }; + baseEmissive: THREE.Color; + baseEmissiveIntensity: number; +} + +function supportsEmissiveGlow(material: THREE.Material): material is StaffGlowMaterialBinding["material"] { + return "emissive" in material + && material.emissive instanceof THREE.Color + && "emissiveIntensity" in material + && typeof material.emissiveIntensity === "number"; +} + +function prepareStaffGlow(scene: THREE.Object3D) { + const materialClones = new Map(); + const bindings: StaffGlowMaterialBinding[] = []; + scene.traverse((object) => { + if (!(object instanceof THREE.Mesh)) return; + const cloneMaterial = (source: THREE.Material) => { + const existing = materialClones.get(source); + if (existing) return existing; + const clone = source.clone(); + materialClones.set(source, clone); + if (supportsEmissiveGlow(clone)) { + bindings.push({ + material: clone, + baseEmissive: clone.emissive.clone(), + baseEmissiveIntensity: clone.emissiveIntensity, + }); + } + return clone; + }; + object.material = Array.isArray(object.material) + ? object.material.map(cloneMaterial) + : cloneMaterial(object.material); + }); + + scene.updateMatrixWorld(true); + const bounds = new THREE.Box3().setFromObject(scene); + const height = bounds.max.y - bounds.min.y; + const tipPosition: [number, number, number] = [ + (bounds.min.x + bounds.max.x) * 0.5, + bounds.max.y - height * 0.06, + (bounds.min.z + bounds.max.z) * 0.5, + ]; + return { bindings, materials: [...materialClones.values()], tipPosition }; +} + +function createActorScene(source: THREE.Object3D, profile: HealerVisualProfile | null) { + const scene = cloneSkeleton(source); + if (!profile) return scene; + + // Preserve each GLB's authored material and texture palette. Class identity belongs + // in geometry, equipment, and small accents; whole-body tinting erases surface detail. + for (const nodeName of profile.hiddenNodes) { + const node = resolveRigNode(scene, nodeName); + if (node) node.visible = false; + } + return scene; +} + +function createRigActorScene(source: THREE.Object3D) { + const scene = cloneSkeleton(source); + const renderNodes: THREE.Object3D[] = []; + scene.traverse((object) => { + if (object instanceof THREE.Mesh) renderNodes.push(object); + }); + for (const renderNode of renderNodes) renderNode.parent?.remove(renderNode); + return scene; +} + +function disposeActorSkeletons(scene: THREE.Object3D) { + const skeletons = new Set(); + scene.traverse((object) => { + if (object instanceof THREE.SkinnedMesh) skeletons.add(object.skeleton); + }); + for (const skeleton of skeletons) skeleton.dispose(); +} + +function StaffCastGlow({ + bindings, + position, +}: { + bindings: readonly StaffGlowMaterialBinding[]; + position: readonly [number, number, number]; +}) { + const aura = useRef(null); + const auraMaterial = useRef(null); + const light = useRef(null); + const glowColor = useMemo(() => new THREE.Color(), []); + const lastClassId = useRef(null); + const lastPulseId = useRef(useGameStore.getState().scenePulse.id); + const afterglowAge = useRef(null); + const lastStrength = useRef(Number.NaN); + + useFrame(({ clock }, delta) => { + const state = useGameStore.getState(); + if (state.scenePulse.id !== lastPulseId.current) { + lastPulseId.current = state.scenePulse.id; + if (isHealerPulseKind(state.scenePulse.kind)) afterglowAge.current = 0; + } else if (afterglowAge.current !== null) { + afterglowAge.current += delta; + if (afterglowAge.current >= STAFF_CAST_AFTERGLOW_SECONDS) afterglowAge.current = null; + } + + const activeCast = state.activeCast; + const castingProgress = activeCast + ? (state.time - activeCast.startedAt) / Math.max(0.001, activeCast.completesAt - activeCast.startedAt) + : null; + const baseStrength = staffCastGlowStrength({ castingProgress, afterglowAge: afterglowAge.current }); + const strength = Math.min(1, baseStrength * (0.94 + Math.sin(clock.elapsedTime * 11) * 0.06)); + const classChanged = lastClassId.current !== state.healerClassId; + if (classChanged) { + lastClassId.current = state.healerClassId; + glowColor.set(STAFF_CAST_GLOW_PROFILES[state.healerClassId].color); + if (auraMaterial.current) auraMaterial.current.color.copy(glowColor); + if (light.current) light.current.color.copy(glowColor); + } + if (!classChanged && Math.abs(strength - lastStrength.current) < 0.001) return; + lastStrength.current = strength; + + for (const binding of bindings) { + binding.material.emissive.copy(binding.baseEmissive).lerp(glowColor, strength); + binding.material.emissiveIntensity = binding.baseEmissiveIntensity + strength * 3.2; + } + if (aura.current) { + aura.current.visible = strength > 0.01; + aura.current.scale.setScalar(0.82 + strength * 0.3); + } + if (auraMaterial.current) auraMaterial.current.opacity = strength * 0.62; + if (light.current) light.current.intensity = strength * 2.8; + }); + + return ( + + + + + + + + ); +} + function PartyCharacterModel({ memberId, + visualMemberId = memberId, + healerClassId, + appearanceOverride, + modelMode = CHARACTER_MODEL_MODE, animationState, animationTrigger, }: { memberId: MemberId; + visualMemberId?: MemberId; + healerClassId?: HealerClassId; + appearanceOverride?: CharacterAppearanceV1; + modelMode?: CharacterModelMode; animationState: MutableRefObject; animationTrigger: MutableRefObject; }) { - const gltf = useGameGLTF(PARTY_MODEL_URLS[memberId]); - const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]); - const loadout = PARTY_WEAPON_URLS[memberId]; - const grips = PARTY_WEAPON_GRIPS[memberId]; - const rightWeapon = useGameGLTF(loadout.right); - const leftWeapon = useGameGLTF(loadout.left ?? loadout.right); + const healerVisual = memberId === "aelia" && healerClassId + ? HEALER_VISUAL_PROFILES[healerClassId] + : null; + const healerAppearance = healerVisual + ? modelMode === "modular" && appearanceOverride + ? appearanceOverride + : healerVisual.appearance + : null; + const usesModularRenderer = Boolean(healerAppearance && modelMode === "modular"); + const modularAppearance = usesModularRenderer + ? healerAppearance + : null; + const bodyMemberId = healerVisual?.bodyMemberId ?? visualMemberId; + const scaleMemberId = modularAppearance?.scaleSourceMemberId ?? bodyMemberId; + const animationMemberId = healerVisual?.animationMemberId ?? visualMemberId; + const bodyGltf = useGameGLTF(PARTY_MODEL_URLS[bodyMemberId]); + const animationGltf = useGameGLTF(PARTY_MODEL_URLS[animationMemberId]); + const actorScene = useMemo( + () => usesModularRenderer + ? createRigActorScene(animationGltf.scene) + : createActorScene(bodyGltf.scene, healerVisual), + [animationGltf.scene, bodyGltf.scene, healerVisual, usesModularRenderer], + ); + const partyLoadout = PARTY_WEAPON_URLS[visualMemberId]; + const partyModelIds = PARTY_WEAPON_MODEL_IDS[visualMemberId]; + const resolvedHealerEquipment = healerAppearance + ? resolveCharacterEquipment(healerAppearance) + : null; + const rightModelId = resolvedHealerEquipment?.mainHand.modelId ?? partyModelIds.right; + const effectiveLeftModelId = resolvedHealerEquipment?.offHand?.modelId ?? (healerAppearance ? undefined : partyModelIds.left); + const wearsCrossbowQuiver = Boolean(usesModularRenderer && resolvedHealerEquipment?.backPropModelId); + const loadout = healerAppearance + ? { + right: characterEquipmentAssetUrl(rightModelId), + left: effectiveLeftModelId ? characterEquipmentAssetUrl(effectiveLeftModelId) : undefined, + } + : partyLoadout; + const grips = { + right: weaponDefinition(rightModelId).grip as CharacterWeaponGrip, + left: effectiveLeftModelId + ? weaponDefinition(effectiveLeftModelId).grip as CharacterWeaponGrip + : undefined, + }; + const rightWeapon = useWeaponGLTF(loadout.right); + const leftWeapon = useWeaponGLTF(loadout.left ?? loadout.right); + const quiverWeapon = useWeaponGLTF(wearsCrossbowQuiver ? CROSSBOW_QUIVER_URL : loadout.right); const rightHandSlot = resolveRigNode(actorScene, "handslot.r"); const leftHandSlot = resolveRigNode(actorScene, "handslot.l"); + const backSlot = resolveRigNode(actorScene, "chest") ?? resolveRigNode(actorScene, "spine"); + const accessorySlot = healerVisual ? resolveRigNode(actorScene, "head") : null; + const renderedAppearance = useMemo( + () => modularAppearance && resolvedHealerEquipment?.suppressSkinnedBack + ? { ...modularAppearance, backPartId: null } + : modularAppearance, + [modularAppearance, resolvedHealerEquipment?.suppressSkinnedBack], + ); const rightWeaponScene = useMemo( - () => prepareHeldWeapon(rightWeapon.scene.clone(true), grips.right, "r"), - [grips.right, rightWeapon.scene], + () => prepareHeldWeapon(rightWeapon.scene.clone(true), rightModelId, grips.right, "r"), + [grips.right, rightModelId, rightWeapon.scene], ); const leftWeaponScene = useMemo( - () => loadout.left ? prepareHeldWeapon(leftWeapon.scene.clone(true), grips.left ?? grips.right, "l") : null, - [grips.left, grips.right, leftWeapon.scene, loadout.left], + () => loadout.left && effectiveLeftModelId + ? prepareHeldWeapon(leftWeapon.scene.clone(true), effectiveLeftModelId, grips.left ?? grips.right, "l") + : null, + [effectiveLeftModelId, grips.left, grips.right, leftWeapon.scene, loadout.left], ); - const { actions } = useAnimations(gltf.animations, actorScene); + const quiverScene = useMemo( + () => wearsCrossbowQuiver ? prepareBackQuiver(quiverWeapon.scene.clone(true)) : null, + [quiverWeapon.scene, wearsCrossbowQuiver], + ); + const staffGlow = useMemo( + () => memberId === "aelia" && grips.right !== "crossbow" ? prepareStaffGlow(rightWeaponScene) : null, + [grips.right, memberId, rightWeaponScene], + ); + const { actions } = useAnimations(animationGltf.animations, actorScene); const activeClip = useRef(undefined); const activeState = useRef(undefined); const activeTrigger = useRef(Number.NaN); + useEffect(() => { + // A newly selected weapon can suspend this subtree while its GLB loads. Force + // the actor action to restart after it resumes instead of leaving the shared + // healer rig in its bind pose. + activeState.current = undefined; + activeTrigger.current = Number.NaN; + }, [effectiveLeftModelId, rightModelId]); + useEffect(() => { actorScene.traverse((object) => { if (object instanceof THREE.Mesh) { @@ -302,8 +866,10 @@ function PartyCharacterModel({ }); }, [actorScene]); + useEffect(() => () => disposeActorSkeletons(actorScene), [actorScene]); + useEffect(() => { - for (const weaponScene of [rightWeaponScene, leftWeaponScene]) { + for (const weaponScene of [rightWeaponScene, leftWeaponScene, quiverScene]) { if (!weaponScene) continue; weaponScene.traverse((object) => { if (object instanceof THREE.Mesh) { @@ -314,7 +880,11 @@ function PartyCharacterModel({ } }); } - }, [leftWeaponScene, rightWeaponScene]); + }, [leftWeaponScene, quiverScene, rightWeaponScene]); + + useEffect(() => () => { + for (const material of staffGlow?.materials ?? []) material.dispose(); + }, [staffGlow]); useFrame(() => { const state = animationState.current; @@ -330,7 +900,7 @@ function PartyCharacterModel({ : state === "cast" ? "Spellcasting" : state === "attack" - ? PARTY_ATTACK_CLIPS[memberId] + ? PARTY_ATTACK_CLIPS[visualMemberId] : "Idle"; if (!shouldStartActorAnimation(activeState.current, activeTrigger.current, state, trigger)) return; const next = actions[clipName]; @@ -354,9 +924,25 @@ function PartyCharacterModel({ return ( <> - + {renderedAppearance && ( + + )} + {rightHandSlot && createPortal(, rightHandSlot)} + {rightHandSlot && staffGlow && createPortal( + , + rightHandSlot, + )} {leftWeaponScene && leftHandSlot && createPortal(, leftHandSlot)} + {quiverScene && backSlot && createPortal(, backSlot)} + {accessorySlot && healerVisual && createPortal( + , + accessorySlot, + )} ); } @@ -450,6 +1036,16 @@ function Character({ memberId, selected = false }: { memberId: Exclude(null); const animationState = useRef("idle"); const animationTrigger = useRef(0); + const visualArchetype = useGameStore((state) => state.party.find((member) => member.id === memberId)?.runProfile?.visualArchetype); + const visualMemberId: MemberId = visualArchetype === "knight" + ? "brann" + : visualArchetype === "ranger" + ? "nia" + : visualArchetype === "mage" + ? "orin" + : visualArchetype === "rogue" + ? "vale" + : memberId; useEffect(() => { const start = useGameStore.getState().partyPositions[memberId]; group.current?.position.set(start[0], 0.025, start[1]); @@ -487,7 +1083,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude 0 && (state.phase === "combat" || moving)) { const faceBoss = state.phase === "combat"; @@ -505,7 +1101,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude - + {selected && ( @@ -516,14 +1112,24 @@ function Character({ memberId, selected = false }: { memberId: Exclude(null); const animationState = useRef("idle"); const animationTrigger = useRef(0); const keys = useRef(new Set()); const scenePulse = useGameStore((state) => state.scenePulse); + const healerClassId = useGameStore((state) => state.healerClassId); const selected = useGameStore((state) => state.selectedMemberId === "aelia"); + const rpgEncounterKey = useGameStore((state) => { + const phase = state.rpgRun?.phase; + if (state.runMode !== "rpg-roguelike") return null; + const bossIndex = state.rpgRun?.bossIndex ?? 0; + if (phase === "challenge-active") return bossIndex * 2 + 1; + if (phase === "boss-combat") return bossIndex * 2 + 2; + return null; + }); const setPlayerPosition = useGameStore((state) => state.setPlayerPosition); + const setHockeyAimDirection = useGameStore((state) => state.setHockeyAimDirection); const { camera } = useThree(); const broadcastTimer = useRef(0); const castingUntil = useRef(0); @@ -531,14 +1137,17 @@ function PlayerCharacter() { const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []); const cameraOrbit = useRef({ yaw: DEFAULT_CAMERA_YAW, pitch: DEFAULT_CAMERA_PITCH }); const cameraRelativeMovement = useRef({ x: 0, z: 0 }); + const hockeyAimMovement = useRef({ x: 0, z: -1 }); useEffect(() => { const start = useGameStore.getState().partyPositions.aelia; group.current?.position.set(start[0], 0.025, start[1]); - }, []); + keys.current.clear(); + broadcastTimer.current = 0; + }, [rpgEncounterKey]); useEffect(() => { - if (["renew", "shield", "purify", "radiance", "barrier"].includes(scenePulse.kind)) { + if (["periodic-heal", "protective", "cleanse", "group-heal", "field"].includes(scenePulse.kind)) { castingUntil.current = performance.now() + 700; instantCastTrigger.current = scenePulse.id; } @@ -555,10 +1164,19 @@ function PlayerCharacter() { const nudgeZ = Number(key === "s") - Number(key === "w"); if (!nudgeX && !nudgeZ) return; setCameraRelativeMovement(cameraRelativeMovement.current, nudgeX, nudgeZ, cameraOrbit.current.yaw); - const next = clampToArena([ + const aetherAssaultMode = state.activityMode === "aether-assault"; + const hockeyMode = state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker"; + const requestedPosition: [number, number] = [ group.current.position.x + cameraRelativeMovement.current.x * 0.18, group.current.position.z + cameraRelativeMovement.current.z * 0.18, - ]); + ]; + const next = state.runMode === "rpg-roguelike" && state.activityMode === "boss" + ? clampToBossArenaWithPortals(requestedPosition, { north: state.rpgRun?.phase === "boss-cleared", south: false }) + : aetherAssaultMode + ? clampToHockeyArena(requestedPosition, 0.65) + : hockeyMode + ? clampToHockeyHealerHalf(requestedPosition, 0.65) + : clampToArena(requestedPosition); group.current.position.x = next[0]; group.current.position.z = next[1]; setPlayerPosition([group.current.position.x, group.current.position.z]); @@ -579,15 +1197,21 @@ function PlayerCharacter() { const state = useGameStore.getState(); const knocked = state.party[0].knockedUntil > state.time; const player = state.party[0]; - if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) { - inputX = Number(keys.current.has("d")) - Number(keys.current.has("a")); - inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w")); - const controller = getControllerMovement(); - inputX += controller.moveX; - inputZ += controller.moveY; - } const controller = getControllerMovement(); - if (state.phase === "combat" && !state.paused) { + const rawInputX = Number(keys.current.has("d")) - Number(keys.current.has("a")) + controller.moveX; + const rawInputZ = Number(keys.current.has("s")) - Number(keys.current.has("w")) + controller.moveY; + if ((state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker") && state.phase === "combat" && !state.paused) { + setCameraRelativeMovement(hockeyAimMovement.current, rawInputX, rawInputZ, cameraOrbit.current.yaw); + setHockeyAimDirection([hockeyAimMovement.current.x, hockeyAimMovement.current.z]); + } + if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) { + inputX = rawInputX; + inputZ = rawInputZ; + } + if (state.activityMode === "aether-assault") { + cameraOrbit.current.yaw = DEFAULT_CAMERA_YAW; + cameraOrbit.current.pitch = DEFAULT_CAMERA_PITCH; + } else if (state.phase === "combat" && !state.paused) { updateCameraOrbit(cameraOrbit.current, controller.lookX, controller.lookY, delta); } setCameraRelativeMovement(cameraRelativeMovement.current, inputX, inputZ, cameraOrbit.current.yaw); @@ -596,7 +1220,14 @@ function PlayerCharacter() { const length = Math.hypot(inputX, inputZ); if (length > 0.05) { const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length); - const next = clampToArena([group.current.position.x + inputX * speed, group.current.position.z + inputZ * speed]); + const requestedPosition: [number, number] = [group.current.position.x + inputX * speed, group.current.position.z + inputZ * speed]; + const next = state.runMode === "rpg-roguelike" && state.activityMode === "boss" + ? clampToBossArenaWithPortals(requestedPosition, { north: state.rpgRun?.phase === "boss-cleared", south: false }) + : state.activityMode === "aether-assault" + ? clampToHockeyArena(requestedPosition, 0.65) + : state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker" + ? clampToHockeyHealerHalf(requestedPosition, 0.65) + : clampToArena(requestedPosition); group.current.position.x = next[0]; group.current.position.z = next[1]; group.current.rotation.y = Math.atan2(inputX, inputZ); @@ -635,20 +1266,28 @@ function PlayerCharacter() { const horizontalDistance = Math.cos(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE; const sinYaw = Math.sin(cameraOrbit.current.yaw); const cosYaw = Math.cos(cameraOrbit.current.yaw); + const pvpOffsetZ = state.activityMode === "hockey-healing-pvp" ? HOCKEY_PVP_SIDE_OFFSET_Z : 0; desiredCameraPosition.set( group.current.position.x + sinYaw * horizontalDistance, CAMERA_FOCUS_HEIGHT + Math.sin(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE, - group.current.position.z + cosYaw * horizontalDistance, + group.current.position.z + pvpOffsetZ + cosYaw * horizontalDistance, ); camera.position.lerp(desiredCameraPosition, 1 - Math.pow(0.002, delta)); camera.lookAt( group.current.position.x - sinYaw * CAMERA_LOOK_AHEAD, CAMERA_FOCUS_HEIGHT, - group.current.position.z - cosYaw * CAMERA_LOOK_AHEAD, + group.current.position.z + pvpOffsetZ - cosYaw * CAMERA_LOOK_AHEAD, ); broadcastTimer.current += delta; - if (broadcastTimer.current > 0.15) { + const activeRunMode = useGameStore.getState().runMode; + const positionSyncInterval = activeRunMode === "hockey-healing" + || activeRunMode === "hockey-healing-pvp" + || activeRunMode === "blockbreaker" + || activeRunMode === "aether-assault" + ? 0.08 + : 0.15; + if (broadcastTimer.current > positionSyncInterval) { setPlayerPosition([group.current.position.x, group.current.position.z]); broadcastTimer.current = 0; } @@ -656,19 +1295,448 @@ function PlayerCharacter() { return ( - + {selected && ( )} - ); } -function Party() { +function HockeyHealingPlayfield() { + const puck = useRef(null); + const puckGlow = useRef(null); + const npcPaddle = useRef(null); + const npcPaddleMaterial = useRef(null); + const arrow = useRef(null); + const arrowMaterial = useRef(null); + + useFrame(({ clock }, delta) => { + const state = useGameStore.getState(); + const hockeyMode = state.activityMode === "hockey-healing"; + if (puck.current) { + puck.current.visible = hockeyMode; + if (hockeyMode) { + puck.current.position.set(state.hockey.puckPosition[0], 0.48, state.hockey.puckPosition[1]); + puck.current.rotation.y += 0.07; + const pulse = 1 + Math.sin(clock.elapsedTime * 7) * 0.08; + puck.current.scale.setScalar(pulse); + if (puckGlow.current) puckGlow.current.opacity = 0.6 + Math.sin(clock.elapsedTime * 7) * 0.18; + } + } + if (npcPaddle.current) { + npcPaddle.current.visible = hockeyMode; + if (hockeyMode) { + npcPaddle.current.position.x = THREE.MathUtils.damp(npcPaddle.current.position.x, state.hockey.paddleX, 18, delta); + const hitPulse = Math.max(0, 1 - (state.time - state.hockey.paddleHitAt) / 0.22); + npcPaddle.current.scale.set(1 + hitPulse * 0.04, 1 + hitPulse * 0.16, 1); + if (npcPaddleMaterial.current) npcPaddleMaterial.current.emissiveIntensity = 1.8 + hitPulse * 3.4; + } + } + if (!arrow.current) return; + const arrowVisible = hockeyMode + && state.phase === "combat" + && hockeyAimPreviewVisible(state.hockey, state.partyPositions.aelia); + arrow.current.visible = arrowVisible; + if (!arrowVisible) return; + const direction = hockeyReturnDirection(state.hockey.aimDirection); + arrow.current.position.set(state.hockey.puckPosition[0], 0.09, state.hockey.puckPosition[1]); + arrow.current.rotation.y = Math.atan2(-direction[0], -direction[1]); + if (arrowMaterial.current) arrowMaterial.current.opacity = 0.62 + Math.sin(clock.elapsedTime * 8) * 0.2; + }); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +const BLOCKBREAKER_COLOR_KEYS: readonly BlockbreakerBrickColor[] = ["cyan", "amber", "magenta", "lime"]; + +function BlockbreakerPlayfield() { + const biome = useGameStore((state) => blockbreakerBiomeForSeed(state.blockbreaker.seed)); + const root = useRef(null); + const bricks = useRef>({ + cyan: null, + amber: null, + magenta: null, + lime: null, + }); + const marksA = useRef(null); + const marksB = useRef(null); + const puck = useRef(null); + const puckGlow = useRef(null); + const arrow = useRef(null); + const arrowMaterial = useRef(null); + const breakFx = useRef(null); + const breakFxMaterial = useRef(null); + const transform = useMemo(() => new THREE.Object3D(), []); + const layout = useRef({ active: false, seed: 0, rows: -1, broken: -1 }); + + useFrame(({ clock }, delta) => { + const state = useGameStore.getState(); + const active = state.activityMode === "blockbreaker"; + if (root.current) root.current.visible = active; + if (!active) { + layout.current.active = false; + return; + } + + const blockbreaker = state.blockbreaker; + const reducedMotion = document.documentElement.classList.contains("force-reduced-motion"); + if (puck.current) { + puck.current.visible = blockbreaker.status === "live"; + puck.current.position.set(blockbreaker.puckPosition[0], 0.48, blockbreaker.puckPosition[1]); + if (!reducedMotion) puck.current.rotation.y += delta * 5.5; + puck.current.scale.setScalar(reducedMotion ? 1 : 1 + Math.sin(clock.elapsedTime * 7) * 0.08); + if (puckGlow.current) puckGlow.current.opacity = reducedMotion ? 0.58 : 0.58 + Math.sin(clock.elapsedTime * 7) * 0.18; + } + + const needsLayout = !layout.current.active + || layout.current.seed !== blockbreaker.seed + || layout.current.rows !== blockbreaker.rowsSpawned + || layout.current.broken !== blockbreaker.bricksBroken; + const brickMeshesReady = bricks.current.cyan + && bricks.current.amber + && bricks.current.magenta + && bricks.current.lime; + if (needsLayout && brickMeshesReady && marksA.current && marksB.current) { + const count = Math.min(BLOCKBREAKER_MAX_BRICKS, blockbreaker.bricks.length); + const colorCounts: Record = { cyan: 0, amber: 0, magenta: 0, lime: 0 }; + marksA.current.count = count; + marksB.current.count = count; + for (let index = 0; index < count; index += 1) { + const brick = blockbreaker.bricks[index]; + const x = blockbreakerColumnX(brick.column); + const z = blockbreakerRowZ(brick.row); + const colorIndex = colorCounts[brick.color]; + transform.position.set(x, 0.72, z); + transform.rotation.set(0, 0, 0); + transform.scale.set(BLOCKBREAKER_BRICK_WIDTH, 1.24, BLOCKBREAKER_BRICK_DEPTH); + transform.updateMatrix(); + bricks.current[brick.color]?.setMatrixAt(colorIndex, transform.matrix); + colorCounts[brick.color] = colorIndex + 1; + + transform.position.set(x, 0.72, z + BLOCKBREAKER_BRICK_DEPTH * 0.535); + transform.rotation.set(0, 0, brick.color === "amber" ? Math.PI / 4 : 0); + if (brick.color === "cyan") transform.scale.set(0.14, 0.38, 0.035); + else if (brick.color === "amber") transform.scale.set(0.27, 0.27, 0.035); + else if (brick.color === "magenta") transform.scale.set(0.35, 0.09, 0.035); + else transform.scale.set(0.34, 0.075, 0.035); + if (brick.color === "lime") transform.position.y += 0.16; + transform.updateMatrix(); + marksA.current.setMatrixAt(index, transform.matrix); + + transform.position.set(x, 0.72, z + BLOCKBREAKER_BRICK_DEPTH * 0.54); + transform.rotation.set(0, 0, 0); + if (brick.color === "magenta") transform.scale.set(0.09, 0.35, 0.035); + else if (brick.color === "lime") { + transform.position.y -= 0.16; + transform.scale.set(0.34, 0.075, 0.035); + } else transform.scale.setScalar(0.0001); + transform.updateMatrix(); + marksB.current.setMatrixAt(index, transform.matrix); + } + for (const color of BLOCKBREAKER_COLOR_KEYS) { + const mesh = bricks.current[color]; + if (!mesh) continue; + mesh.count = colorCounts[color]; + mesh.instanceMatrix.needsUpdate = true; + mesh.computeBoundingSphere(); + } + marksA.current.instanceMatrix.needsUpdate = true; + marksB.current.instanceMatrix.needsUpdate = true; + marksA.current.computeBoundingSphere(); + marksB.current.computeBoundingSphere(); + layout.current = { + active: true, + seed: blockbreaker.seed, + rows: blockbreaker.rowsSpawned, + broken: blockbreaker.bricksBroken, + }; + } + + if (arrow.current) { + const visible = state.phase === "combat" && blockbreakerAimPreviewVisible(blockbreaker, state.partyPositions.aelia); + arrow.current.visible = visible; + if (visible) { + const direction = hockeyReturnDirection(blockbreaker.aimDirection); + arrow.current.position.set(blockbreaker.puckPosition[0], 0.09, blockbreaker.puckPosition[1]); + arrow.current.rotation.y = Math.atan2(-direction[0], -direction[1]); + if (arrowMaterial.current) arrowMaterial.current.opacity = reducedMotion ? 0.72 : 0.62 + Math.sin(clock.elapsedTime * 8) * 0.2; + } + } + + if (breakFx.current) { + const age = state.time - blockbreaker.lastBreakAt; + const visible = age >= 0 && age < 0.42; + breakFx.current.visible = visible; + if (visible) { + const progress = age / 0.42; + breakFx.current.position.set(blockbreaker.puckPosition[0], 0.2, blockbreaker.puckPosition[1]); + breakFx.current.scale.setScalar(reducedMotion ? 1.4 : 0.6 + progress * 4.2); + if (breakFxMaterial.current) breakFxMaterial.current.opacity = reducedMotion ? 0.5 : (1 - progress) * 0.9; + } + } + }); + + return ( + + {BLOCKBREAKER_COLOR_KEYS.map((color) => ( + { bricks.current[color] = mesh; }} + args={[undefined, undefined, BLOCKBREAKER_MAX_BRICKS]} + castShadow + receiveShadow + frustumCulled={false} + > + + + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function AetherAssaultPlayfield() { + const root = useRef(null); + const hulls = useRef(null); + const wings = useRef(null); + const trails = useRef(null); + const warnings = useRef(null); + const playerShots = useRef(null); + const enemyShots = useRef(null); + const hullMaterial = useRef(null); + const wingMaterial = useRef(null); + const trailMaterial = useRef(null); + const hitRing = useRef(null); + const hitMaterial = useRef(null); + const transform = useMemo(() => new THREE.Object3D(), []); + const standardColors = useMemo(() => AETHER_STANDARD_SHIP_COLORS.map((color) => new THREE.Color(color)), []); + + useFrame(({ clock }) => { + const state = useGameStore.getState(); + const active = state.activityMode === "aether-assault"; + if (root.current) root.current.visible = active; + if (!active || !hulls.current || !wings.current || !trails.current || !warnings.current || !playerShots.current || !enemyShots.current) return; + + const reducedMotion = document.documentElement.classList.contains("force-reduced-motion"); + const aether = state.aetherAssault; + const shipCount = Math.min(AETHER_MAX_SHIPS, aether.ships.length); + const formationColor = standardColors[aetherShipColorIndex(aether.seed, aether.wave, 0)]; + hullMaterial.current?.color.copy(formationColor); + wingMaterial.current?.color.copy(formationColor); + trailMaterial.current?.color.copy(formationColor); + let trailCount = 0; + let warningCount = 0; + hulls.current.count = shipCount; + wings.current.count = shipCount; + for (let index = 0; index < shipCount; index += 1) { + const ship = aether.ships[index]; + const armored = ship.kind === "armored"; + const hover = reducedMotion ? 0 : Math.sin(clock.elapsedTime * 3.2 + index * 0.7) * 0.1; + const diveTilt = ship.phase === "diving" ? Math.sin(clock.elapsedTime * 5 + index) * 0.38 : 0; + transform.position.set(ship.position[0], 2.25 + hover, ship.position[1]); + transform.rotation.set(diveTilt, Math.PI, diveTilt * 0.45); + transform.scale.setScalar(armored ? 0.82 : 0.66); + transform.updateMatrix(); + hulls.current.setMatrixAt(index, transform.matrix); + + transform.position.set(ship.position[0], 2.12 + hover, ship.position[1] + 0.08); + transform.rotation.set(diveTilt, Math.PI, diveTilt * 0.45); + transform.scale.set(armored ? 1.38 : 1.12, armored ? 0.12 : 0.09, armored ? 0.76 : 0.62); + transform.updateMatrix(); + wings.current.setMatrixAt(index, transform.matrix); + + if (ship.phase === "entering" || ship.phase === "diving" || ship.phase === "returning") { + transform.position.set(ship.position[0], 2.18 + hover, ship.position[1] + 0.85); + transform.rotation.set(Math.PI / 2, 0, 0); + transform.scale.set(0.11, 0.11, ship.phase === "diving" ? 1.5 : 0.92); + transform.updateMatrix(); + trails.current.setMatrixAt(trailCount, transform.matrix); + trailCount += 1; + } + if (ship.phase === "diving") { + transform.position.set(ship.targetPosition[0], 0.045, HOCKEY_ARENA_MAX_Z - 0.9); + transform.rotation.set(-Math.PI / 2, 0, 0); + transform.scale.setScalar(reducedMotion ? 1 : 0.82 + Math.sin(clock.elapsedTime * 8) * 0.14); + transform.updateMatrix(); + warnings.current.setMatrixAt(warningCount, transform.matrix); + warningCount += 1; + } + } + hulls.current.instanceMatrix.needsUpdate = true; + wings.current.instanceMatrix.needsUpdate = true; + trails.current.count = trailCount; + warnings.current.count = warningCount; + trails.current.instanceMatrix.needsUpdate = true; + warnings.current.instanceMatrix.needsUpdate = true; + + const playerShotCount = Math.min(AETHER_MAX_PLAYER_SHOTS, aether.playerShots.length); + playerShots.current.count = playerShotCount; + for (let index = 0; index < playerShotCount; index += 1) { + const shot = aether.playerShots[index]; + transform.position.set(shot.position[0], 1.15, shot.position[1]); + transform.rotation.set(Math.PI / 2, 0, 0); + transform.scale.set(0.14, 0.14, 0.58); + transform.updateMatrix(); + playerShots.current.setMatrixAt(index, transform.matrix); + } + playerShots.current.instanceMatrix.needsUpdate = true; + + const enemyShotCount = Math.min(AETHER_MAX_ENEMY_SHOTS, aether.enemyShots.length); + enemyShots.current.count = enemyShotCount; + for (let index = 0; index < enemyShotCount; index += 1) { + const shot = aether.enemyShots[index]; + transform.position.set(shot.position[0], 0.82, shot.position[1]); + transform.rotation.set(0, 0, 0); + transform.scale.setScalar(0.24); + transform.updateMatrix(); + enemyShots.current.setMatrixAt(index, transform.matrix); + } + enemyShots.current.instanceMatrix.needsUpdate = true; + + if (hitRing.current) { + const age = state.time - aether.lastPlayerHitAt; + const visible = age >= 0 && age < 0.6; + hitRing.current.visible = visible; + if (visible) { + const progress = age / 0.6; + const player = state.partyPositions.aelia; + hitRing.current.position.set(player[0], 0.08, player[1]); + hitRing.current.scale.setScalar(0.7 + progress * 2.8); + if (hitMaterial.current) hitMaterial.current.opacity = reducedMotion ? 0.68 : (1 - progress) * 0.9; + } + } + }); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function Party({ playerAppearance }: { playerAppearance?: CharacterAppearanceV1 }) { const selected = useGameStore((state) => state.selectedMemberId); const [loadSupportModels, setLoadSupportModels] = useState(false); @@ -680,7 +1748,7 @@ function Party() { return ( <> }> - + {loadSupportModels ? ( @@ -708,16 +1776,107 @@ function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) { ); } -function BossFallback({ bossIndex }: { bossIndex: number }) { - const boss = useGameStore((state) => bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss); - const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion); +function OpponentCharacter({ memberId }: { memberId: MemberId }) { + const group = useRef(null); + const animationState = useRef("idle"); + const animationTrigger = useRef(0); + + useEffect(() => { + const start = useGameStore.getState().hockeyPvpOpponent.partyPositions[memberId]; + group.current?.position.set(start[0], 0.025, start[1]); + }, [memberId]); + + useFrame((_, delta) => { + if (!group.current) return; + const state = useGameStore.getState(); + const opponent = state.hockeyPvpOpponent; + const target = opponent.partyPositions[memberId]; + const dx = target[0] - group.current.position.x; + const dz = target[1] - group.current.position.z; + const moving = Math.hypot(dx, dz) > 0.015; + const blend = 1 - Math.pow(0.002, delta); + group.current.position.x = THREE.MathUtils.lerp(group.current.position.x, target[0], blend); + group.current.position.z = THREE.MathUtils.lerp(group.current.position.z, target[1], blend); + + const member = opponent.party.find((entry) => entry.id === memberId); + const actor = memberId === "aelia" ? null : opponent.partyCombat.combatants[memberId]; + const knocked = Boolean(member && member.knockedUntil > state.time); + const attacking = Boolean(actor?.visualAction && actor.visualAction.endsAt > state.time); + animationTrigger.current = !member || member.hp <= 0 + ? 0 + : knocked + ? member.knockedUntil + : attacking + ? actor?.visualAction?.startedAt ?? 0 + : 0; + animationState.current = !member || member.hp <= 0 + ? "death" + : knocked + ? "hit" + : attacking + ? "attack" + : moving + ? "walk" + : "idle"; + + if (member && member.hp > 0 && !knocked) { + const boss = opponent.bossMotion.position; + const facingX = boss[0] - group.current.position.x; + const facingZ = boss[1] - group.current.position.z; + const targetAngle = Math.atan2(facingX, facingZ); + const angleDelta = Math.atan2( + Math.sin(targetAngle - group.current.rotation.y), + Math.cos(targetAngle - group.current.rotation.y), + ); + group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta)); + } + }); + + return ( + + + + + + + + ); +} + +function OpponentPartyFallback() { + const positions = useGameStore((state) => state.hockeyPvpOpponent.partyPositions); + return ( + <> + {(Object.keys(positions) as MemberId[]).map((memberId) => ( + + + + + ))} + + ); +} + +function OpponentParty() { + return ( + }> + {(["aelia", "brann", "nia", "orin", "vale"] as MemberId[]).map((memberId) => ( + + ))} + + ); +} + +function BossFallback({ bossIndex, opponent = false }: { bossIndex: number; opponent?: boolean }) { + const boss = useGameStore((state) => opponent ? state.hockeyPvpOpponent.boss : bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss); + const motion = useGameStore((state) => opponent ? state.hockeyPvpOpponent.bossMotion : bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion); const group = useRef(null); const material = useRef(null); const deathElapsed = useRef(0); useFrame((_, delta) => { const defeated = (boss?.hp ?? 1) <= 0; deathElapsed.current = defeated ? deathElapsed.current + delta : 0; - const opacity = bossDeathOpacity(deathElapsed.current); + const opacity = bossDeathOpacity(deathElapsed.current, boss?.id); if (group.current) group.current.visible = opacity > 0; if (material.current) { const transparent = opacity < 0.999; @@ -742,54 +1901,37 @@ function BossFallback({ bossIndex }: { bossIndex: number }) { ); } -function BullBoss({ bossIndex }: { bossIndex: number }) { +function BullBoss({ bossIndex, opponent = false }: { bossIndex: number; opponent?: boolean }) { const phase = useGameStore((state) => state.phase); - const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion); - const motionMode = motion?.mode ?? "holding"; - const animationCue = motion ? bossAnimationCue(motion) : "idle"; - const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0); + const bossHp = useGameStore((state) => (opponent ? state.hockeyPvpOpponent.boss : bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0); const defeated = bossHp <= 0; const group = useRef(null); + const modelRoot = useRef(null); const light = useRef(null); const gltf = useGLTF(BULL_URL, false, true); const { model: bullScene, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]); const { actions } = useAnimations(gltf.animations, bullScene); const targetPosition = useMemo(() => new THREE.Vector3(), []); + useBossAnimationPlayback({ + actions, + clips: BULL_BOSS_ANIMATION_CONFIG, + archetype: "bull", + bossIndex, + opponent, + modelRoot, + }); + useEffect(() => { return () => { for (const entry of fadeMaterials) entry.material.dispose(); }; }, [fadeMaterials]); - useBossDeathFade(group, light, fadeMaterials, defeated, 2.8); - - const clipName = phase === "victory" || defeated - ? "Death" - : animationCue === "attack" - ? "Idle_Headlow" - : animationCue === "special" - ? "Gallop_Jump" - : animationCue === "move" - ? "Gallop" - : "Idle"; - - useEffect(() => { - const next = actions[clipName]; - if (!next) return; - for (const action of Object.values(actions)) action?.fadeOut(0.18); - next.reset().setEffectiveWeight(1).setEffectiveTimeScale(motionMode === "charging" ? 1.3 : 1).fadeIn(0.18).play(); - if (clipName === "Death") { - next.setLoop(THREE.LoopOnce, 1); - next.clampWhenFinished = true; - } else { - next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY); - } - return () => { next.fadeOut(0.18); }; - }, [actions, clipName, motionMode]); + useBossDeathFade(group, light, fadeMaterials, defeated, 2.8, "bulldrome"); useFrame((_, delta) => { if (!group.current) return; const state = useGameStore.getState(); - const current = encounterBossAt(state, bossIndex); + const current = encounterBossAt(state, bossIndex, opponent); if (!current) return; const motion = current.motion; targetPosition.set(motion.position[0], 0.03, motion.position[1]); @@ -801,8 +1943,12 @@ function BullBoss({ bossIndex }: { bossIndex: number }) { return; } - let facingX = state.partyPositions.brann[0] - motion.position[0]; - let facingZ = state.partyPositions.brann[1] - motion.position[1]; + const partyPositions = opponent ? state.hockeyPvpOpponent.partyPositions : state.partyPositions; + const party = opponent ? state.hockeyPvpOpponent.party : state.party; + const targetIndex = selectMeleeTargetIndex(party); + const targetId = targetIndex >= 0 ? party[targetIndex].id : "brann"; + let facingX = partyPositions[targetId][0] - motion.position[0]; + let facingZ = partyPositions[targetId][1] - motion.position[1]; if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") { facingX = motion.chargeEnd[0] - motion.chargeStart[0]; facingZ = motion.chargeEnd[1] - motion.chargeStart[1]; @@ -816,27 +1962,22 @@ function BullBoss({ bossIndex }: { bossIndex: number }) { if (phase === "briefing") return null; return ( - + + + ); } - -function alternateBossClip(kind: AlternateBossKind, motion: ReturnType["bossMotion"]) { - const config = ALTERNATE_BOSS_CONFIG[kind]; - return config[bossAnimationCue(motion)]; -} - -function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) { +function AlternateBoss({ kind, bossIndex, opponent = false }: { kind: AlternateBossKind; bossIndex: number; opponent?: boolean }) { const config = ALTERNATE_BOSS_CONFIG[kind]; const archetype = BOSS_ARCHETYPE_BY_ID[kind]; const phase = useGameStore((state) => state.phase); - const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion); - const motionMode = motion?.mode ?? "holding"; - const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0); + const bossHp = useGameStore((state) => (opponent ? state.hockeyPvpOpponent.boss : bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0); const defeated = bossHp <= 0; const group = useRef(null); + const modelRoot = useRef(null); const light = useRef(null); const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url); const gltf = useGameGLTF(assetUrl); @@ -845,38 +1986,25 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex const targetPosition = useMemo(() => new THREE.Vector3(), []); const burrowPositionY = bossBurrowPositionY(modelTopY, config.scale); + useBossAnimationPlayback({ + actions, + clips: config, + archetype, + bossIndex, + opponent, + modelRoot, + }); + useEffect(() => { return () => { for (const entry of fadeMaterials) entry.material.dispose(); }; }, [fadeMaterials]); - useBossDeathFade(group, light, fadeMaterials, defeated, 2.5); - - const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motion ?? useGameStore.getState().bossMotion); - - useEffect(() => { - const next = actions[clipName]; - if (!next) return; - for (const action of Object.values(actions)) action?.fadeOut(0.16); - const timeScale = archetype === "duelist" && motionMode === "mantis_line_telegraph" - ? 0.55 - : archetype === "duelist" && motionMode === "mantis_cross_telegraph" - ? 0.6 - : 1; - next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play(); - const authoredOneShot = ![config.idle, config.move].includes(clipName); - if (phase === "victory" || defeated || authoredOneShot) { - next.setLoop(THREE.LoopOnce, 1); - next.clampWhenFinished = true; - } else { - next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY); - } - return () => { next.fadeOut(0.16); }; - }, [actions, archetype, clipName, config.idle, config.move, defeated, motionMode, phase]); + useBossDeathFade(group, light, fadeMaterials, defeated, 2.5, kind); useFrame((_, delta) => { if (!group.current) return; const state = useGameStore.getState(); - const current = encounterBossAt(state, bossIndex); + const current = encounterBossAt(state, bossIndex, opponent); if (!current) return; const motion = current.motion; const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall"; @@ -886,9 +2014,13 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta)); if (!bossCanTrackTarget(current.boss.hp)) return; + const partyPositions = opponent ? state.hockeyPvpOpponent.partyPositions : state.partyPositions; + const party = opponent ? state.hockeyPvpOpponent.party : state.party; + const meleeTargetIndex = selectMeleeTargetIndex(party); + const meleeTargetId = meleeTargetIndex >= 0 ? party[meleeTargetIndex].id : "brann"; let targetAngle = Math.atan2( - state.partyPositions.brann[0] - motion.position[0], - state.partyPositions.brann[1] - motion.position[1], + partyPositions[meleeTargetId][0] - motion.position[0], + partyPositions[meleeTargetId][1] - motion.position[1], ); if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) { targetAngle = motion.breathAngle; @@ -896,7 +2028,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex motion.mode === "mantis_line_telegraph" || motion.mode === "mantis_cross_telegraph" ) { - const target = state.partyPositions[motion.chargeTargetId]; + const target = partyPositions[motion.chargeTargetId]; targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]); } else if (motion.mode === "telegraph" || motion.mode === "charging") { targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]); @@ -911,7 +2043,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex if (phase === "briefing") return null; return ( - + + + ); @@ -930,6 +2064,9 @@ function BarrierField() { if (!active) return; group.current.position.set(state.barrier.center[0], 0.058, state.barrier.center[1]); group.current.rotation.y = clock.elapsedTime * 0.08; + const fieldColor = state.barrier.kind === "spirit-link" ? "#9d8cf2" : "#e7bf46"; + if (fill.current) fill.current.color.set(fieldColor); + if (innerRing.current) innerRing.current.color.set(fieldColor); if (fill.current) fill.current.opacity = 0.16 + (Math.sin(clock.elapsedTime * 2.6) + 1) * 0.035; if (innerRing.current) innerRing.current.opacity = 0.38 + (Math.sin(clock.elapsedTime * 3.2) + 1) * 0.12; }); @@ -971,11 +2108,13 @@ function TankAuraField() { useFrame(({ clock }) => { if (!group.current) return; const state = useGameStore.getState(); - const active = state.phase === "combat" && state.partyCombat.tankAura.expiresAt > state.time && state.party[1].hp > 0; + const sourceId = state.partyCombat.tankAura.sourceId; + const source = state.party.find((member) => member.id === sourceId); + const active = state.phase === "combat" && state.partyCombat.tankAura.expiresAt > state.time && Boolean(source && source.hp > 0); group.current.visible = active; if (!active) return; - const brann = state.partyPositions.brann; - group.current.position.set(brann[0], 0.06, brann[1]); + const tankPosition = state.partyPositions[sourceId]; + group.current.position.set(tankPosition[0], 0.06, tankPosition[1]); group.current.rotation.y = clock.elapsedTime * -0.22; if (material.current) material.current.opacity = 0.13 + (Math.sin(clock.elapsedTime * 5) + 1) * 0.05; }); @@ -1000,7 +2139,7 @@ function TankAuraField() { ); } -function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) { +function RangedProjectile({ memberId }: { memberId: AiCombatantId }) { const projectile = useRef(null); const impact = useRef(null); const coreMaterial = useRef(null); @@ -1015,14 +2154,18 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) { const current = useMemo(() => new THREE.Vector3(), []); const direction = useMemo(() => new THREE.Vector3(), []); const up = useMemo(() => new THREE.Vector3(0, 1, 0), []); + const visualArchetype = useGameStore((state) => state.party.find((member) => member.id === memberId)?.runProfile?.visualArchetype); + const arrowStyle = visualArchetype === "ranger" || !visualArchetype && memberId === "nia"; useFrame(({ clock }) => { if (!projectile.current || !impact.current) return; const state = useGameStore.getState(); const action = state.partyCombat.combatants[memberId].visualAction; const member = state.party.find((entry) => entry.id === memberId)!; + const profile = action ? partyAttackVfxProfile(action.abilityId) : null; const rapid = action?.abilityId === "rapid_fire"; const active = action !== null + && profile?.style === "projectile" && state.phase === "combat" && member.hp > 0 && action.abilityId !== "overcharge" @@ -1030,9 +2173,8 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) { && (rapid ? state.time <= action.endsAt : state.time <= action.impactAt); projectile.current.visible = active; impact.current.visible = false; - if (!action || state.phase !== "combat" || member.hp <= 0 || action.abilityId === "overcharge") return; + if (!action || !profile || profile.style !== "projectile" || state.phase !== "combat" || member.hp <= 0 || action.abilityId === "overcharge") return; - const profile = partyAttackVfxProfile(action.abilityId); if (lastAbilityId.current !== action.abilityId) { lastAbilityId.current = action.abilityId; coreMaterial.current?.color.set(profile.primary); @@ -1053,11 +2195,11 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) { end.set(target[0], 1.12, target[1]); if (active) { current.copy(start).lerp(end, progress); - current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34); + current.y += Math.sin(progress * Math.PI) * (arrowStyle ? 0.34 : 0.95); projectile.current.position.copy(current); direction.subVectors(end, start).normalize(); projectile.current.quaternion.setFromUnitVectors(up, direction); - const pulseScale = memberId === "orin" ? 1 + Math.sin(clock.elapsedTime * 14) * 0.12 : 1; + const pulseScale = arrowStyle ? 1 : 1 + Math.sin(clock.elapsedTime * 14) * 0.12; projectile.current.scale.setScalar(profile.scale * pulseScale); trail.current?.scale.set(1, profile.trail, 1); if (trailMaterial.current) trailMaterial.current.opacity = 0.34 + Math.sin(clock.elapsedTime * 10) * 0.08; @@ -1079,7 +2221,7 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) { return ( <> - {memberId === "nia" ? ( + {arrowStyle ? ( <> @@ -1132,13 +2274,15 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) { function RangedProjectiles() { return ( <> + + ); } -function CloseAttackVfx({ memberId }: { memberId: "brann" | "orin" | "vale" }) { +function CloseAttackVfx({ memberId }: { memberId: AiCombatantId }) { const group = useRef(null); const firstArc = useRef(null); const secondArc = useRef(null); @@ -1230,7 +2374,7 @@ function CloseAttackVfx({ memberId }: { memberId: "brann" | "orin" | "vale" }) { ); } -function PartyPowerAuraVfx({ memberId }: { memberId: "orin" | "vale" }) { +function PartyPowerAuraVfx({ memberId }: { memberId: AiCombatantId }) { const group = useRef(null); const material = useRef(null); useFrame(({ clock }) => { @@ -1238,12 +2382,13 @@ function PartyPowerAuraVfx({ memberId }: { memberId: "orin" | "vale" }) { const state = useGameStore.getState(); const actor = state.partyCombat.combatants[memberId]; const member = state.party.find((entry) => entry.id === memberId)!; - const active = state.phase === "combat" && member.hp > 0 && (memberId === "orin" ? actor.overchargeStacks > 0 : actor.bladeFlurryUntil > state.time); + const overcharged = actor.overchargeStacks > 0; + const active = state.phase === "combat" && member.hp > 0 && (overcharged || actor.bladeFlurryUntil > state.time); group.current.visible = active; if (!active) return; const position = state.partyPositions[memberId]; group.current.position.set(position[0], 0.16, position[1]); - group.current.rotation.y = clock.elapsedTime * (memberId === "orin" ? 1.4 : -1.8); + group.current.rotation.y = clock.elapsedTime * (overcharged ? 1.4 : -1.8); const pulse = 0.92 + Math.sin(clock.elapsedTime * 5.5) * 0.12; group.current.scale.setScalar(pulse); if (material.current) material.current.opacity = 0.38 + Math.sin(clock.elapsedTime * 4.2) * 0.1; @@ -1268,8 +2413,11 @@ function PartyCombatVfx() { <> + + + @@ -1291,6 +2439,106 @@ function BossActor() { ); } +function OpponentBossActor() { + const phase = useGameStore((state) => state.phase); + const bossId = useGameStore((state) => state.hockeyPvpOpponent.boss.id); + if (phase === "briefing") return null; + return ( + }> + {bossId === "bulldrome" + ? + : } + + ); +} + +function HockeyHealingPvpPlayfield() { + const puck = useRef(null); + const puckGlow = useRef(null); + const arrow = useRef(null); + const arrowMaterial = useRef(null); + + useFrame(({ clock }) => { + const state = useGameStore.getState(); + const active = state.activityMode === "hockey-healing-pvp"; + if (puck.current) { + puck.current.visible = active; + if (active) { + puck.current.position.set(state.hockeyPvp.puckPosition[0], 0.48, state.hockeyPvp.puckPosition[1]); + puck.current.rotation.y += 0.07; + puck.current.scale.setScalar(1 + Math.sin(clock.elapsedTime * 7) * 0.08); + if (puckGlow.current) puckGlow.current.opacity = 0.6 + Math.sin(clock.elapsedTime * 7) * 0.18; + } + } + if (!arrow.current) return; + const localPlayer = hockeyPvpLocalToWorld(state.partyPositions.aelia); + const distance = Math.hypot( + state.hockeyPvp.puckPosition[0] - localPlayer[0], + state.hockeyPvp.puckPosition[1] - localPlayer[1], + ); + const visible = active + && state.phase === "combat" + && state.hockeyPvp.puckVelocity[1] > 0 + && distance < 8; + arrow.current.visible = visible; + if (!visible) return; + const direction = hockeyReturnDirection(state.hockeyPvp.aimDirection); + arrow.current.position.set(state.hockeyPvp.puckPosition[0], 0.09, state.hockeyPvp.puckPosition[1]); + arrow.current.rotation.y = Math.atan2(-direction[0], -direction[1]); + if (arrowMaterial.current) arrowMaterial.current.opacity = 0.62 + Math.sin(clock.elapsedTime * 8) * 0.2; + }); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function EncounterActors({ playerAppearance }: { playerAppearance?: CharacterAppearanceV1 }) { + const pvp = useGameStore((state) => state.activityMode === "hockey-healing-pvp"); + const localOffset = pvp ? HOCKEY_PVP_SIDE_OFFSET_Z : 0; + return ( + <> + + + + + + + + + + {pvp && ( + + + + + )} + + ); +} + type PerformanceMemory = Performance & { memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number }; }; @@ -1384,9 +2632,10 @@ function PerformanceProbe() { const FX_BURST_PARTICLE_COUNT = 8; function scenePulseColor(kind: PulseKind) { - if (kind === "shield") return "#62bdff"; - if (kind === "purify") return "#c39bff"; - if (kind === "renew") return "#72e0a1"; + if (kind === "protective") return "#62bdff"; + if (kind === "cleanse") return "#c39bff"; + if (kind === "periodic-heal") return "#72e0a1"; + if (kind === "field") return "#d8c16e"; if (kind === "breath") return "#66dcff"; if (kind === "venom") return "#8fdb4f"; if (kind === "tether") return "#d482ff"; @@ -1456,7 +2705,7 @@ function CombatFx() { return ; } -export function GameScene() { +export function GameScene({ playerAppearance }: { playerAppearance?: CharacterAppearanceV1 }) { const [dpr, setDpr] = useState(() => Math.min(MAX_RENDER_DPR, Math.max(MIN_RENDER_DPR, window.devicePixelRatio || 1))); const setRenderDpr = useCallback((next: number) => { setDpr((current) => Math.abs(current - next) < 0.001 ? current : next); @@ -1473,19 +2722,212 @@ export function GameScene() { - - - - - - - + + + + + {PERFORMANCE_PROBE_ENABLED && } ); } +const MIXED_GALLERY_APPEARANCE: CharacterAppearanceV1 = { + version: 1, + rigId: "medium", + scaleSourceMemberId: "brann", + headPartId: "rogue-head", + upperBodyPartId: "knight-upper", + lowerBodyPartId: "ranger-lower", + headwearPartId: "mage-hat", + backPartId: "druid-backpack", + mainHand: { modelId: "cc/adv_wand", grip: "wand" }, + offHand: { modelId: "cc/spellbook_open", grip: "prop" }, +}; + +function galleryAppearanceOverride(classId: HealerClassId, mix: string | null) { + if (!mix || CHARACTER_MODEL_MODE !== "modular") return undefined; + const base = HEALER_VISUAL_PROFILES[classId].appearance; + if (mix === "head") return { ...base, headPartId: "rogue-head" } satisfies CharacterAppearanceV1; + if (mix === "upper") return { ...base, upperBodyPartId: "knight-upper" } satisfies CharacterAppearanceV1; + if (mix === "lower") return { ...base, lowerBodyPartId: "ranger-lower" } satisfies CharacterAppearanceV1; + if (mix === "headwear") return { ...base, headwearPartId: "knight-helmet" } satisfies CharacterAppearanceV1; + if (mix === "back") return { ...base, backPartId: "druid-backpack" } satisfies CharacterAppearanceV1; + if (mix === "weapons") return { + ...base, + mainHand: { modelId: "cc/adv_wand", grip: "wand" }, + offHand: { modelId: "cc/spellbook_open", grip: "prop" }, + } satisfies CharacterAppearanceV1; + return mix === "all" || mix === "1" ? MIXED_GALLERY_APPEARANCE : undefined; +} + +export type HealerPreviewAnimation = Extract; + +function PreviewHealerActor({ + appearanceOverride, + animation = "idle", + classId, + modelMode = CHARACTER_MODEL_MODE, + positionX, +}: { + appearanceOverride?: CharacterAppearanceV1; + animation?: HealerPreviewAnimation; + classId: HealerClassId; + modelMode?: CharacterModelMode; + positionX: number; +}) { + const animationState = useRef(animation); + const animationTrigger = useRef(0); + const profile = HEALER_VISUAL_PROFILES[classId]; + + useEffect(() => { + if (animationState.current === animation) return; + animationState.current = animation; + animationTrigger.current += 1; + }, [animation]); + + return ( + + + + + + + + ); +} + +function GalleryCamera() { + const { camera } = useThree(); + useEffect(() => { + camera.lookAt(0, 1.15, 0); + camera.updateProjectionMatrix(); + }, [camera]); + return null; +} + +function PreviewFrameScheduler() { + const { advance } = useThree(); + const frameId = useRef(null); + const lastRenderedAt = useRef(null); + + useEffect(() => { + const schedule = (now: number) => { + const previous = lastRenderedAt.current; + if (previous === null || now - previous + FRAME_INTERVAL_JITTER_MS >= GAMEPLAY_FRAME_INTERVAL_MS) { + lastRenderedAt.current = now; + advance(now / 1000, true); + } + frameId.current = window.requestAnimationFrame(schedule); + }; + frameId.current = window.requestAnimationFrame(schedule); + return () => { + if (frameId.current !== null) window.cancelAnimationFrame(frameId.current); + frameId.current = null; + lastRenderedAt.current = null; + }; + }, [advance]); + + return null; +} + +export function HealerAppearancePreview({ + animation = "idle", + appearance, + classId, + modelMode = CHARACTER_MODEL_MODE, +}: { + animation?: HealerPreviewAnimation; + appearance: CharacterAppearanceV1; + classId: HealerClassId; + modelMode?: CharacterModelMode; +}) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +/** Development-only deterministic view used for rig, socket, and silhouette QA. */ +export function HealerModelGallery() { + const query = new URLSearchParams(window.location.search); + const requestedClass = query.get("class"); + const requestedMix = query.get("mix"); + const galleryClasses = HEALER_CLASS_ORDER.filter((classId) => !requestedClass || classId === requestedClass); + const visibleClasses = galleryClasses.length > 0 ? galleryClasses : HEALER_CLASS_ORDER; + const solo = visibleClasses.length === 1; + return ( +
+ + + + + + + + + + + + + {visibleClasses.map((classId, index) => ( + + ))} + + + +
+ {visibleClasses.map((classId) => {classId})} +
+
+ ); +} + if (LEGACY_GAME_ASSETS_FORCED) { for (const memberId of CRITICAL_PARTY_MEMBER_IDS) { useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true); diff --git a/src/components/HealerClassAccessory.tsx b/src/components/HealerClassAccessory.tsx new file mode 100644 index 0000000..8540694 --- /dev/null +++ b/src/components/HealerClassAccessory.tsx @@ -0,0 +1,115 @@ +import { useFrame } from "@react-three/fiber"; +import { useRef } from "react"; +import type * as THREE from "three"; +import type { HealerVisualProfile } from "../game/healerVisuals"; + +function RelicMaterial({ color }: { color: string }) { + return ( + + ); +} + +export function HealerClassAccessory({ profile }: { profile: HealerVisualProfile }) { + const animated = useRef(null); + + useFrame((_, delta) => { + if (!animated.current || profile.accessory !== "clockwork-rings") return; + animated.current.rotation.z += delta * 0.42; + animated.current.rotation.y -= delta * 0.18; + }); + + if (profile.accessory === "sun-halo") { + return ( + + + + + + + + + + + ); + } + + if (profile.accessory === "grove-antlers") { + return ( + + + + + + + + + + + + + + + + + + + ); + } + + if (profile.accessory === "storm-totem") { + return ( + + + + + + + + + + + + + + + ); + } + + if (profile.accessory === "sun-crest") { + return ( + + + + + + + + + + + ); + } + + return ( + + + + + + + + + + + + + + + ); +} diff --git a/src/components/ModularCharacterBody.tsx b/src/components/ModularCharacterBody.tsx new file mode 100644 index 0000000..69fe86d --- /dev/null +++ b/src/components/ModularCharacterBody.tsx @@ -0,0 +1,114 @@ +import { createPortal } from "@react-three/fiber"; +import { useEffect, useMemo } from "react"; +import * as THREE from "three"; +import { + CHARACTER_PART_CATALOG, + characterAppearancePartIds, + type CharacterAppearanceV1, + type CharacterPartId, +} from "../game/characterAppearance"; +import type { MemberId } from "../game/types"; +import { useGameGLTF } from "./GameAssetProvider"; + +interface BoundCharacterPart { + group: THREE.Group; + skeletons: THREE.Skeleton[]; +} + +function rigBonesByName(rigScene: THREE.Object3D) { + const bones = new Map(); + rigScene.traverse((object) => { + if (object instanceof THREE.Bone) bones.set(object.name, object); + }); + return bones; +} + +function createBoundCharacterPart( + sourceScene: THREE.Object3D, + rigScene: THREE.Object3D, + partId: CharacterPartId, +): BoundCharacterPart { + const definition = CHARACTER_PART_CATALOG[partId]; + const rigBones = rigBonesByName(rigScene); + const group = new THREE.Group(); + group.name = `character-part:${partId}`; + const skeletons: THREE.Skeleton[] = []; + + sourceScene.updateMatrixWorld(true); + for (const nodeName of definition.nodeNames) { + const sourceNode = sourceScene.getObjectByName(nodeName); + if (!sourceNode) throw new Error(`Character part ${partId} is missing node ${nodeName}.`); + + const partNode = sourceNode.clone(true); + partNode.matrix.copy(sourceNode.matrixWorld); + partNode.matrix.decompose(partNode.position, partNode.quaternion, partNode.scale); + partNode.traverse((object) => { + if (!(object instanceof THREE.SkinnedMesh)) return; + const mappedBones = object.skeleton.bones.map((sourceBone) => { + const rigBone = rigBones.get(sourceBone.name); + if (!rigBone) throw new Error(`Character part ${partId} cannot resolve rig bone ${sourceBone.name}.`); + return rigBone; + }); + const skeleton = new THREE.Skeleton( + mappedBones, + object.skeleton.boneInverses.map((inverse) => inverse.clone()), + ); + const bindMatrix = object.bindMatrix.clone(); + object.bind(skeleton, bindMatrix); + object.castShadow = true; + object.receiveShadow = true; + object.frustumCulled = false; + skeletons.push(skeleton); + }); + group.add(partNode); + } + + return { group, skeletons }; +} + +function ModularCharacterPart({ + actorScene, + modelUrls, + partId, +}: { + actorScene: THREE.Object3D; + modelUrls: Record; + partId: CharacterPartId; +}) { + const definition = CHARACTER_PART_CATALOG[partId]; + const gltf = useGameGLTF(modelUrls[definition.sourceMemberId]); + const boundPart = useMemo( + () => createBoundCharacterPart(gltf.scene, actorScene, partId), + [actorScene, gltf.scene, partId], + ); + + useEffect(() => () => { + for (const skeleton of boundPart.skeletons) skeleton.dispose(); + }, [boundPart]); + + const rigRoot = actorScene.getObjectByName("Rig_Medium") ?? actorScene; + return createPortal(, rigRoot); +} + +export function ModularCharacterBody({ + actorScene, + appearance, + modelUrls, +}: { + actorScene: THREE.Object3D; + appearance: CharacterAppearanceV1; + modelUrls: Record; +}) { + return ( + <> + {characterAppearancePartIds(appearance).map((partId) => ( + + ))} + + ); +} diff --git a/src/components/TopScreen.tsx b/src/components/TopScreen.tsx index 1e43a7a..3c75c69 100644 --- a/src/components/TopScreen.tsx +++ b/src/components/TopScreen.tsx @@ -1,11 +1,15 @@ -import { lazy, Suspense } from "react"; -import { barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store"; -import { HEALER_CLASSES } from "../game/healers"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store"; +import { HEALER_ABILITIES } from "../game/healers"; import { BOSS_DEFINITIONS } from "../game/bossCatalog"; import { bossRoomFor } from "../game/bossRooms"; import { tankAuraProtects } from "../game/partyCombat"; import { BuffDraftPanel } from "./BuffDraftPanel"; import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; +import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp"; +import { blockbreakerTimeMultiplier } from "../game/blockbreaker"; +import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay"; +import type { CharacterAppearanceV1 } from "../game/characterAppearance"; const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))); @@ -43,10 +47,12 @@ function CompactParty() { )} - {member.renewExpiresAt > 0 && R} + {member.healingEffects.some((effect) => effect.expiresAt > time) && H} + {member.reactiveHeal && member.reactiveHeal.expiresAt > time && E{member.reactiveHeal.charges}} {member.debuffs.length > 0 && !} {barrierProtects(partyPositions[member.id], barrier, time) && B} - {tankAuraProtects(partyPositions[member.id], partyPositions.brann, tankAura, time) && T} + {barrier.kind === "spirit-link" && healerFieldContains(partyPositions[member.id], barrier, time) && S} + {tankAuraProtects(partyPositions[member.id], partyPositions[tankAura.sourceId], tankAura, time) && T} {member.knockedUntil > time && KD} @@ -57,12 +63,11 @@ function CompactParty() { } function CastingBar() { - const healerClassId = useGameStore((state) => state.healerClassId); - const abilityName = HEALER_CLASSES[healerClassId].abilities.mend.name; const activeCast = useGameStore((state) => state.activeCast); const time = useGameStore((state) => state.time); const party = useGameStore((state) => state.party); if (!activeCast) return null; + const abilityName = HEALER_ABILITIES[activeCast.abilityId].name; const duration = activeCast.completesAt - activeCast.startedAt; const progress = Math.min(1, Math.max(0, (time - activeCast.startedAt) / duration)); const target = party.find((member) => member.id === activeCast.targetId); @@ -78,13 +83,19 @@ function CastingBar() { function BossBar() { const boss = useGameStore((state) => state.boss); const additionalBosses = useGameStore((state) => state.additionalBosses); + const opponentBoss = useGameStore((state) => state.hockeyPvpOpponent.boss); const phase = useGameStore((state) => state.phase); + const activityMode = useGameStore((state) => state.activityMode); if (phase === "briefing") return null; - const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)]; + const hockeyMode = activityMode === "hockey-healing"; + const blockbreakerMode = activityMode === "blockbreaker"; + const aetherAssaultMode = activityMode === "aether-assault"; + const pvpMode = activityMode === "hockey-healing-pvp"; + const bosses = pvpMode ? [boss, opponentBoss] : [boss, ...additionalBosses.map((entry) => entry.boss)]; return ( -
1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""}`}> - {bosses.map((entry) =>
-
Vault Beast{entry.name}{Math.ceil((entry.hp / entry.maxHp) * 100)}%
+
1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""} ${pvpMode ? "is-pvp" : ""}`}> + {bosses.map((entry, index) =>
+
{pvpMode ? index === 0 ? "Your target" : "Rival target" : hockeyMode ? index === 0 ? "Striker · paddle + damage" : "Frontline · party damage" : blockbreakerMode ? index === 0 ? "Breaker flank" : "Wall pressure" : aetherAssaultMode ? index === 0 ? "Arcade flank" : "Party pressure" : "Vault Beast"}{entry.name}{Math.ceil((entry.hp / entry.maxHp) * 100)}%
)}
@@ -110,48 +121,123 @@ function EncounterCallout() { function PhaseOverlay() { const phase = useGameStore((state) => state.phase); const runMode = useGameStore((state) => state.runMode); + const activityMode = useGameStore((state) => state.activityMode); const round = useGameStore((state) => state.round); const endlessMode = useGameStore((state) => state.endlessMode); const endlessBossKills = useGameStore((state) => state.endlessBossKills); const primaryBoss = useGameStore((state) => state.boss); const additionalBosses = useGameStore((state) => state.additionalBosses); + const hockey = useGameStore((state) => state.hockey); + const blockbreaker = useGameStore((state) => state.blockbreaker); + const aetherAssault = useGameStore((state) => state.aetherAssault); + const hockeyPvp = useGameStore((state) => state.hockeyPvp); + if (runMode === "rpg-roguelike") return null; if (phase === "intermission") return ; const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)]; const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]); const room = bossRoomFor(primaryBoss.id); const bossNames = bosses.map((boss) => boss.name).join(" & "); + const hockeyMode = activityMode === "hockey-healing"; + const blockbreakerMode = activityMode === "blockbreaker"; + const aetherAssaultMode = activityMode === "aether-assault"; + const pvpMode = activityMode === "hockey-healing-pvp"; const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode; - const endlessDefeat = phase === "defeat" && endlessMode; - const briefingMode = runMode === "rogue-trials" + const endlessDefeat = phase === "defeat" && endlessMode && !hockeyMode && !blockbreakerMode && !aetherAssaultMode; + const briefingMode = hockeyMode + ? "Endless Goal Defense" + : blockbreakerMode + ? "Endless Color Break" + : aetherAssaultMode + ? "Endless Arcade Assault" + : pvpMode + ? `Versus ${hockeyPvp.opponentName}` + : runMode === "rogue-trials" ? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round" : bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial; if (phase === "combat") return null; const title = phase === "briefing" - ? room.name + ? hockeyMode ? "Hockey Healing" : blockbreakerMode ? "Blockbreaker" : aetherAssaultMode ? "Aether Assault" : pvpMode ? "Healing Hockey PVP" : room.name : phase === "victory" - ? showEndlessChoice ? "Rogue Trials Cleared" : `${bossNames} Broken` - : endlessDefeat ? "Endless Run Ended" : "Party Broken"; + ? showEndlessChoice ? "Rogue Trials Cleared" : pvpMode ? "Match Won" : `${bossNames} Broken` + : hockeyMode ? "Goal Breached" : blockbreakerMode && blockbreaker.status === "lost" ? "Wall Breached" : aetherAssaultMode ? "Formation Lost" : pvpMode ? "Match Lost" : endlessDefeat ? "Endless Run Ended" : "Party Broken"; const eyebrow = phase === "briefing" - ? `${briefingMode} · ${room.biome}` + ? hockeyMode ? `${briefingMode} · Rectangular Boss Rink` : blockbreakerMode ? `${briefingMode} · Advancing Brick Rink` : aetherAssaultMode ? `${briefingMode} · Bright Five-Lane Rink` : pvpMode ? `${briefingMode} · Extended Versus Rink` : `${briefingMode} · ${room.biome}` : phase === "victory" - ? showEndlessChoice ? "Endless Path Unlocked" : "Encounter Complete" - : endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed"; + ? showEndlessChoice ? "Endless Path Unlocked" : pvpMode ? `${hockeyPvp.opponentGoalsConceded} Rival Goals · ${endlessBossKills} Boss Kills` : "Encounter Complete" + : hockeyMode ? `${hockey.returns} Pucks Returned · ${endlessBossKills} Bosses Defeated` : blockbreakerMode ? `${blockbreaker.bricksBroken} Bricks · ${blockbreaker.score.toLocaleString()} Points` : aetherAssaultMode ? `${aetherAssault.score.toLocaleString()} Points · Wave ${aetherAssault.wave}` : pvpMode ? `${hockeyPvp.localGoalsConceded} Goals Conceded · ${hockeyPvp.opponentBossKills} Rival Boss Kills` : endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed"; const copy = phase === "briefing" - ? definitions.map((boss) => boss.briefing).join(" ") + ? hockeyMode + ? "Defend the wide blue goal. Aim each return; the moving Pong paddle strikes it back. Party fights both bosses on enemy half. Fallen bosses are replaced." + : blockbreakerMode + ? "Aim the puck into advancing five-brick rows. Matching orthogonal colors break as one combo while two bosses pressure the party. Missed pucks safely re-serve." + : aetherAssaultMode + ? "Move across the full bright rink while spellfire launches automatically. Line up ship formations, dodge red bolts and dives, and keep healing through two endless bosses." + : pvpMode + ? "Two parties fight matching boss sequences. Defend your goal and aim each return. Every goal deals 45 damage to all five players. Fallen bosses respawn instantly." + : definitions.map((boss) => boss.briefing).join(" ") : phase === "victory" - ? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : "Five entered. Five endured." - : endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" "); + ? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : pvpMode ? `${hockeyPvp.opponentName}'s party fell first.` : "Five entered. Five endured." + : hockeyMode + ? `Run ended after ${hockey.returns} returns and ${endlessBossKills} boss kills.` + : blockbreakerMode + ? `Run record: ${blockbreaker.bricksBroken} bricks, ${blockbreaker.score.toLocaleString()} points, and ${endlessBossKills} boss kills.` + : aetherAssaultMode + ? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.` + : pvpMode ? `${hockeyPvp.opponentName} kept their party standing.` + : endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" "); return (
{eyebrow}

{title}

{copy}

- {phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : "Restart from lower display"} + {phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}
); } +function BlockbreakerScorePopup() { + const activityMode = useGameStore((state) => state.activityMode); + const lastBreakAt = useGameStore((state) => state.blockbreaker.lastBreakAt); + const count = useGameStore((state) => state.blockbreaker.lastBreakCount); + const award = useGameStore((state) => state.blockbreaker.lastScoreAward); + const [visibleAt, setVisibleAt] = useState(null); + + useEffect(() => { + if (activityMode !== "blockbreaker" || !Number.isFinite(lastBreakAt)) { + setVisibleAt(null); + return; + } + setVisibleAt(lastBreakAt); + const timeout = window.setTimeout(() => setVisibleAt((current) => current === lastBreakAt ? null : current), 850); + return () => window.clearTimeout(timeout); + }, [activityMode, lastBreakAt]); + + if (visibleAt === null) return null; + return
+{award.toLocaleString()}{count} {count === 1 ? "brick" : "brick combo"}
; +} + +function AetherScorePopup() { + const activityMode = useGameStore((state) => state.activityMode); + const lastKillAt = useGameStore((state) => state.aetherAssault.lastKillAt); + const award = useGameStore((state) => state.aetherAssault.lastKillScore); + const multiplier = useGameStore((state) => state.aetherAssault.multiplier); + const [visibleAt, setVisibleAt] = useState(null); + + useEffect(() => { + if (activityMode !== "aether-assault" || !Number.isFinite(lastKillAt)) { + setVisibleAt(null); + return; + } + setVisibleAt(lastKillAt); + const timeout = window.setTimeout(() => setVisibleAt((current) => current === lastKillAt ? null : current), 700); + return () => window.clearTimeout(timeout); + }, [activityMode, lastKillAt]); + + if (visibleAt === null) return null; + return
+{award.toLocaleString()}{multiplier.toFixed(2)}× streak
; +} + function PauseOverlay({ onExit }: { onExit?: () => void }) { const paused = useGameStore((state) => state.paused); const selection = useGameStore((state) => state.pauseSelection); @@ -170,14 +256,12 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {

Simulation, damage, and movement are stopped.

@@ -188,30 +272,116 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) { ); } -export function TopScreen({ onExit }: { onExit?: () => void }) { +function GoalPopup() { + const activityMode = useGameStore((state) => state.activityMode); + const goalSequence = useGameStore((state) => state.hockeyPvp.goalSequence); + const lastGoalSide = useGameStore((state) => state.hockeyPvp.lastGoalSide); + const lastSeenSequence = useRef(goalSequence); + const [visibleSequence, setVisibleSequence] = useState(null); + + useEffect(() => { + if (activityMode !== "hockey-healing-pvp" || goalSequence < lastSeenSequence.current) { + lastSeenSequence.current = goalSequence; + setVisibleSequence(null); + return; + } + if (goalSequence === lastSeenSequence.current) return; + lastSeenSequence.current = goalSequence; + setVisibleSequence(goalSequence); + const timeout = window.setTimeout(() => { + setVisibleSequence((current) => current === goalSequence ? null : current); + }, 1_250); + return () => window.clearTimeout(timeout); + }, [activityMode, goalSequence]); + + if (visibleSequence === null) return null; + return ( +
+ GOAL +
+ ); +} + +function DampeningIndicator() { + const activityMode = useGameStore((state) => state.activityMode); + const localBossKills = useGameStore((state) => state.endlessBossKills); + const opponentBossKills = useGameStore((state) => state.hockeyPvp.opponentBossKills); + if (activityMode !== "hockey-healing-pvp") return null; + const percent = hockeyPvpDampeningPercent(localBossKills, opponentBossKills); + return ( +
+ Dampening{percent}% + +
+ ); +} + +export function TopScreen({ + onExit, + playerAppearance, +}: { + onExit?: () => void; + playerAppearance?: CharacterAppearanceV1; +}) { const phase = useGameStore((state) => state.phase); const bossCount = useGameStore((state) => state.additionalBosses.length + 1); const round = useGameStore((state) => state.round); const runMode = useGameStore((state) => state.runMode); + const activityMode = useGameStore((state) => state.activityMode); const endlessMode = useGameStore((state) => state.endlessMode); const endlessBossKills = useGameStore((state) => state.endlessBossKills); + const hockeyReturns = useGameStore((state) => state.hockey.returns); + const blockbreaker = useGameStore((state) => state.blockbreaker); + const aetherAssault = useGameStore((state) => state.aetherAssault); + const hockeyPvp = useGameStore((state) => state.hockeyPvp); + const time = useGameStore((state) => state.time); const setPaused = useGameStore((state) => state.setPaused); + const rpgRun = useGameStore((state) => state.rpgRun); + const rpgFocusId = useGameStore((state) => state.rpgFocusId); + const dispatchRpgAction = useGameStore((state) => state.dispatchRpgAction); + const setRpgFocusId = useGameStore((state) => state.setRpgFocusId); + const restart = useGameStore((state) => state.restart); + const hockeyMode = activityMode === "hockey-healing"; + const blockbreakerMode = activityMode === "blockbreaker"; + const aetherAssaultMode = activityMode === "aether-assault"; + const pvpMode = activityMode === "hockey-healing-pvp"; + const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`; return (
}> - +
-
{endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}{endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}
+
{hockeyMode ? `Hockey Healing · ${hockeyReturns} returns · ${duration}` : blockbreakerMode ? `Blockbreaker · ${blockbreaker.score.toLocaleString()} pts · ${duration}` : aetherAssaultMode ? `Aether Assault · ${aetherAssault.score.toLocaleString()} pts · ${duration}` : pvpMode ? `VS ${hockeyPvp.opponentName} · ${duration}` : endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}{hockeyMode ? `Defend wide goal · ${endlessBossKills} boss kills` : blockbreakerMode ? `${blockbreaker.bricksBroken} bricks · ${blockbreakerTimeMultiplier(time).toFixed(1)}× · row in ${Math.max(0, blockbreaker.nextRowAt - time).toFixed(1)}s` : aetherAssaultMode ? `Wave ${aetherAssault.wave} · ${aetherAssault.ships.length} ships · ${aetherAssault.multiplier.toFixed(2)}×` : pvpMode ? `Goals ${hockeyPvp.opponentGoalsConceded}–${hockeyPvp.localGoalsConceded} · Bosses ${endlessBossKills}–${hockeyPvp.opponentBossKills}` : endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}
+ -
WASD Move Q / E Target 1–6 Cast
+
WASD Move{aetherAssaultMode ? " + auto-fire" : ""} Q / E Target 1–6 Cast
{onExit && }
+ + + + {runMode === "rpg-roguelike" && rpgRun && ( + + )}
); diff --git a/src/components/aetherAssaultVisuals.test.ts b/src/components/aetherAssaultVisuals.test.ts new file mode 100644 index 0000000..512e009 --- /dev/null +++ b/src/components/aetherAssaultVisuals.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { + AETHER_ARMORED_SHIP_COLORS, + AETHER_STANDARD_SHIP_COLORS, + aetherShipColor, + aetherShipColorIndex, +} from "./aetherAssaultVisuals"; + +describe("Aether Assault ship colors", () => { + it("assigns a deterministic mixed palette across a formation", () => { + const first = Array.from({ length: 6 }, (_, index) => aetherShipColor(1234, 1, index, "standard")); + const replay = Array.from({ length: 6 }, (_, index) => aetherShipColor(1234, 1, index, "standard")); + + expect(replay).toEqual(first); + expect(new Set(first).size).toBe(AETHER_STANDARD_SHIP_COLORS.length); + }); + + it("changes palette ordering between waves and keeps armored variants distinct", () => { + const waveOneIndex = aetherShipColorIndex(42, 1, 0); + const waveTwoIndex = aetherShipColorIndex(42, 2, 0); + + expect(waveTwoIndex).not.toBe(waveOneIndex); + expect(aetherShipColor(42, 1, 0, "standard")).toBe(AETHER_STANDARD_SHIP_COLORS[waveOneIndex]); + expect(aetherShipColor(42, 1, 0, "armored")).toBe(AETHER_ARMORED_SHIP_COLORS[waveOneIndex]); + }); +}); diff --git a/src/components/aetherAssaultVisuals.ts b/src/components/aetherAssaultVisuals.ts new file mode 100644 index 0000000..89c6f8f --- /dev/null +++ b/src/components/aetherAssaultVisuals.ts @@ -0,0 +1,32 @@ +import type { AetherShipKind } from "../game/aetherAssault"; + +export const AETHER_STANDARD_SHIP_COLORS = [ + "#20c8e8", + "#8a5cff", + "#f044b5", + "#31cf74", + "#f06b3c", + "#d6b91c", +] as const; + +export const AETHER_ARMORED_SHIP_COLORS = [ + "#9ef5ff", + "#d0b8ff", + "#ff9bdc", + "#9af0b8", + "#ffb088", + "#ffe976", +] as const; + +export function aetherShipColorIndex(seed: number, wave: number, shipIndex: number) { + const normalizedSeed = Math.floor(Number.isFinite(seed) ? seed : 0) >>> 0; + const mixedSeed = (normalizedSeed ^ (normalizedSeed >>> 16)) >>> 0; + const normalizedWave = Math.max(1, Math.floor(Number.isFinite(wave) ? wave : 1)); + const normalizedIndex = Math.max(0, Math.floor(Number.isFinite(shipIndex) ? shipIndex : 0)); + return (mixedSeed + (normalizedWave - 1) * 3 + normalizedIndex * 5) % AETHER_STANDARD_SHIP_COLORS.length; +} + +export function aetherShipColor(seed: number, wave: number, shipIndex: number, kind: AetherShipKind) { + const palette = kind === "armored" ? AETHER_ARMORED_SHIP_COLORS : AETHER_STANDARD_SHIP_COLORS; + return palette[aetherShipColorIndex(seed, wave, shipIndex)]; +} diff --git a/src/components/boss/bossDeathVisuals.test.ts b/src/components/boss/bossDeathVisuals.test.ts index 219b8e3..6fe4a20 100644 --- a/src/components/boss/bossDeathVisuals.test.ts +++ b/src/components/boss/bossDeathVisuals.test.ts @@ -6,6 +6,8 @@ import { BOSS_DEATH_HOLD_SECONDS, advanceBossIndicatorOpacity, bossCanTrackTarget, + bossDeathDespawnSeconds, + bossDeathHoldSeconds, bossDeathOpacity, } from "./bossDeathVisuals"; @@ -28,4 +30,11 @@ describe("boss death visuals", () => { expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS / 2)).toBeCloseTo(0.5); expect(bossDeathOpacity(BOSS_DEATH_DESPAWN_SECONDS)).toBe(0); }); + + it("keeps Gravehorn visible through its full authored fall", () => { + const hold = bossDeathHoldSeconds("gravehorn-triceratops"); + expect(hold).toBeGreaterThan(5.73); + expect(bossDeathOpacity(5.73, "gravehorn-triceratops")).toBe(1); + expect(bossDeathOpacity(bossDeathDespawnSeconds("gravehorn-triceratops"), "gravehorn-triceratops")).toBe(0); + }); }); diff --git a/src/components/boss/bossDeathVisuals.ts b/src/components/boss/bossDeathVisuals.ts index ccd8549..e1127c6 100644 --- a/src/components/boss/bossDeathVisuals.ts +++ b/src/components/boss/bossDeathVisuals.ts @@ -2,6 +2,8 @@ export { BOSS_DEATH_DESPAWN_SECONDS, BOSS_DEATH_FADE_SECONDS, BOSS_DEATH_HOLD_SECONDS, + bossDeathDespawnSeconds, + bossDeathHoldSeconds, bossDeathOpacity, } from "../../game/bossDeath"; diff --git a/src/components/rpgRoguelike/RpgRoomPortals.tsx b/src/components/rpgRoguelike/RpgRoomPortals.tsx new file mode 100644 index 0000000..402c1cf --- /dev/null +++ b/src/components/rpgRoguelike/RpgRoomPortals.tsx @@ -0,0 +1,171 @@ +import { useFrame } from "@react-three/fiber"; +import { useRef } from "react"; +import * as THREE from "three"; +import { ARENA_CENTER, ARENA_WALL_RADIUS } from "../../game/arena"; +import { BOSS_ARENA_PORTAL_HALF_WIDTH } from "../../game/rpgRoguelike/playSpace"; + +export interface RpgRoomPortalsProps { + entryOpen: boolean; + exitOpen: boolean; + accent: string; + wallColor: string; +} + +interface PortalDoorwayProps { + open: boolean; + accent: string; + wallColor: string; + position: readonly [number, number, number]; + rotationY: number; + hideNearCamera?: boolean; +} + +const DOOR_HEIGHT = 3.25; +const DOOR_DEPTH = 0.24; +const FRAME_DEPTH = 0.72; +const FRAME_POST_WIDTH = 0.48; +const OPEN_ANGLE = Math.PI * 0.56; +const OPEN_DAMPING = 8; +const CAMERA_HIDE_RADIUS = 3.6; + +function DoorLeaf({ side, accent, wallColor }: { + side: "left" | "right"; + accent: string; + wallColor: string; +}) { + const leafWidth = BOSS_ARENA_PORTAL_HALF_WIDTH; + const centerX = side === "left" ? leafWidth * 0.5 : -leafWidth * 0.5; + return ( + + + + + + {[-0.9, 0, 0.9].map((y) => ( + + + + + ))} + + ); +} + +function PortalDoorway({ + open, + accent, + wallColor, + position, + rotationY, + hideNearCamera = false, +}: PortalDoorwayProps) { + const root = useRef(null); + const leftHinge = useRef(null); + const rightHinge = useRef(null); + + useFrame(({ camera }, delta) => { + if (root.current) { + const distanceToCamera = Math.hypot( + camera.position.x - position[0], + camera.position.z - position[2], + ); + root.current.visible = !hideNearCamera || distanceToCamera >= CAMERA_HIDE_RADIUS; + } + if (!leftHinge.current || !rightHinge.current) return; + const reducedMotion = typeof document !== "undefined" + && document.documentElement.classList.contains("force-reduced-motion"); + const leftTarget = open ? OPEN_ANGLE : 0; + const rightTarget = -leftTarget; + if (reducedMotion) { + leftHinge.current.rotation.y = leftTarget; + rightHinge.current.rotation.y = rightTarget; + return; + } + leftHinge.current.rotation.y = THREE.MathUtils.damp( + leftHinge.current.rotation.y, + leftTarget, + OPEN_DAMPING, + delta, + ); + rightHinge.current.rotation.y = THREE.MathUtils.damp( + rightHinge.current.rotation.y, + rightTarget, + OPEN_DAMPING, + delta, + ); + }); + + const postX = BOSS_ARENA_PORTAL_HALF_WIDTH + FRAME_POST_WIDTH * 0.5; + return ( + + {([-1, 1] as const).map((side) => ( + + + + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** + * Projects the linear RPG route through the boss arena: entry is the south + * portal (+Z), while the next-room exit is the north portal (-Z). + */ +export function RpgRoomPortals({ entryOpen, exitOpen, accent, wallColor }: RpgRoomPortalsProps) { + return ( + + + + + ); +} diff --git a/src/components/rpgRoguelike/RpgRunOverlay.tsx b/src/components/rpgRoguelike/RpgRunOverlay.tsx new file mode 100644 index 0000000..2c506b0 --- /dev/null +++ b/src/components/rpgRoguelike/RpgRunOverlay.tsx @@ -0,0 +1,431 @@ +import { useEffect, useRef, type KeyboardEvent } from "react"; +import { HEALER_ABILITIES } from "../../game/healers"; +import { + ABILITY_LOADOUT_SLOTS, + MAX_ACTIVE_ROSTER, + MAX_EQUIPPED_SPELLS, + PARTY_DRAFT_WAVE_COUNT, + PARTY_RECRUITS_PER_WAVE, + SPELL_DRAFT_WAVE_COUNT, + SPELL_PICKS_PER_WAVE, + rpgFocusId, +} from "../../game/rpgRoguelike"; +import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs"; +import { + ChallengeObjective, + createUiContext, + currentBoss, + FocusButton, + GearCard, + PartyCard, + rewardSummary, + RoutePips, + RunHeader, + runAccentStyle, + SpellCard, + type RpgRunUiContext, + type RpgRunUiProps, +} from "./RpgRunUiShared"; +import "./rpgRoguelike.css"; + +function DraftFooter({ context, focusId, action, disabled, label, hint }: { + readonly context: RpgRunUiContext; + readonly focusId: string; + readonly action: Parameters[0]; + readonly disabled: boolean; + readonly label: string; + readonly hint: string; +}) { + return ( +
+ {hint} + + {label}{DEFAULT_CONTROLLER_GLYPHS.confirm} + +
+ ); +} + +function PartyDraft({ context }: { context: RpgRunUiContext }) { + const { run } = context; + const draft = run.partyDraft!; + const canRemove = draft.waveIndex > 0; + const canRecruit = run.roster.length < MAX_ACTIVE_ROSTER + && draft.recruitedThisWaveIds.length < PARTY_RECRUITS_PER_WAVE; + const finalWave = draft.waveIndex === PARTY_DRAFT_WAVE_COUNT - 1; + const canContinue = finalWave + ? run.roster.length === MAX_ACTIVE_ROSTER && run.roster.some((member) => member.role === "Tank") + : run.roster.length + (PARTY_DRAFT_WAVE_COUNT - draft.waveIndex - 1) * PARTY_RECRUITS_PER_WAVE >= MAX_ACTIVE_ROSTER; + return ( +
+
+
Draft {draft.waveIndex + 1} / {PARTY_DRAFT_WAVE_COUNT}

Assemble your party

+

Recruit up to {PARTY_RECRUITS_PER_WAVE} this wave. Four companions enter each room.

+ {draft.recruitedThisWaveIds.length}/{PARTY_RECRUITS_PER_WAVE} picked +
+
+ {draft.offers.map((candidate) => { + const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId); + const disabled = !recruited && !canRecruit; + const action = recruited + ? { type: "party-remove", memberId: candidate.candidateId } as const + : { type: "party-recruit", candidateId: candidate.candidateId } as const; + return ( + Party full : ( + {recruited ? canRemove ? "Remove" : "Locked" : "Recruit"} + )} + /> + ); + })} +
+
+ Party {run.roster.length}/{MAX_ACTIVE_ROSTER} + {run.roster.map((member) => ( + {member.name}{member.className}× + ))} + {Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => Open)} +
+ +
+ ); +} + +function SpellDraft({ context }: { context: RpgRunUiContext }) { + const { run } = context; + const draft = run.spellDraft!; + const canRemove = draft.waveIndex > 0; + const canPick = run.selectedSpellIds.length < MAX_EQUIPPED_SPELLS + && draft.pickedThisWaveIds.length < SPELL_PICKS_PER_WAVE; + const finalWave = draft.waveIndex === SPELL_DRAFT_WAVE_COUNT - 1; + const canContinue = !finalWave || run.selectedSpellIds.length > 0; + return ( +
+
+
Draft {draft.waveIndex + 1} / {SPELL_DRAFT_WAVE_COUNT}

Build your spellbook

+

Learn up to {SPELL_PICKS_PER_WAVE} spells. Abilities from every enabled healer class can mix.

+ {draft.pickedThisWaveIds.length}/{SPELL_PICKS_PER_WAVE} picked +
+
+ {draft.offers.map((spellId) => { + const selected = run.selectedSpellIds.includes(spellId); + const disabled = !selected && !canPick; + const action = selected + ? { type: "spell-remove", spellId } as const + : { type: "spell-pick", spellId } as const; + return ( + Wave cap reached : ( + {selected ? canRemove ? "Remove" : "Locked" : "Learn"} + )} + /> + ); + })} +
+
+ Spellbook {run.selectedSpellIds.length}/{MAX_EQUIPPED_SPELLS} + {ABILITY_LOADOUT_SLOTS.map((slotId, index) => ({ slotId, index, spellId: run.abilityLoadout[slotId] })) + .filter((entry): entry is { slotId: typeof ABILITY_LOADOUT_SLOTS[number]; index: number; spellId: NonNullable } => Boolean(entry.spellId)) + .map(({ spellId, index }) => { + const spell = HEALER_ABILITIES[spellId]; + return ( + {index + 1}{spell.icon}{spell.shortName}× + ); + })} +
+ 0 ? "Remove drafted spells to make room for new magic." : "Each learned spell fills the next ability slot."} + /> +
+ ); +} + +function Briefing({ context, kind }: { context: RpgRunUiContext; kind: "challenge" | "boss" }) { + const { run } = context; + const boss = currentBoss(run); + if (kind === "challenge") { + return ( +
+ + Hallway challenge +

Prove the party before the next door

+ +

Failure does not end the run. Success adds gold and improves the next chest.

+ + Start Challenge {DEFAULT_CONTROLLER_GLYPHS.confirm} + +
+ ); + } + return ( +
+ {boss?.icon ?? "♛"} + {run.bossIndex === run.bossRoute.length - 1 ? "Final encounter" : "Boss chamber"} +

{boss?.name ?? "Unknown Guardian"}

+

{boss?.title}

+

{boss?.briefing}

+ {run.lastChallengeResult && ( +
+ {run.lastChallengeResult.succeeded ? `Challenge cleared · +${run.lastChallengeResult.objective.rewardCurrency} gold · upgraded chest` : "Challenge missed · standard chest remains"} +
+ )} + + Enter Chamber {DEFAULT_CONTROLLER_GLYPHS.confirm} + +
+ ); +} + +function BossCleared({ context }: { context: RpgRunUiContext }) { + const boss = currentBoss(context.run); + return ( +
+
North gate open
+ Boss defeated +

{boss?.name} has fallen

+

Walk through the far door to claim the chest. Party health carries forward.

+ + Open Chest {DEFAULT_CONTROLLER_GLYPHS.confirm} + +
+ ); +} + +function Rewards({ context }: { context: RpgRunUiContext }) { + const chest = context.run.pendingReward; + if (!chest) return null; + return ( +
+
+
Chest quality +{chest.quality}

Choose one reward

+

Rewards improve this run only. Gear auto-equips when stronger.

+
+
+ {chest.choices.map((choice) => { + const summary = rewardSummary(choice); + return ( + + {summary.icon}{summary.eyebrow}

{choice.label}

{summary.detail}

Claim +
+ ); + })} +
+
← / → Choose {DEFAULT_CONTROLLER_GLYPHS.confirm} Claim
+
+ ); +} + +function Shop({ context }: { context: RpgRunUiContext }) { + const { run } = context; + const shop = run.shop; + if (!shop) return null; + const needsRest = run.playerHp > 0 && run.playerHp < 100 + || run.roster.some((member) => member.hp > 0 && member.hp < member.stats.maxHp); + const dead = run.roster.filter((member) => member.hp <= 0); + return ( +
+
+
Safe intermission

Wayfarer's Exchange

+

Run gear auto-equips when stronger. Sell displaced gear from the bag.

+
+
+

Buy gear

+ {shop.offers.map((offer) => { + const disabled = offer.sold || run.currency < offer.price; + return ( + {offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"} + } /> + ); + })} +
+

Bag & services

+ {run.bag.length ? run.bag.map((item) => ( + + {item.slotId} · +{item.enhancement}{item.name}Sell ◆ {item.sellPrice} + + )) :

Bag empty. Replaced gear appears here.

} + + Living membersRest party◆ {shop.restCost} + + {dead.map((member) => ( + + Return at 50% HPRevive {member.name}◆ {shop.reviveCost} + + ))} +
+
+ +
+ ); +} + +function Terminal({ context }: { context: RpgRunUiContext }) { + const victory = context.run.phase === "victory"; + return ( +
+ {victory ? "♛" : "◇"} + {victory ? "Expedition complete" : "Run ended"} +

{victory ? "The gauntlet is conquered" : "The party has fallen"}

+

{context.run.bossesDefeated} bosses defeated · {context.run.selectedSpellIds.length} spells drafted · ◆ {context.run.currency} remaining

+
+ New Run + Mode Select +
+
+ ); +} + +function CompactCombatHud({ context }: { context: RpgRunUiContext }) { + const { run } = context; + const boss = currentBoss(run); + const cleared = run.phase === "boss-cleared"; + return ( +
+
+ {run.phase === "challenge-active" ? "Hallway challenge" : cleared ? "Room cleared" : "RPG Roguelike"} + {run.phase === "challenge-active" ? run.currentChallenge?.objective.name : cleared ? "North gate open · cross it or confirm" : boss?.name} +
+ {run.phase === "challenge-active" ? : } +
+ ); +} + +/** Main-display overlay. Draft/reward/shop phases gate play; live phases render compact HUD only. */ +export function RpgRunOverlay(props: RpgRunUiProps) { + const context = createUiContext(props); + const live = props.run.phase === "challenge-active" || props.run.phase === "boss-combat" || props.run.phase === "boss-cleared"; + const overlayRef = useRef(null); + const previousFocusRef = useRef(null); + + useEffect(() => { + if (live) return; + previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + return () => { + const previous = previousFocusRef.current; + if (previous?.isConnected) previous.focus({ preventScroll: true }); + previousFocusRef.current = null; + }; + }, [live]); + + useEffect(() => { + if (live) return; + const frame = requestAnimationFrame(() => { + const selected = overlayRef.current?.querySelector("[data-rpg-focus].is-controller-selected:not(:disabled)"); + selected?.focus({ preventScroll: true }); + }); + return () => cancelAnimationFrame(frame); + }, [context.activeFocusId, live, props.run.phase]); + + const trapModalTab = (event: KeyboardEvent) => { + if (live || event.key !== "Tab") return; + const buttons = [...(overlayRef.current?.querySelectorAll("button:not(:disabled)") ?? [])]; + if (!buttons.length) { + event.preventDefault(); + overlayRef.current?.focus({ preventScroll: true }); + return; + } + const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement); + const nextIndex = event.shiftKey + ? currentIndex <= 0 ? buttons.length - 1 : currentIndex - 1 + : currentIndex < 0 || currentIndex === buttons.length - 1 ? 0 : currentIndex + 1; + event.preventDefault(); + buttons[nextIndex].focus({ preventScroll: true }); + }; + return ( +
+ {live ? : ( + <> + + + {props.run.phase === "party-draft" && } + {props.run.phase === "spell-draft" && } + {props.run.phase === "challenge-briefing" && } + {props.run.phase === "boss-briefing" && } + {props.run.phase === "reward" && } + {props.run.phase === "shop" && } + {(props.run.phase === "victory" || props.run.phase === "defeat") && } + + )} +
+ ); +} diff --git a/src/components/rpgRoguelike/RpgRunTacticalPanel.tsx b/src/components/rpgRoguelike/RpgRunTacticalPanel.tsx new file mode 100644 index 0000000..b5bbaec --- /dev/null +++ b/src/components/rpgRoguelike/RpgRunTacticalPanel.tsx @@ -0,0 +1,410 @@ +import type { CSSProperties } from "react"; +import { HEALER_ABILITIES } from "../../game/healers"; +import { ABILITY_CONTROLLER_BINDINGS } from "../../game/controllerBindings"; +import { + ABILITY_LOADOUT_SLOTS, + MAX_ACTIVE_ROSTER, + MAX_EQUIPPED_SPELLS, + PARTY_DRAFT_WAVE_COUNT, + PARTY_RECRUITS_PER_WAVE, + SPELL_DRAFT_WAVE_COUNT, + SPELL_PICKS_PER_WAVE, + rpgFocusId, +} from "../../game/rpgRoguelike"; +import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs"; +import { + ChallengeObjective, + createUiContext, + currentBoss, + FocusButton, + GearCard, + PartyCard, + rewardSummary, + RoutePips, + RunHeader, + runAccentStyle, + SpellCard, + type RpgRunUiContext, + type RpgLiveCombatUi, + type RpgRunUiProps, +} from "./RpgRunUiShared"; +import "./rpgRoguelike.css"; + +function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) { + const { run } = context; + return ( +
+

Party

{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing
+
+ {run.roster.map((member) => ( + Remove + ) : undefined} /> + ))} + {!run.roster.length &&

Drafted companions appear here.

} +
+
+ ); +} + +function TacticalSpellbook({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) { + const { run } = context; + return ( +
+

Spellbook

{run.selectedSpellIds.length}/{MAX_EQUIPPED_SPELLS} slots
+
+ {ABILITY_LOADOUT_SLOTS.map((slotId, index) => ({ slotId, index, spellId: run.abilityLoadout[slotId] })) + .filter((entry): entry is { slotId: typeof ABILITY_LOADOUT_SLOTS[number]; index: number; spellId: NonNullable } => Boolean(entry.spellId)) + .map(({ spellId, index }) => { + const spell = HEALER_ABILITIES[spellId]; + return interactive ? ( + {index + 1}{spell.icon}{spell.shortName}Rank {run.spellRanks[spellId] ?? 0}× + ) : ( +
+ {index + 1}{spell.icon}{spell.shortName}Rank {run.spellRanks[spellId] ?? 0} +
+ ); + })} + {!run.selectedSpellIds.length &&

Drafted spells appear here.

} +
+
+ ); +} + +function TacticalContinue({ context, focusId, action, label, disabled = false }: { + readonly context: RpgRunUiContext; + readonly focusId: string; + readonly action: Parameters[0]; + readonly label: string; + readonly disabled?: boolean; +}) { + return ( + + {label}{DEFAULT_CONTROLLER_GLYPHS.confirm} + + ); +} + +function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) { + const { run } = context; + const draft = run.partyDraft!; + const canRemove = draft.waveIndex > 0; + const canRecruit = run.roster.length < MAX_ACTIVE_ROSTER && draft.recruitedThisWaveIds.length < PARTY_RECRUITS_PER_WAVE; + const finalWave = draft.waveIndex === PARTY_DRAFT_WAVE_COUNT - 1; + const canContinue = finalWave + ? run.roster.length === MAX_ACTIVE_ROSTER && run.roster.some((member) => member.role === "Tank") + : run.roster.length + (PARTY_DRAFT_WAVE_COUNT - draft.waveIndex - 1) * PARTY_RECRUITS_PER_WAVE >= MAX_ACTIVE_ROSTER; + return ( + <> +
+

Offers · wave {draft.waveIndex + 1}/{PARTY_DRAFT_WAVE_COUNT}

{draft.recruitedThisWaveIds.length}/{PARTY_RECRUITS_PER_WAVE} picked
+
+ {draft.offers.map((candidate) => { + const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId); + const disabled = (recruited && !canRemove) || (!recruited && !canRecruit); + return ( + + {candidate.role === "Tank" ? "⬡" : "⚔"} + {candidate.rarity} · {candidate.role}{candidate.name}{candidate.className} + HP {candidate.stats.maxHp}ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)} + {recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"} + + ); + })} +
+
+ + + + ); +} + +function TacticalSpellDraft({ context }: { context: RpgRunUiContext }) { + const { run } = context; + const draft = run.spellDraft!; + const canRemove = draft.waveIndex > 0; + const canPick = run.selectedSpellIds.length < MAX_EQUIPPED_SPELLS && draft.pickedThisWaveIds.length < SPELL_PICKS_PER_WAVE; + const finalWave = draft.waveIndex === SPELL_DRAFT_WAVE_COUNT - 1; + return ( + <> +
+

Spells · wave {draft.waveIndex + 1}/{SPELL_DRAFT_WAVE_COUNT}

{draft.pickedThisWaveIds.length}/{SPELL_PICKS_PER_WAVE} picked
+
+ {draft.offers.map((spellId) => { + const spell = HEALER_ABILITIES[spellId]; + const selected = run.selectedSpellIds.includes(spellId); + const disabled = (selected && !canRemove) || (!selected && !canPick); + return ( + + {spell.icon}{spell.targeting} · {spell.mana} mana{spell.name}{spell.description}{selected ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Learn"} + + ); + })} +
+
+ + + + ); +} + +function TacticalBriefing({ context }: { context: RpgRunUiContext }) { + const { run } = context; + const challenge = run.phase === "challenge-briefing"; + const boss = currentBoss(run); + return ( + <> +
+ {challenge ? "◇" : boss?.icon ?? "♛"} + {challenge ? "Hallway challenge" : "Boss chamber"} +

{challenge ? run.currentChallenge?.objective.name : boss?.name}

+

{challenge ? "Success improves the next chest. Failure still opens the boss door." : boss?.briefing}

+
+ {challenge && } + + + + ); +} + +function TacticalLiveParty({ live }: { live: RpgLiveCombatUi }) { + return ( +
+

Live party

{live.party.filter((member) => member.hp > 0).length}/{live.party.length} standing
+
+ {live.party.map((member) => { + const health = Math.max(0, Math.min(100, member.hp / Math.max(1, member.maxHp) * 100)); + const selected = member.id === live.selectedMemberId; + return ( + + ); + })} +
+
+ ); +} + +function TacticalLiveSpellbook({ context, live }: { context: RpgRunUiContext; live: RpgLiveCombatUi }) { + const castProgress = live.activeCast + ? Math.max(0, Math.min(1, (live.time - live.activeCast.startedAt) / Math.max(0.001, live.activeCast.completesAt - live.activeCast.startedAt))) + : 0; + return ( +
+

Spell arsenal

{Math.ceil(live.mana)} / {live.maxMana} mana
+
+
+ Verdancy{live.spellResources.verdancy}/5 + Tidal Surge{live.spellResources.tidalSurge}/2 + Conviction{live.spellResources.conviction}/3 + Chronoshards{live.spellResources.chronoshards}/3 +
+ {live.activeCast && ( +
+ Casting {HEALER_ABILITIES[live.activeCast.abilityId].name} +
+ )} +
+ {ABILITY_LOADOUT_SLOTS.map((slotId, index) => { + const spellId = context.run.abilityLoadout[slotId]; + if (!spellId) return
{index + 1}Empty
; + const spell = HEALER_ABILITIES[spellId]; + const cooldown = Math.max(0, live.cooldowns[slotId] - live.time, live.globalCooldownUntil - live.time); + const selected = live.party.find((member) => member.id === live.selectedMemberId); + const disabled = !live.onCastAbility || live.activeCast !== null || cooldown > 0 || live.mana < spell.mana + || (spell.targeting === "ally" && (!selected || selected.hp <= 0)) + || (spell.pulseKind === "cleanse" && !selected?.debuffs.length); + return ( + + ); + })} +
+
+ ); +} + +function TacticalLive({ context }: { context: RpgRunUiContext }) { + const { run } = context; + const boss = currentBoss(run); + const live = context.liveCombat; + return ( + <> + {run.phase === "challenge-active" ? : ( +
+ {boss?.icon ?? "♛"}Boss battle

{boss?.name}

{boss?.summary}

+
+ )} + {live ? : } + {live ? : } +

D-pad Target Face buttons Cast

+ + ); +} + +function TacticalCleared({ context }: { context: RpgRunUiContext }) { + return ( + <> +
+ North gate open

Room cleared

Cross the far doorway. Health and fallen companions carry forward.

+
+ + + + ); +} + +function TacticalRewards({ context }: { context: RpgRunUiContext }) { + const chest = context.run.pendingReward; + if (!chest) return null; + return ( +
+

Choose one reward

Chest +{chest.quality}
+
+ {chest.choices.map((choice) => { + const summary = rewardSummary(choice); + return ( + + {summary.icon}{summary.eyebrow}{choice.label}

{summary.detail}

Claim +
+ ); + })} +
+
+ ); +} + +function TacticalShop({ context }: { context: RpgRunUiContext }) { + const { run } = context; + const shop = run.shop; + if (!shop) return null; + const needsRest = run.playerHp > 0 && run.playerHp < 100 + || run.roster.some((member) => member.hp > 0 && member.hp < member.stats.maxHp); + return ( + <> +
+

Buy gear

◆ {run.currency}
+
+ {shop.offers.map((offer) => ( + + {offer.sold ? "Sold" : "Buy"} + + } /> + ))} +
+
+
+

Bag & services

{run.bag.length} stored
+
+ {run.bag.map((item) => ( + + Sell gear{item.name}◆ {item.sellPrice} + + ))} + + Restore living partyRest◆ {shop.restCost} + + {run.roster.filter((member) => member.hp <= 0).map((member) => ( + + Return at 50% HPRevive {member.name}◆ {shop.reviveCost} + + ))} +
+
+ + + ); +} + +function TacticalTerminal({ context }: { context: RpgRunUiContext }) { + const victory = context.run.phase === "victory"; + return ( +
+ {victory ? "♛" : "◇"}{victory ? "Run complete" : "Run ended"}

{victory ? "Victory" : "Party fallen"}

+

{context.run.bossesDefeated} bosses · {context.run.selectedSpellIds.length} spells · ◆ {context.run.currency}

+ New run{DEFAULT_CONTROLLER_GLYPHS.confirm} + Mode select +
+ ); +} + +/** Secondary-display tactical projection. Same semantic focus IDs as top overlay. */ +export function RpgRunTacticalPanel(props: RpgRunUiProps) { + const context = createUiContext(props); + const run = props.run; + return ( + + ); +} diff --git a/src/components/rpgRoguelike/RpgRunUiShared.tsx b/src/components/rpgRoguelike/RpgRunUiShared.tsx new file mode 100644 index 0000000..11ed8d8 --- /dev/null +++ b/src/components/rpgRoguelike/RpgRunUiShared.tsx @@ -0,0 +1,325 @@ +import { useEffect, useRef, type CSSProperties, type ReactNode } from "react"; +import { BOSS_DEFINITIONS } from "../../game/bossCatalog"; +import { HEALER_ABILITIES } from "../../game/healers"; +import type { AbilitySlotId, ActiveCast, HealerAbilityId, MemberId, PartyMember } from "../../game/types"; +import type { + PartyDraftCandidate, + PartyRosterMember, + RewardChoice, + RpgRoguelikeAction, + RpgRoguelikeRunState, + RunGearItem, +} from "../../game/rpgRoguelike"; +import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT } from "../../game/rpgRoguelike"; +import type { RpgUiCommand } from "../../game/rpgRoguelike"; +import { normalizeRpgFocusId } from "../../game/rpgRoguelike"; + +export interface RpgRunUiProps { + readonly run: RpgRoguelikeRunState; + readonly focusedId?: string | null; + readonly onFocusChange?: (focusId: string) => void; + readonly onAction: (action: RpgRoguelikeAction) => void; + readonly onRestartRun?: () => void; + readonly onExitRun?: () => void; + readonly className?: string; + readonly liveCombat?: RpgLiveCombatUi; +} + +export interface RpgLiveCombatUi { + readonly party: readonly PartyMember[]; + readonly selectedMemberId: MemberId; + readonly mana: number; + readonly maxMana: number; + readonly cooldowns: Readonly>; + readonly globalCooldownUntil: number; + readonly time: number; + readonly activeCast: ActiveCast | null; + readonly spellResources: { + readonly verdancy: number; + readonly tidalSurge: number; + readonly conviction: number; + readonly chronoshards: number; + }; + readonly onSelectMember?: (memberId: MemberId) => void; + readonly onCastAbility?: (abilitySlotId: AbilitySlotId) => void; +} + +export interface RpgRunUiContext extends RpgRunUiProps { + readonly activeFocusId: string | null; +} + +export function createUiContext(props: RpgRunUiProps): RpgRunUiContext { + return { ...props, activeFocusId: normalizeRpgFocusId(props.run, props.focusedId) }; +} + +export function runAccentStyle(accent: string): CSSProperties { + return { "--rpg-accent": accent } as CSSProperties; +} + +export function executeUiCommand(context: RpgRunUiContext, command: RpgUiCommand): void { + if (command.type === "run-action") context.onAction(command.action); + else if (command.type === "restart-run") context.onRestartRun?.(); + else context.onExitRun?.(); +} + +interface FocusButtonProps { + readonly context: RpgRunUiContext; + readonly focusId: string; + readonly command: RpgUiCommand; + readonly className?: string; + readonly disabled?: boolean; + readonly pressed?: boolean; + readonly style?: CSSProperties; + readonly children: ReactNode; + readonly label?: string; +} + +export function FocusButton({ + context, + focusId, + command, + className = "", + disabled = false, + pressed, + style, + children, + label, +}: FocusButtonProps) { + const selected = context.activeFocusId === focusId; + const buttonRef = useRef(null); + useEffect(() => { + const button = buttonRef.current; + if (!selected || !button) return; + let parent = button.parentElement; + while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) { + const childRect = button.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + if (parent.scrollHeight > parent.clientHeight) { + if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top; + else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom; + } + if (parent.scrollWidth > parent.clientWidth) { + if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left; + else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right; + } + parent = parent.parentElement; + } + }, [selected]); + return ( + + ); +} + +export function phaseLabel(run: RpgRoguelikeRunState): string { + switch (run.phase) { + case "party-draft": return "Party Draft"; + case "spell-draft": return "Spell Draft"; + case "challenge-briefing": return "Hallway Challenge"; + case "challenge-active": return "Challenge Active"; + case "boss-briefing": return run.bossIndex === TOTAL_BOSS_COUNT - 1 ? "Final Boss" : "Boss Door"; + case "boss-combat": return run.bossIndex === TOTAL_BOSS_COUNT - 1 ? "Final Battle" : "Boss Battle"; + case "boss-cleared": return "Room Cleared"; + case "reward": return "Reward Chest"; + case "shop": return `Act ${run.shop?.act ?? 1} Intermission`; + case "victory": return "Run Complete"; + case "defeat": return "Party Fallen"; + } +} + +export function routeLabel(run: RpgRoguelikeRunState): string { + if (run.bossIndex >= TOTAL_BOSS_COUNT - 1) return "Finale"; + return `Act ${Math.floor(run.bossIndex / BOSSES_PER_ACT) + 1} · Room ${(run.bossIndex % BOSSES_PER_ACT) + 1}`; +} + +export function currentBoss(run: RpgRoguelikeRunState) { + const bossId = run.bossRoute[run.bossIndex]; + return bossId ? BOSS_DEFINITIONS[bossId] : null; +} + +export function RunHeader({ run, compact = false }: { run: RpgRoguelikeRunState; compact?: boolean }) { + return ( +
+
+ {routeLabel(run)} + {phaseLabel(run)} +
+
+ {run.currency} + {Math.ceil(run.playerHp)} + {run.bossesDefeated}/{TOTAL_BOSS_COUNT} +
+
+ ); +} + +export function RoutePips({ run }: { run: RpgRoguelikeRunState }) { + return ( +
+ {run.bossRoute.map((bossId, index) => ( + + ))} +
+ ); +} + +function titleCase(value: string): string { + return value.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); +} + +export function PartyCard({ + member, + candidate, + selected = false, + compact = false, + action, +}: { + readonly member?: PartyRosterMember; + readonly candidate?: PartyDraftCandidate; + readonly selected?: boolean; + readonly compact?: boolean; + readonly action?: ReactNode; +}) { + const entry = member ?? candidate; + if (!entry) return null; + const hp = member?.hp ?? entry.stats.maxHp; + const hpPercent = Math.max(0, Math.min(100, (hp / entry.stats.maxHp) * 100)); + return ( +
+
{entry.rarity}{entry.role}
+

{entry.name}

+

{entry.className}

+ {!compact && ( +
+ HP{entry.stats.maxHp} + ST{entry.stats.singleTarget.toFixed(2)} + AOE{entry.stats.areaDamage.toFixed(2)} + DEF{entry.stats.defense.toFixed(2)} +
+ )} + {member && ( +
+ + {hp <= 0 ? "Fallen" : `${Math.ceil(hp)} / ${entry.stats.maxHp}`} +
+ )} + {!compact && entry.traitIds.length > 0 &&
{entry.traitIds.map(titleCase).join(" · ")}
} + {action} +
+ ); +} + +export function SpellCard({ + spellId, + rank = 0, + selected = false, + compact = false, + action, +}: { + readonly spellId: HealerAbilityId; + readonly rank?: number; + readonly selected?: boolean; + readonly compact?: boolean; + readonly action?: ReactNode; +}) { + const spell = HEALER_ABILITIES[spellId]; + return ( +
+ {spell.icon} +
+ {spell.targeting} · {spell.mana} mana{rank > 0 ? ` · Rank ${rank}` : ""} +

{spell.name}

+ {!compact &&

{spell.description}

} +
+ {action} +
+ ); +} + +export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): string { + if (item.ownerId === "player") return "Healer"; + return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion"; +} + +export function GearCard({ run, item, price, action }: { + readonly run: RpgRoguelikeRunState; + readonly item: RunGearItem; + readonly price?: number; + readonly action?: ReactNode; +}) { + return ( +
+ {item.slotId === "weapon" ? "⚔" : item.slotId === "armor" ? "⬡" : "◇"} +
+ {gearOwnerName(run, item)} · {item.slotId} +

{item.name}

+

+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}

+
+ {action} +
+ ); +} + +export function rewardSummary(choice: RewardChoice): { icon: string; eyebrow: string; detail: string; accent: string } { + if (choice.kind === "spell-rank") { + const spell = HEALER_ABILITIES[choice.spellId]; + return { icon: spell.icon, eyebrow: "Spell upgrade", detail: `${spell.name} reaches rank ${choice.nextRank}.`, accent: spell.color }; + } + if (choice.kind === "member-rarity") { + return { icon: "♟", eyebrow: "Party upgrade", detail: `Promote companion to ${choice.nextRarity}.`, accent: "#b47aec" }; + } + if (choice.kind === "run-gear") { + return { icon: "◇", eyebrow: `Gear +${choice.item.enhancement}`, detail: `Equip ${choice.item.name}; displaced gear moves to bag.`, accent: "#65aef2" }; + } + return { icon: "◆", eyebrow: "Run currency", detail: `Gain ${choice.amount} gold for this run.`, accent: "#efc858" }; +} + +export function ChallengeObjective({ run, compact = false }: { run: RpgRoguelikeRunState; compact?: boolean }) { + const challenge = run.currentChallenge; + const previous = run.lastChallengeResult; + if (!challenge && !previous) return null; + const objective = challenge?.objective ?? previous!.objective; + const metrics = challenge?.metrics ?? previous!.metrics; + const current = metrics[objective.metric]; + const progress = Math.max(0, Math.min(100, (current / objective.target) * 100)); + const copy = objective.challengeId === "blockbreaker" + ? `Break ${objective.target} bricks before the board falls.` + : objective.challengeId === "hockey" + ? `Defeat ${objective.target} ${objective.target === 1 ? "boss" : "bosses"} without conceding.` + : `Defeat ${objective.target} enemies before the assault ends.`; + return ( +
+
Repeat tier {objective.repeatIndex + 1}{objective.name}
+ {!compact &&

{copy}

} +
+ + {current} / {objective.target} +
+ {!compact &&
Success: ◆ {objective.rewardCurrency} · chest quality +{objective.chestQualityBonus}
} +
+ ); +} diff --git a/src/components/rpgRoguelike/index.ts b/src/components/rpgRoguelike/index.ts new file mode 100644 index 0000000..5aedb71 --- /dev/null +++ b/src/components/rpgRoguelike/index.ts @@ -0,0 +1,4 @@ +export { RpgRoomPortals, type RpgRoomPortalsProps } from "./RpgRoomPortals"; +export { RpgRunOverlay } from "./RpgRunOverlay"; +export { RpgRunTacticalPanel } from "./RpgRunTacticalPanel"; +export type { RpgRunUiProps } from "./RpgRunUiShared"; diff --git a/src/components/rpgRoguelike/rpgRoguelike.css b/src/components/rpgRoguelike/rpgRoguelike.css new file mode 100644 index 0000000..d48e3b5 --- /dev/null +++ b/src/components/rpgRoguelike/rpgRoguelike.css @@ -0,0 +1,1771 @@ +.rpg-run-overlay, +.rpg-run-tactical { + --rpg-gold: #efc858; + --rpg-ink: #f3f7f4; + --rpg-muted: #9eb0aa; + --rpg-line: rgba(179, 210, 199, 0.2); + --rpg-panel: rgba(8, 19, 17, 0.96); + color: var(--rpg-ink); + font-family: "Rajdhani", "Avenir Next Condensed", sans-serif; + text-align: left; +} + +.rpg-run-overlay { + position: absolute; + inset: 0; + z-index: 34; + container: rpg-top / inline-size; + pointer-events: none; +} + +.rpg-run-overlay.is-gated { + pointer-events: auto; + background: + linear-gradient(180deg, rgba(2, 7, 6, 0.9), rgba(3, 10, 8, 0.98)), + repeating-linear-gradient(120deg, transparent 0 15px, rgba(255, 255, 255, 0.018) 15px 16px); + box-shadow: inset 0 0 80px rgba(0, 0, 0, 0.75); +} + +.rpg-run-header { + height: 50px; + padding: 8px 16px 6px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + border-bottom: 1px solid var(--rpg-line); + background: linear-gradient(180deg, rgba(11, 28, 24, 0.98), rgba(6, 16, 14, 0.92)); +} + +.rpg-run-header > div:first-child { + min-width: 0; + display: grid; +} + +.rpg-run-header small, +.rpg-phase-title small, +.rpg-centered-panel > small, +.rpg-cleared-panel > small, +.rpg-tactical-hero > small, +.rpg-tactical-terminal > small { + color: var(--rpg-gold); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.16em; + line-height: 1.1; + text-transform: uppercase; +} + +.rpg-run-header strong { + overflow: hidden; + color: var(--rpg-ink); + font-family: "Cinzel", Georgia, serif; + font-size: 17px; + letter-spacing: 0.04em; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-run-resources { + display: flex; + align-items: center; + gap: 8px; +} + +.rpg-run-resources span { + min-width: 70px; + padding: 5px 9px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + color: #dce8e2; + border: 1px solid var(--rpg-line); + border-radius: 4px; + background: rgba(3, 10, 8, 0.68); + font-size: 12px; + font-weight: 700; +} + +.rpg-run-resources i { + color: var(--rpg-gold); + font-style: normal; +} + +.rpg-route-pips { + height: 18px; + padding: 5px 16px; + display: grid; + grid-template-columns: repeat(10, minmax(0, 1fr)); + gap: 5px; + background: rgba(2, 8, 7, 0.9); +} + +.rpg-route-pips i { + height: 3px; + border-radius: 2px; + background: rgba(179, 210, 199, 0.15); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03); +} + +.rpg-route-pips i.is-cleared { + background: #65ba8d; + box-shadow: 0 0 6px rgba(101, 186, 141, 0.45); +} + +.rpg-route-pips i.is-current { + height: 5px; + margin-top: -1px; + background: var(--rpg-gold); + box-shadow: 0 0 8px rgba(239, 200, 88, 0.58); +} + +.rpg-route-pips i.is-finale { + clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); +} + +.rpg-phase-panel { + position: absolute; + inset: 68px 16px 10px; + min-height: 0; +} + +.rpg-phase-title { + min-height: 50px; + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(250px, 1.6fr) auto; + align-items: center; + gap: 14px; +} + +.rpg-phase-title h2, +.rpg-centered-panel h2, +.rpg-cleared-panel h2, +.rpg-tactical-hero h2, +.rpg-tactical-terminal h2 { + margin: 1px 0 0; + color: var(--rpg-ink); + font-family: "Cinzel", Georgia, serif; + font-size: 21px; + letter-spacing: 0.035em; + line-height: 1.1; +} + +.rpg-phase-title p { + margin: 0; + color: var(--rpg-muted); + font-size: 12px; + line-height: 1.25; +} + +.rpg-phase-title > b { + padding: 5px 9px; + color: var(--rpg-gold); + border: 1px solid rgba(239, 200, 88, 0.28); + border-radius: 4px; + background: rgba(239, 200, 88, 0.07); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rpg-party-draft, +.rpg-spell-draft, +.rpg-reward-panel, +.rpg-shop-panel { + display: flex; + flex-direction: column; +} + +.rpg-party-offer-grid { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 7px; +} + +.rpg-party-card, +.rpg-spell-card, +.rpg-gear-card { + position: relative; + overflow: hidden; + min-width: 0; + margin: 0; + border: 1px solid color-mix(in srgb, var(--rpg-accent, #86a39a) 42%, transparent); + border-radius: 6px; + background: + linear-gradient(145deg, color-mix(in srgb, var(--rpg-accent, #86a39a) 10%, transparent), transparent 42%), + rgba(9, 22, 19, 0.92); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.035); +} + +.rpg-party-card { + padding: 9px 9px 7px; + display: flex; + flex-direction: column; +} + +.rpg-party-card::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 2px; + background: var(--rpg-accent); + opacity: 0.8; +} + +.rpg-party-card.rarity-white { --rarity-color: #e7e9ec; } +.rpg-party-card.rarity-green, +.rpg-picked-chip.rarity-green { --rarity-color: #70d886; } +.rpg-party-card.rarity-blue, +.rpg-picked-chip.rarity-blue { --rarity-color: #65aef2; } +.rpg-party-card.rarity-purple, +.rpg-picked-chip.rarity-purple { --rarity-color: #b47aec; } +.rpg-party-card.rarity-gold, +.rpg-picked-chip.rarity-gold { --rarity-color: #efc858; } + +.rpg-party-card.is-picked { + border-color: var(--rarity-color, var(--rpg-gold)); + background: + linear-gradient(145deg, color-mix(in srgb, var(--rarity-color, var(--rpg-gold)) 18%, transparent), transparent 52%), + rgba(10, 25, 21, 0.96); +} + +.rpg-card-kicker { + display: flex; + justify-content: space-between; + gap: 5px; + color: var(--rarity-color, #e7e9ec); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.rpg-card-kicker b { + color: var(--rpg-muted); + font-weight: 600; +} + +.rpg-party-card h3, +.rpg-spell-card h3, +.rpg-gear-card h3 { + overflow: hidden; + margin: 6px 0 0; + color: #fff; + font-family: "Cinzel", Georgia, serif; + font-size: 15px; + line-height: 1.1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-party-card > p, +.rpg-spell-card p, +.rpg-gear-card p { + margin: 2px 0 0; + color: var(--rpg-muted); + font-size: 10px; + line-height: 1.2; +} + +.rpg-stat-row { + margin-top: 8px; + padding-top: 7px; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px; + border-top: 1px solid var(--rpg-line); +} + +.rpg-stat-row span { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + color: #dce8e2; + font-size: 10px; + font-weight: 700; +} + +.rpg-stat-row small { + color: #71857d; + font-size: 7px; + letter-spacing: 0.08em; +} + +.rpg-party-card > footer { + margin-top: auto; + padding: 7px 0 25px; + color: color-mix(in srgb, var(--rpg-accent) 72%, white); + font-size: 8px; + line-height: 1.25; +} + +.rpg-vital-bar { + position: relative; + height: 12px; + margin-top: 7px; + overflow: hidden; + border-radius: 2px; + background: rgba(0, 0, 0, 0.52); +} + +.rpg-vital-bar > i { + position: absolute; + inset: 0 auto 0 0; + background: linear-gradient(90deg, #3d9f6d, #75d79d); +} + +.rpg-vital-bar > small { + position: absolute; + inset: 0; + color: #f0fff5; + font-size: 8px; + font-weight: 700; + line-height: 12px; + text-align: center; + text-shadow: 0 1px 2px #000; +} + +.rpg-card-action { + position: absolute; + inset: 0; + z-index: 2; + padding: 0; + cursor: pointer; + border: 2px solid transparent; + border-radius: inherit; + background: transparent; +} + +.rpg-card-action > span, +.rpg-card-status { + position: absolute; + right: 5px; + bottom: 5px; + padding: 3px 7px; + color: #06100d; + border-radius: 3px; + background: var(--rpg-gold); + font-size: 8px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rpg-card-status { + color: #91a29c; + background: rgba(5, 12, 10, 0.88); +} + +.rpg-card-action.is-controller-selected, +.rpg-run-overlay button.is-controller-selected, +.rpg-run-tactical button.is-controller-selected { + outline: none; + border-color: var(--rpg-gold); + box-shadow: 0 0 0 1px rgba(239, 200, 88, 0.34), 0 0 14px rgba(239, 200, 88, 0.2); +} + +.rpg-picked-strip, +.rpg-spellbook-strip { + min-height: 48px; + padding: 5px 0; + display: flex; + align-items: stretch; + gap: 5px; +} + +.rpg-picked-strip > strong, +.rpg-spellbook-strip > strong { + width: 68px; + display: grid; + align-content: center; + color: var(--rpg-muted); + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rpg-picked-chip, +.rpg-empty-chip, +.rpg-spellbook-chip { + min-width: 0; + flex: 1; + border: 1px solid var(--rpg-line); + border-radius: 4px; + background: rgba(10, 24, 20, 0.9); +} + +.rpg-picked-chip { + position: relative; + padding: 4px 18px 4px 10px; + display: grid; + align-content: center; + cursor: pointer; + text-align: left; +} + +.rpg-picked-chip > i { + position: absolute; + inset: 0 auto 0 0; + width: 3px; +} + +.rpg-picked-chip > small { + overflow: hidden; + color: var(--rpg-muted); + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-picked-chip > b { + position: absolute; + right: 6px; + color: #82958e; +} + +.rpg-empty-chip { + display: grid; + place-items: center; + color: #52645d; + border-style: dashed; + font-size: 9px; + font-style: normal; +} + +.rpg-draft-footer { + min-height: 43px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--rpg-line); +} + +.rpg-draft-footer > span { + overflow: hidden; + color: var(--rpg-muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-primary-action, +.rpg-secondary-action { + min-width: 160px; + min-height: 34px; + padding: 7px 13px; + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + cursor: pointer; + border: 1px solid rgba(239, 200, 88, 0.55); + border-radius: 4px; + background: linear-gradient(180deg, #d7b650, #9d7925); + color: #07100d; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rpg-secondary-action { + border-color: var(--rpg-line); + background: rgba(10, 25, 21, 0.88); + color: #d6e4de; +} + +.rpg-primary-action:disabled, +.rpg-secondary-action:disabled, +.rpg-run-overlay button:disabled, +.rpg-run-tactical button:disabled { + cursor: default; + filter: saturate(0.25); + opacity: 0.43; +} + +.rpg-spell-offer-grid { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 9px; +} + +.rpg-spell-card { + padding: 13px 13px 32px 54px; +} + +.rpg-spell-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--rpg-accent); +} + +.rpg-spell-card.is-picked { + border-color: var(--rpg-accent); + background: linear-gradient(145deg, color-mix(in srgb, var(--rpg-accent) 17%, transparent), transparent 60%), rgba(9, 22, 19, 0.96); +} + +.rpg-spell-icon { + position: absolute; + top: 12px; + left: 12px; + width: 32px; + height: 32px; + display: grid; + place-items: center; + color: var(--rpg-accent); + border: 1px solid color-mix(in srgb, var(--rpg-accent) 45%, transparent); + border-radius: 50%; + background: color-mix(in srgb, var(--rpg-accent) 10%, #07110f); + font-size: 18px; + font-style: normal; +} + +.rpg-spell-card h3 { + margin-top: 3px; +} + +.rpg-spell-card > div > small { + color: var(--rpg-accent); + font-size: 8px; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.rpg-spell-card p { + margin-top: 8px; + font-size: 11px; + line-height: 1.32; +} + +.rpg-spellbook-strip { + min-height: 52px; +} + +.rpg-spellbook-chip { + padding: 4px 6px; + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + align-items: center; + gap: 4px; + cursor: pointer; + color: #dce8e2; +} + +.rpg-spellbook-chip > b { + color: #71857d; + font-size: 8px; +} + +.rpg-spellbook-chip > i { + color: var(--rpg-accent); + font-style: normal; +} + +.rpg-spellbook-chip > span { + overflow: hidden; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-spellbook-chip > small { + color: #71857d; +} + +.rpg-centered-panel, +.rpg-cleared-panel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} + +.rpg-centered-panel::before, +.rpg-cleared-panel::before { + content: ""; + position: absolute; + width: min(560px, 80%); + height: 320px; + z-index: -1; + border: 1px solid var(--rpg-line); + border-radius: 50%; + background: radial-gradient(ellipse, color-mix(in srgb, var(--rpg-accent, #70b99f) 11%, transparent), transparent 70%); +} + +.rpg-hero-sigil { + width: 58px; + height: 58px; + margin-bottom: 9px; + display: grid; + place-items: center; + color: var(--rpg-accent, var(--rpg-gold)); + border: 1px solid color-mix(in srgb, var(--rpg-accent, var(--rpg-gold)) 55%, transparent); + border-radius: 50%; + background: rgba(6, 16, 14, 0.85); + box-shadow: 0 0 28px color-mix(in srgb, var(--rpg-accent, var(--rpg-gold)) 16%, transparent); + font-size: 27px; +} + +.rpg-centered-panel > h3 { + margin: 4px 0 0; + color: var(--rpg-accent, var(--rpg-gold)); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.rpg-centered-panel > p, +.rpg-cleared-panel > p { + width: min(540px, 85%); + margin: 9px 0; + color: var(--rpg-muted); + font-size: 12px; + line-height: 1.35; +} + +.rpg-large-action { + min-width: 210px; + min-height: 39px; + margin-top: 11px; +} + +.rpg-challenge-objective { + width: min(470px, 78%); + margin-top: 13px; + padding: 10px 12px; + color: var(--rpg-ink); + border: 1px solid var(--rpg-line); + border-radius: 5px; + background: rgba(7, 18, 15, 0.88); + text-align: left; +} + +.rpg-challenge-objective > div:first-child { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.rpg-challenge-objective small { + color: var(--rpg-gold); + font-size: 8px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.rpg-challenge-objective strong { + font-size: 13px; +} + +.rpg-challenge-objective p, +.rpg-challenge-objective footer { + margin: 6px 0 0; + color: var(--rpg-muted); + font-size: 10px; +} + +.rpg-objective-progress { + position: relative; + height: 16px; + margin-top: 7px; + overflow: hidden; + border-radius: 3px; + background: rgba(0, 0, 0, 0.48); +} + +.rpg-objective-progress > i { + position: absolute; + inset: 0 auto 0 0; + background: linear-gradient(90deg, #4a9c7b, #77d1a8); +} + +.rpg-objective-progress > span { + position: absolute; + inset: 0; + color: white; + font-size: 9px; + font-weight: 800; + line-height: 16px; + text-align: center; + text-shadow: 0 1px 2px black; +} + +.rpg-soft-fail-copy { + margin: 7px 0 0 !important; + color: #b8c8c2 !important; + font-size: 10px !important; +} + +.rpg-challenge-result { + margin-top: 7px; + padding: 5px 9px; + border-radius: 3px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.rpg-challenge-result.is-success { + color: #9de1b8; + background: rgba(70, 167, 108, 0.15); +} + +.rpg-challenge-result.is-failure { + color: #d6b69c; + background: rgba(182, 100, 63, 0.12); +} + +.rpg-exit-arrow { + margin-bottom: 18px; + display: grid; + place-items: center; + color: var(--rpg-gold); + font-size: 28px; + line-height: 0.7; +} + +.rpg-exit-arrow i { + width: 70px; + height: 3px; + margin-bottom: 8px; + background: var(--rpg-gold); + box-shadow: 0 0 15px rgba(239, 200, 88, 0.65); +} + +.rpg-exit-arrow span { + margin-top: 10px; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.rpg-reward-grid { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 11px; + align-items: stretch; +} + +.rpg-reward-card { + position: relative; + min-width: 0; + padding: 20px 17px; + cursor: pointer; + border: 1px solid color-mix(in srgb, var(--rpg-accent) 45%, transparent); + border-radius: 7px; + background: linear-gradient(150deg, color-mix(in srgb, var(--rpg-accent) 13%, transparent), transparent 55%), rgba(8, 20, 17, 0.96); + text-align: left; +} + +.rpg-reward-card > i { + width: 42px; + height: 42px; + display: grid; + place-items: center; + color: var(--rpg-accent); + border: 1px solid color-mix(in srgb, var(--rpg-accent) 45%, transparent); + border-radius: 50%; + background: rgba(3, 10, 8, 0.5); + font-size: 22px; + font-style: normal; +} + +.rpg-reward-card > small { + display: block; + margin-top: 16px; + color: var(--rpg-accent); + font-size: 8px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.rpg-reward-card h3 { + margin: 4px 0; + font-family: "Cinzel", Georgia, serif; + font-size: 16px; +} + +.rpg-reward-card p { + margin: 0; + color: var(--rpg-muted); + font-size: 11px; + line-height: 1.3; +} + +.rpg-reward-card > b { + position: absolute; + right: 12px; + bottom: 10px; + color: var(--rpg-accent); + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.rpg-controller-hint { + min-height: 30px; + margin: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--rpg-muted); + font-size: 9px; +} + +.rpg-controller-hint b { + color: #dce8e2; +} + +.rpg-controller-hint i { + width: 1px; + height: 12px; + background: var(--rpg-line); +} + +.rpg-shop-layout { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(240px, 0.9fr); + gap: 12px; +} + +.rpg-shop-layout > section { + min-height: 0; + display: flex; + flex-direction: column; +} + +.rpg-shop-layout h3 { + margin: 0 0 5px; + color: var(--rpg-muted); + font-size: 9px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.rpg-shop-grid, +.rpg-tactical-shop-grid { + min-height: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.rpg-gear-card { + min-height: 72px; + padding: 8px 8px 22px 43px; +} + +.rpg-gear-card > i { + position: absolute; + top: 10px; + left: 9px; + width: 26px; + height: 26px; + display: grid; + place-items: center; + color: #75bceb; + border: 1px solid rgba(101, 174, 242, 0.35); + border-radius: 4px; + font-style: normal; +} + +.rpg-gear-card h3 { + margin-top: 2px; + font-size: 11px; +} + +.rpg-gear-card small { + color: #75bceb; + font-size: 7px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rpg-gear-card p { + font-size: 9px; +} + +.rpg-shop-side-list { + min-height: 0; + overflow-y: auto; + display: grid; + align-content: start; + gap: 5px; + scrollbar-color: #557269 transparent; + scrollbar-width: thin; +} + +.rpg-shop-row, +.rpg-service-row { + min-height: 42px; + padding: 5px 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + color: #dce8e2; + border: 1px solid var(--rpg-line); + border-radius: 4px; + background: rgba(8, 21, 18, 0.9); + text-align: left; +} + +.rpg-shop-row span, +.rpg-service-row span { + min-width: 0; + display: grid; + font-size: 10px; + font-weight: 700; +} + +.rpg-shop-row small, +.rpg-service-row small { + overflow: hidden; + color: var(--rpg-muted); + font-size: 7px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-shop-row > b, +.rpg-service-row > b { + flex: 0 0 auto; + color: var(--rpg-gold); + font-size: 9px; +} + +.rpg-empty-copy { + margin: 0; + padding: 9px; + color: #6f837b; + border: 1px dashed var(--rpg-line); + border-radius: 4px; + font-size: 9px; + text-align: center; +} + +.rpg-terminal-actions { + margin-top: 10px; + display: flex; + gap: 8px; +} + +.rpg-terminal.is-victory .rpg-hero-sigil, +.rpg-tactical-terminal.is-victory > i { + color: var(--rpg-gold); + box-shadow: 0 0 34px rgba(239, 200, 88, 0.22); +} + +.rpg-terminal.is-defeat .rpg-hero-sigil, +.rpg-tactical-terminal.is-defeat > i { + color: #d27567; + border-color: rgba(210, 117, 103, 0.5); +} + +.rpg-compact-combat-hud { + position: absolute; + top: 62px; + left: 50%; + width: min(440px, calc(100% - 280px)); + min-height: 40px; + padding: 6px 11px; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid rgba(179, 210, 199, 0.16); + border-radius: 5px; + background: rgba(3, 11, 9, 0.78); + backdrop-filter: blur(4px); +} + +.rpg-compact-combat-hud > div:first-child { + min-width: 0; + display: grid; +} + +.rpg-compact-combat-hud small { + color: var(--rpg-gold); + font-size: 7px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.rpg-compact-combat-hud strong { + overflow: hidden; + font-family: "Cinzel", Georgia, serif; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-compact-combat-hud .rpg-route-pips { + width: 180px; + height: auto; + padding: 0; + background: none; +} + +.rpg-challenge-objective.is-compact { + width: 220px; + margin: 0; + padding: 0; + border: 0; + background: none; +} + +.rpg-challenge-objective.is-compact > div:first-child { + display: none; +} + +.rpg-challenge-objective.is-compact .rpg-objective-progress { + margin: 0; +} + +/* Bottom / tactical display */ + +.rpg-run-tactical { + width: 100%; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + container: rpg-bottom / inline-size; + background: + radial-gradient(circle at 90% 0, rgba(94, 199, 176, 0.08), transparent 42%), + #07120f; +} + +.rpg-run-tactical .rpg-run-header { + height: 47px; + flex: 0 0 47px; + padding: 6px 10px 5px; +} + +.rpg-run-tactical .rpg-run-header strong { + font-size: 14px; +} + +.rpg-run-tactical .rpg-run-resources span { + min-width: 56px; + padding: 4px 6px; + font-size: 10px; +} + +.rpg-run-tactical > .rpg-route-pips { + height: 15px; + flex: 0 0 15px; + padding: 4px 10px; +} + +.rpg-tactical-body { + min-height: 0; + flex: 1; + overflow-x: hidden; + overflow-y: auto; + padding: 8px 10px 10px; + overscroll-behavior: contain; + scrollbar-color: #48675d transparent; + scrollbar-width: thin; +} + +.rpg-tactical-section + .rpg-tactical-section, +.rpg-tactical-hero + .rpg-tactical-section, +.rpg-challenge-objective + .rpg-tactical-section { + margin-top: 8px; +} + +.rpg-tactical-section > header { + min-height: 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.rpg-tactical-section > header h3 { + margin: 0; + color: #dce8e2; + font-size: 10px; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.rpg-tactical-section > header span { + color: var(--rpg-gold); + font-size: 9px; +} + +.rpg-tactical-list { + display: grid; + gap: 4px; +} + +.rpg-tactical-offer { + position: relative; + min-height: 50px; + padding: 5px 61px 5px 38px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + cursor: pointer; + color: #e7f0ec; + border: 1px solid color-mix(in srgb, var(--rpg-accent) 36%, transparent); + border-radius: 5px; + background: linear-gradient(110deg, color-mix(in srgb, var(--rpg-accent) 10%, transparent), transparent 55%), rgba(8, 21, 18, 0.92); + text-align: left; +} + +.rpg-tactical-offer > i { + position: absolute; + left: 8px; + width: 22px; + height: 22px; + display: grid; + place-items: center; + color: var(--rpg-accent); + border: 1px solid color-mix(in srgb, var(--rpg-accent) 35%, transparent); + border-radius: 50%; + font-size: 12px; + font-style: normal; +} + +.rpg-tactical-offer > span { + min-width: 0; + display: grid; +} + +.rpg-tactical-offer > span small { + color: var(--rpg-accent); + font-size: 7px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rpg-tactical-offer > span strong { + overflow: hidden; + font-family: "Cinzel", Georgia, serif; + font-size: 11px; + line-height: 1.15; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-tactical-offer > span em { + overflow: hidden; + color: var(--rpg-muted); + font-size: 8px; + font-style: normal; + line-height: 1.1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-tactical-offer > b { + display: grid; + color: #cddbd5; + font-size: 8px; + text-align: right; +} + +.rpg-tactical-offer > b small { + color: var(--rpg-muted); + font-size: 7px; + font-weight: 500; +} + +.rpg-tactical-offer > u { + position: absolute; + right: 6px; + min-width: 46px; + padding: 3px 5px; + color: #07100d; + border-radius: 3px; + background: var(--rpg-gold); + font-size: 7px; + font-weight: 800; + letter-spacing: 0.06em; + text-align: center; + text-decoration: none; + text-transform: uppercase; +} + +.rpg-tactical-offer[aria-pressed="true"] { + background: linear-gradient(110deg, color-mix(in srgb, var(--rpg-accent) 20%, transparent), transparent 60%), rgba(8, 21, 18, 0.96); +} + +.rpg-tactical-offer.is-spell { + min-height: 63px; + grid-template-columns: 1fr; +} + +.rpg-tactical-offer.is-spell > span em { + max-width: 420px; +} + +.rpg-tactical-party-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 4px; +} + +.rpg-party-card.is-compact { + min-height: 72px; + padding: 6px; +} + +.rpg-party-card.is-compact h3 { + margin-top: 4px; + font-size: 10px; +} + +.rpg-party-card.is-compact > p { + overflow: hidden; + font-size: 7px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-party-card.is-compact .rpg-card-kicker { + font-size: 6px; +} + +.rpg-party-card.is-compact .rpg-vital-bar { + height: 10px; + margin-top: 5px; +} + +.rpg-party-card.is-compact .rpg-vital-bar small { + font-size: 6px; + line-height: 10px; +} + +.rpg-tactical-spell-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; +} + +.rpg-tactical-spell { + min-width: 0; + min-height: 40px; + padding: 4px 5px; + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + align-items: center; + gap: 5px; + color: #dce8e2; + border: 1px solid color-mix(in srgb, var(--rpg-accent) 30%, transparent); + border-radius: 4px; + background: rgba(8, 21, 18, 0.9); + text-align: left; +} + +button.rpg-tactical-spell { + cursor: pointer; +} + +.rpg-tactical-spell > b { + color: #71857d; + font-size: 7px; +} + +.rpg-tactical-spell > i { + color: var(--rpg-accent); + font-size: 13px; + font-style: normal; +} + +.rpg-tactical-spell > span { + overflow: hidden; + display: grid; + font-size: 8px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-tactical-spell > span small { + color: var(--rpg-muted); + font-size: 6px; + font-weight: 500; +} + +.rpg-tactical-spell > em { + color: #71857d; + font-style: normal; +} + +.rpg-tactical-continue { + width: 100%; + min-height: 42px; + margin-top: 8px; + padding: 7px 12px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + color: #07100d; + border: 1px solid rgba(255, 225, 147, 0.5); + border-radius: 5px; + background: linear-gradient(180deg, #dcbc58, #a17c27); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rpg-tactical-continue > b { + min-width: 25px; + height: 25px; + display: grid; + place-items: center; + border: 1px solid rgba(7, 16, 13, 0.35); + border-radius: 50%; +} + +.rpg-tactical-hero { + padding: 10px 12px 10px 55px; + position: relative; + min-height: 82px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--rpg-accent, #70b99f) 35%, transparent); + border-radius: 6px; + background: linear-gradient(120deg, color-mix(in srgb, var(--rpg-accent, #70b99f) 12%, transparent), transparent 58%), rgba(8, 21, 18, 0.92); +} + +.rpg-tactical-hero > i { + position: absolute; + top: 13px; + left: 12px; + width: 32px; + height: 32px; + display: grid; + place-items: center; + color: var(--rpg-accent, var(--rpg-gold)); + border: 1px solid color-mix(in srgb, var(--rpg-accent, var(--rpg-gold)) 40%, transparent); + border-radius: 50%; + font-size: 18px; + font-style: normal; +} + +.rpg-tactical-hero h2 { + margin-top: 2px; + font-size: 15px; +} + +.rpg-tactical-hero p { + margin: 5px 0 0; + color: var(--rpg-muted); + font-size: 9px; + line-height: 1.25; +} + +.rpg-run-tactical .rpg-challenge-objective { + width: 100%; + margin-top: 0; +} + +.rpg-tactical-cleared > i { + color: var(--rpg-gold); + font-size: 24px; +} + +.rpg-tactical-rewards { + display: grid; + gap: 6px; +} + +.rpg-tactical-reward { + min-height: 94px; + padding: 9px 64px 9px 49px; + position: relative; + cursor: pointer; + color: #e7f0ec; + border: 1px solid color-mix(in srgb, var(--rpg-accent) 42%, transparent); + border-radius: 5px; + background: linear-gradient(110deg, color-mix(in srgb, var(--rpg-accent) 12%, transparent), transparent 58%), rgba(8, 21, 18, 0.92); + text-align: left; +} + +.rpg-tactical-reward > i { + position: absolute; + top: 12px; + left: 10px; + width: 30px; + height: 30px; + display: grid; + place-items: center; + color: var(--rpg-accent); + border: 1px solid color-mix(in srgb, var(--rpg-accent) 40%, transparent); + border-radius: 50%; + font-size: 16px; + font-style: normal; +} + +.rpg-tactical-reward > span { + display: grid; +} + +.rpg-tactical-reward small { + color: var(--rpg-accent); + font-size: 7px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.rpg-tactical-reward strong { + font-family: "Cinzel", Georgia, serif; + font-size: 12px; +} + +.rpg-tactical-reward p { + margin: 4px 0 0; + color: var(--rpg-muted); + font-size: 9px; +} + +.rpg-tactical-reward > b { + position: absolute; + right: 11px; + color: var(--rpg-accent); + font-size: 8px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rpg-run-tactical .rpg-tactical-shop-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.rpg-run-tactical .rpg-gear-card { + min-height: 80px; +} + +.rpg-service-row { + width: 100%; +} + +.rpg-tactical-terminal { + min-height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} + +.rpg-tactical-terminal > i { + width: 58px; + height: 58px; + margin-bottom: 9px; + display: grid; + place-items: center; + color: var(--rpg-gold); + border: 1px solid rgba(239, 200, 88, 0.45); + border-radius: 50%; + font-size: 26px; + font-style: normal; +} + +.rpg-tactical-terminal h2 { + margin-top: 3px; + font-size: 20px; +} + +.rpg-tactical-terminal p { + margin: 7px 0 12px; + color: var(--rpg-muted); + font-size: 10px; +} + +.rpg-tactical-terminal .rpg-secondary-action { + width: 100%; + min-height: 40px; + margin-top: 6px; +} + +.rpg-live-party-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 4px; +} + +.rpg-live-party-grid > button { + min-width: 0; + min-height: 64px; + padding: 5px; + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: center; + gap: 5px; + color: #dce8e2; + border: 1px solid var(--rpg-line); + border-radius: 5px; + background: rgba(8, 21, 18, 0.92); + text-align: left; +} + +.rpg-live-party-grid > button.is-selected { + border-color: var(--rpg-gold); + box-shadow: inset 0 0 0 1px rgba(239, 200, 88, 0.22), 0 0 8px rgba(239, 200, 88, 0.12); +} + +.rpg-live-party-grid > button.is-down { + filter: grayscale(0.8); + opacity: 0.5; +} + +.rpg-live-party-grid > button > i { + width: 24px; + height: 24px; + display: grid; + place-items: center; + color: #07100d; + border-radius: 50%; + background: var(--rpg-member-color); + font-size: 10px; + font-style: normal; + font-weight: 900; +} + +.rpg-live-party-grid > button > span { + min-width: 0; + display: grid; +} + +.rpg-live-party-grid strong, +.rpg-live-party-grid small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-live-party-grid strong { font-size: 9px; } +.rpg-live-party-grid small { color: var(--rpg-muted); font-size: 8px; } + +.rpg-live-party-grid em { + height: 4px; + margin-top: 3px; + overflow: hidden; + border-radius: 2px; + background: rgba(255, 255, 255, 0.08); +} + +.rpg-live-party-grid em b { + height: 100%; + display: block; + background: #65ba8d; +} + +.rpg-live-party-grid u { + grid-column: 1 / -1; + color: var(--rpg-muted); + font-size: 8px; + text-align: center; + text-decoration: none; +} + +.rpg-live-resource-bar { + height: 6px; + overflow: hidden; + border-radius: 3px; + background: rgba(65, 121, 171, 0.2); +} + +.rpg-live-resource-bar i { + height: 100%; + display: block; + background: linear-gradient(90deg, #3976b6, #62a7e5); +} + +.rpg-live-resources { + margin: 4px 0; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 5px; +} + +.rpg-live-resources span { + padding: 3px 6px; + display: flex; + justify-content: space-between; + gap: 5px; + color: var(--rpg-gold); + border: 1px solid var(--rpg-line); + border-radius: 3px; + background: rgba(3, 10, 8, 0.68); + font-size: 8px; + white-space: nowrap; +} + +.rpg-live-resources b { color: var(--rpg-muted); } + +.rpg-live-cast { + margin: 4px 0; + display: grid; + gap: 2px; + color: #dce8e2; + font-size: 9px; +} + +.rpg-live-cast > i { + height: 4px; + overflow: hidden; + border-radius: 2px; + background: rgba(239, 200, 88, 0.15); +} + +.rpg-live-cast > i > em { + width: var(--rpg-cast-progress); + height: 100%; + display: block; + background: var(--rpg-gold); +} + +.rpg-live-spell-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; +} + +.rpg-live-spell { + min-width: 0; + min-height: 50px; + padding: 5px; + display: grid; + grid-template-columns: 20px 22px minmax(0, 1fr) auto; + align-items: center; + gap: 4px; + color: #dce8e2; + border: 1px solid color-mix(in srgb, var(--rpg-accent, #71857d) 36%, transparent); + border-radius: 4px; + background: linear-gradient(110deg, color-mix(in srgb, var(--rpg-accent, #71857d) 8%, transparent), transparent 60%), rgba(8, 21, 18, 0.92); + text-align: left; +} + +.rpg-live-spell > b, +.rpg-live-spell > u { color: var(--rpg-gold); font-size: 8px; text-decoration: none; } +.rpg-live-spell > i { color: var(--rpg-accent); font-size: 14px; font-style: normal; } +.rpg-live-spell > span { min-width: 0; display: grid; } +.rpg-live-spell strong, +.rpg-live-spell small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rpg-live-spell strong { font-size: 9px; } +.rpg-live-spell small { color: var(--rpg-muted); font-size: 8px; } +.rpg-live-spell.is-cooling { opacity: 0.65; } +.rpg-live-spell.is-empty { grid-template-columns: auto 1fr; color: #71857d; } + +/* Lower-panel text must remain readable at the 620x540 Android layout viewport. */ +.rpg-run-tactical small, +.rpg-tactical-offer > u, +.rpg-party-card.is-compact .rpg-card-kicker, +.rpg-tactical-spell > b { + font-size: max(8px, 1.3cqw); +} + +.large-interface-text .rpg-run-overlay small, +.large-interface-text .rpg-run-tactical small { font-size: 10px; } +.large-interface-text .rpg-run-overlay p, +.large-interface-text .rpg-run-tactical p { font-size: 12px; } +.large-interface-text .rpg-run-overlay button, +.large-interface-text .rpg-run-tactical button { font-size: 11px; } + +@container rpg-top (max-width: 720px) { + .rpg-phase-panel { + inset-inline: 10px; + } + + .rpg-phase-title { + grid-template-columns: 1fr auto; + } + + .rpg-phase-title > p { + display: none; + } + + .rpg-party-offer-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + overflow-y: auto; + } + + .rpg-party-card { + min-height: 135px; + } + + .rpg-spell-offer-grid { + grid-template-columns: 1fr; + overflow-y: auto; + } + + .rpg-spell-card { + min-height: 92px; + } + + .rpg-picked-strip, + .rpg-spellbook-strip { + overflow-x: auto; + } + + .rpg-picked-chip, + .rpg-empty-chip, + .rpg-spellbook-chip { + min-width: 100px; + } + + .rpg-shop-layout { + grid-template-columns: 1fr; + overflow-y: auto; + } +} + +@container rpg-bottom (max-width: 480px) { + .rpg-tactical-party-grid, + .rpg-tactical-spell-grid, + .rpg-run-tactical .rpg-tactical-shop-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .rpg-tactical-offer > b { + display: none; + } +} + +/* Single-display browser fallback gets a usable full-height draft surface. */ +@media (max-width: 760px) { + .top-display:has(.rpg-run-overlay.is-gated) { + height: max(480px, min(540px, calc(100dvh - 20px))); + aspect-ratio: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .rpg-run-overlay *, + .rpg-run-tactical * { + scroll-behavior: auto !important; + transition: none !important; + animation: none !important; + } +} diff --git a/src/frontend/appearanceStore.test.ts b/src/frontend/appearanceStore.test.ts new file mode 100644 index 0000000..0ed5cb7 --- /dev/null +++ b/src/frontend/appearanceStore.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createDefaultHealerAppearance } from "../game/healerVisuals"; +import { hasPendingSaveSync } from "./saveSync"; +import { getFrontendSnapshot, useFrontendStore } from "./store"; + +const originalState = useFrontendStore.getState(); + +afterEach(() => { + useFrontendStore.getState().deleteSlot(3); + useFrontendStore.setState({ + activeSlotId: originalState.activeSlotId, + selectedSlotId: originalState.selectedSlotId, + screen: originalState.screen, + appearanceClassId: originalState.appearanceClassId, + appearanceDrafts: structuredClone(originalState.appearanceDrafts), + previewMode: originalState.previewMode, + previewAnimation: originalState.previewAnimation, + notice: originalState.notice, + }); +}); + +describe("Appearance Lab frontend state", () => { + it("opens from saved looks, previews drafts, saves explicitly, and cancels unsaved edits", () => { + const frontend = useFrontendStore.getState(); + frontend.deleteSlot(3); + expect(frontend.createSlot(3, "Wardrobe Tester")).toBe(true); + frontend.playSlot(3); + + useFrontendStore.getState().openAppearanceLab(); + let state = useFrontendStore.getState(); + const savedPriest = state.slots[2].local!.healers.priest.appearance; + expect(state.screen).toBe("appearance"); + expect(state.appearanceClassId).toBe("priest"); + expect(state.appearanceDrafts.priest).toEqual(savedPriest); + expect(state.appearanceDrafts.priest).not.toBe(savedPriest); + expect(state.previewMode).toBe("modular"); + expect(state.previewAnimation).toBe("idle"); + + const mixedPriest = { + ...state.appearanceDrafts.priest, + headPartId: "rogue-head" as const, + }; + state.updateAppearanceDraft(mixedPriest); + state.setAppearancePreviewMode("legacy"); + state.setAppearancePreviewAnimation("cast"); + state = useFrontendStore.getState(); + expect(state.appearanceDrafts.priest.headPartId).toBe("rogue-head"); + expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe("mage-head"); + expect(state.previewMode).toBe("legacy"); + expect(state.previewAnimation).toBe("cast"); + + expect(state.saveAppearanceDraft()).toBe(true); + state = useFrontendStore.getState(); + expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe("rogue-head"); + expect(hasPendingSaveSync(3)).toBe(true); + + state.updateAppearanceDraft({ + ...state.appearanceDrafts.priest, + headPartId: "ranger-head", + }); + useFrontendStore.getState().closeAppearanceLab(); + state = useFrontendStore.getState(); + expect(state.screen).toBe("home"); + expect(state.appearanceDrafts.priest.headPartId).toBe("rogue-head"); + expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe("rogue-head"); + }); + + it("resets only the selected class and includes state but no actions in snapshots", () => { + useFrontendStore.setState((state) => ({ + appearanceClassId: "paladin", + appearanceDrafts: { + ...state.appearanceDrafts, + paladin: { ...state.appearanceDrafts.paladin, headPartId: "rogue-head" }, + }, + previewMode: "legacy", + previewAnimation: "walk", + })); + + useFrontendStore.getState().resetAppearanceDraft(); + const snapshot = getFrontendSnapshot(); + expect(snapshot.appearanceClassId).toBe("paladin"); + expect(snapshot.appearanceDrafts.paladin).toEqual(createDefaultHealerAppearance("paladin")); + expect(snapshot.previewMode).toBe("legacy"); + expect(snapshot.previewAnimation).toBe("walk"); + expect("openAppearanceLab" in snapshot).toBe(false); + expect("updateAppearanceDraft" in snapshot).toBe(false); + expect("saveAppearanceDraft" in snapshot).toBe(false); + expect("setAppearancePreviewAnimation" in snapshot).toBe(false); + }); +}); diff --git a/src/frontend/data.test.ts b/src/frontend/data.test.ts index 0fe1820..6e8db5b 100644 --- a/src/frontend/data.test.ts +++ b/src/frontend/data.test.ts @@ -1,12 +1,26 @@ import { describe, expect, it } from "vitest"; -import { buildCollections, MODE_COPY, selectRandomBoss } from "./data"; +import { buildCollections, createHunterSave, MODE_COPY, selectRandomBoss } from "./data"; +import { HEALER_CLASS_ORDER } from "../game/healers"; +import { HEALER_VISUAL_PROFILES } from "../game/healerVisuals"; import { selectRandomBossPair } from "../game/roguelike"; import { GROUP_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot"; import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog"; describe("game mode configuration", () => { - it("separates randomized PVE from selectable Dungeons", () => { - expect(MODE_COPY["roguelike-pve"].title).toBe("PVE"); + it("initializes progression for every playable healer", () => { + const save = createHunterSave(1, "2026-07-16T00:00:00.000Z", "Aelia"); + expect(Object.keys(save.healers)).toEqual(HEALER_CLASS_ORDER); + expect(save.healers.paladin.inventory.length).toBeGreaterThan(0); + expect(save.healers.chronomancer.inventory.length).toBeGreaterThan(0); + for (const classId of HEALER_CLASS_ORDER) { + expect(save.healers[classId].appearance).toEqual(HEALER_VISUAL_PROFILES[classId].appearance); + expect(save.healers[classId].appearance).not.toBe(HEALER_VISUAL_PROFILES[classId].appearance); + } + }); + + it("separates RPG Roguelike from Rogue Trials and selectable Dungeons", () => { + expect(MODE_COPY["roguelike-pve"].title).toBe("RPG Roguelike"); + expect(MODE_COPY["roguelike-pve"].description).toContain("Draft"); expect(MODE_COPY["rogue-trials"].detail).toContain("Endless"); expect(MODE_COPY.dungeons.title).toBe("Dungeons"); }); diff --git a/src/frontend/data.ts b/src/frontend/data.ts index 270d069..b75a717 100644 --- a/src/frontend/data.ts +++ b/src/frontend/data.ts @@ -1,9 +1,11 @@ import type { BossGroupCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types"; import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "../game/bossCatalog"; -import { createClassInventory } from "../game/healers"; +import { createClassInventory, HEALER_CLASS_ORDER } from "../game/healers"; import type { BossId } from "../game/types"; import { createDefaultGearProgress } from "../game/progression/gear"; import { BOSS_PET_DROPS, GROUP_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type LootRarity, type MaterialStack } from "../game/progression/loot"; +import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker"; +import { createDefaultHealerAppearance } from "../game/healerVisuals"; export const DEFAULT_SETTINGS: GameSettings = { masterVolume: 80, @@ -66,10 +68,10 @@ export const DEFAULT_COLLECTIONS: BossGroupCollection[] = buildCollections(DEFAU export const MODE_COPY: Record = { "roguelike-pve": { - eyebrow: "1–4 hunters · randomized PVE", - title: "PVE", - description: "Enter without an encounter briefing, adapt to two randomized guardians, and build toward a full roguelike run.", - detail: "Two bosses selected when the run begins", + eyebrow: "Solo healer · drafted RPG expedition", + title: "RPG Roguelike", + description: "Draft a random four-companion party and mixed healing spellbook, clear escalating arcade hallways, defeat ten bosses, and build a run-only loadout.", + detail: "Three acts, three shops, and a final guardian", status: "Playable now", }, "rogue-trials": { @@ -86,11 +88,39 @@ export const MODE_COPY: Record [classId, { + level: 1, + inventory: createClassInventory(classId), + appearance: createDefaultHealerAppearance(classId), + }])) as HunterSave["healers"], location: "Ember Vault Approach", playSeconds: 0, updatedAt: now, @@ -140,6 +170,17 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st bossKills: {}, highestRoguelikeRound: 0, highestRogueTrialsEndlessKills: 0, + highestHockeyHealingReturns: 0, + longestHockeyHealingSecondsAtBest: 0, + hockeyHealingPvpWins: 0, + hockeyHealingPvpLosses: 0, + hockeyHealingPvpBossKills: 0, + highestBlockbreakerBricks: 0, + longestBlockbreakerSeconds: 0, + highestBlockbreakerScore: 0, + highestAetherAssaultScore: 0, + highestAetherAssaultWaveAtBest: 0, + longestAetherAssaultSecondsAtBest: 0, }, materials: [] as MaterialStack[], collectionLog: createEmptyCollectionLog(), diff --git a/src/frontend/leaderboardCache.test.ts b/src/frontend/leaderboardCache.test.ts new file mode 100644 index 0000000..069bf91 --- /dev/null +++ b/src/frontend/leaderboardCache.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { LeaderboardCache } from "./leaderboardCache"; +import type { LeaderboardResult } from "./onlineRepository"; + +function memoryStorage() { + const values = new Map(); + return { + values, + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + }; +} + +const result: LeaderboardResult = { + kind: "roguelike", + top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12 }], + current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12 }, +}; + +describe("leaderboard cache", () => { + it("persists rankings for signed-in and offline reads", () => { + const storage = memoryStorage(); + const cache = new LeaderboardCache(storage, () => "2026-07-14T00:00:00.000Z"); + cache.write("hunter", "Aelia", 1, "roguelike", result); + + expect(cache.read(1, "roguelike", "Aelia", "hunter")?.result).toEqual(result); + expect(cache.read(1, "roguelike", "Aelia", null)?.accountId).toBe("hunter"); + expect(cache.read(1, "roguelike", "Other", null)).toBeNull(); + expect(cache.read(1, "roguelike", "Aelia", "different-account")).toBeNull(); + }); + + it("ignores corrupt persisted responses", () => { + const storage = memoryStorage(); + storage.setItem("i-want-to-heal:leaderboards:cache:v1", JSON.stringify({ + "1:roguelike": { accountId: "hunter", hunterName: "Aelia", slotId: 1, statId: "roguelike", updatedAt: "today", result: {} }, + })); + expect(new LeaderboardCache(storage).read(1, "roguelike", "Aelia", "hunter")).toBeNull(); + }); + + it("preserves Hockey Healing duration tiebreakers", () => { + const cache = new LeaderboardCache(memoryStorage()); + const hockeyResult: LeaderboardResult = { + kind: "hockey-healing", + top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 20, secondaryValue: 95.5 }], + current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 20, secondaryValue: 95.5 }, + }; + + cache.write("hunter", "Aelia", 1, "hockey-healing", hockeyResult); + expect(cache.read(1, "hockey-healing", "Aelia", "hunter")?.result).toEqual(hockeyResult); + }); + + it("validates Blockbreaker metric boards for offline reads", () => { + const cache = new LeaderboardCache(memoryStorage()); + const blockbreakerResult: LeaderboardResult = { + kind: "blockbreaker-score", + top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12_500 }], + current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12_500 }, + }; + cache.write("hunter", "Aelia", 1, "blockbreaker-score", blockbreakerResult); + expect(cache.read(1, "blockbreaker-score", "Aelia", "hunter")?.result).toEqual(blockbreakerResult); + }); + + it("preserves Aether Assault wave tiebreakers for offline reads", () => { + const cache = new LeaderboardCache(memoryStorage()); + const aetherResult: LeaderboardResult = { + kind: "aether-assault", + top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 48_500, secondaryValue: 9 }], + current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 48_500, secondaryValue: 9 }, + }; + cache.write("hunter", "Aelia", 1, "aether-assault", aetherResult); + expect(cache.read(1, "aether-assault", "Aelia", "hunter")?.result).toEqual(aetherResult); + }); +}); diff --git a/src/frontend/leaderboardCache.ts b/src/frontend/leaderboardCache.ts new file mode 100644 index 0000000..1752f9b --- /dev/null +++ b/src/frontend/leaderboardCache.ts @@ -0,0 +1,145 @@ +import type { LeaderboardEntry, LeaderboardResult } from "./onlineRepository"; +import type { ProfileStatId, SaveSlotId } from "./types"; + +interface StorageAdapter { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export interface CachedLeaderboard { + accountId: string; + hunterName: string; + slotId: SaveSlotId; + statId: ProfileStatId; + updatedAt: string; + result: LeaderboardResult; +} + +const CACHE_KEY = "i-want-to-heal:leaderboards:cache:v1"; +const fallbackMemory = new Map(); +const fallbackStorage: StorageAdapter = { + getItem: (key) => fallbackMemory.get(key) ?? null, + setItem: (key, value) => { fallbackMemory.set(key, value); }, +}; + +function browserStorage(): StorageAdapter { + try { + if (typeof localStorage !== "undefined") return localStorage; + } catch { + // Android WebView can deny storage before its host is ready. + } + return fallbackStorage; +} + +function cacheId(slotId: SaveSlotId, statId: ProfileStatId) { + return `${slotId}:${statId}`; +} + +function leaderboardEntry(value: unknown): LeaderboardEntry | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Partial; + if (!Number.isInteger(candidate.rank) || Number(candidate.rank) < 1) return null; + if (typeof candidate.username !== "string" || typeof candidate.hunterName !== "string") return null; + if (candidate.slotId !== 1 && candidate.slotId !== 2 && candidate.slotId !== 3) return null; + if (!Number.isFinite(candidate.value) || Number(candidate.value) < 0) return null; + if (candidate.secondaryValue !== undefined && (!Number.isFinite(candidate.secondaryValue) || Number(candidate.secondaryValue) < 0)) return null; + return { + rank: Number(candidate.rank), + username: candidate.username, + hunterName: candidate.hunterName, + slotId: candidate.slotId, + value: Number(candidate.value), + ...(candidate.secondaryValue === undefined ? {} : { secondaryValue: Number(candidate.secondaryValue) }), + }; +} + +function leaderboardResult(value: unknown): LeaderboardResult | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Partial; + if (candidate.kind !== "boss" + && candidate.kind !== "roguelike" + && candidate.kind !== "rogue-trials-endless" + && candidate.kind !== "hockey-healing" + && candidate.kind !== "hockey-pvp-wins" + && candidate.kind !== "hockey-pvp-boss-kills" + && candidate.kind !== "blockbreaker-bricks" + && candidate.kind !== "blockbreaker-time" + && candidate.kind !== "blockbreaker-score" + && candidate.kind !== "aether-assault") return null; + if (!Array.isArray(candidate.top)) return null; + const top = candidate.top.map(leaderboardEntry); + if (top.some((entry) => !entry)) return null; + const current = candidate.current === null ? null : leaderboardEntry(candidate.current); + if (candidate.current !== null && !current) return null; + return { + kind: candidate.kind, + ...(candidate.kind === "boss" && typeof candidate.bossId === "string" ? { bossId: candidate.bossId } : {}), + top: top as LeaderboardEntry[], + current, + }; +} + +function cachedLeaderboard(value: unknown): CachedLeaderboard | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Partial; + if (typeof candidate.accountId !== "string" || typeof candidate.hunterName !== "string") return null; + if (candidate.slotId !== 1 && candidate.slotId !== 2 && candidate.slotId !== 3) return null; + if (typeof candidate.statId !== "string" || typeof candidate.updatedAt !== "string") return null; + if (Number.isNaN(Date.parse(candidate.updatedAt))) return null; + const result = leaderboardResult(candidate.result); + if (!result) return null; + return { ...candidate, result } as CachedLeaderboard; +} + +export class LeaderboardCache { + constructor( + private readonly storage: StorageAdapter = browserStorage(), + private readonly now: () => string = () => new Date().toISOString(), + ) {} + + read(slotId: SaveSlotId, statId: ProfileStatId, hunterName: string, accountId: string | null): CachedLeaderboard | null { + const entry = this.readAll()[cacheId(slotId, statId)]; + if (!entry || entry.slotId !== slotId || entry.statId !== statId || entry.hunterName !== hunterName) return null; + if (accountId && entry.accountId !== accountId) return null; + return structuredClone(entry); + } + + write(accountId: string, hunterName: string, slotId: SaveSlotId, statId: ProfileStatId, result: LeaderboardResult): CachedLeaderboard { + const entries = this.readAll(); + const entry: CachedLeaderboard = { + accountId, + hunterName, + slotId, + statId, + updatedAt: this.now(), + result: structuredClone(result), + }; + entries[cacheId(slotId, statId)] = entry; + this.storage.setItem(CACHE_KEY, JSON.stringify(entries)); + return structuredClone(entry); + } + + clearSlot(slotId: SaveSlotId) { + const entries = this.readAll(); + for (const key of Object.keys(entries)) { + if (entries[key].slotId === slotId) delete entries[key]; + } + this.storage.setItem(CACHE_KEY, JSON.stringify(entries)); + } + + private readAll(): Record { + try { + const raw = this.storage.getItem(CACHE_KEY); + const parsed = raw ? JSON.parse(raw) as Record : {}; + if (!parsed || typeof parsed !== "object") return {}; + return Object.fromEntries(Object.entries(parsed).flatMap(([key, value]) => { + const entry = cachedLeaderboard(value); + return entry ? [[key, entry]] : []; + })); + } catch { + return {}; + } + } +} + +export const leaderboardCache = new LeaderboardCache(); diff --git a/src/frontend/offlineLeaderboardFlow.test.ts b/src/frontend/offlineLeaderboardFlow.test.ts new file mode 100644 index 0000000..5c929e4 --- /dev/null +++ b/src/frontend/offlineLeaderboardFlow.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { onlineRepository } from "./onlineRepository"; +import { hasPendingSaveSync } from "./saveSync"; +import { useFrontendStore } from "./store"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("offline leaderboard publishing", () => { + it("keeps earned stats pending without attempting an offline upload", async () => { + const original = useFrontendStore.getState(); + original.deleteSlot(3); + expect(original.createSlot(3, "Offline Hunter")).toBe(true); + original.playSlot(3); + useFrontendStore.setState({ accountId: "offline-account" }); + useFrontendStore.getState().recordBossVictory("bulldrome", "initiate"); + useFrontendStore.getState().recordAetherAssaultDefeat(18_750, 6, 214.5); + expect(hasPendingSaveSync(3)).toBe(true); + + vi.stubGlobal("navigator", { onLine: false }); + const write = vi.spyOn(onlineRepository, "writeSave"); + expect(await useFrontendStore.getState().uploadSlot(3)).toBe(false); + expect(write).not.toHaveBeenCalled(); + expect(useFrontendStore.getState().slots[2].local?.stats.bossKills.bulldrome).toBe(1); + expect(useFrontendStore.getState().slots[2].local?.stats).toMatchObject({ + highestAetherAssaultScore: 18_750, + highestAetherAssaultWaveAtBest: 6, + longestAetherAssaultSecondsAtBest: 214.5, + }); + + useFrontendStore.getState().deleteSlot(3); + useFrontendStore.setState({ + accountId: original.accountId, + activeSlotId: original.activeSlotId, + selectedSlotId: original.selectedSlotId, + screen: original.screen, + notice: original.notice, + }); + }); +}); diff --git a/src/frontend/onlineRepository.ts b/src/frontend/onlineRepository.ts index 4b5bec8..e07e168 100644 --- a/src/frontend/onlineRepository.ts +++ b/src/frontend/onlineRepository.ts @@ -1,6 +1,7 @@ import { Capacitor } from "@capacitor/core"; import type { HunterSave, SaveSlotId } from "./types"; import type { BossId } from "../game/types"; +import type { HockeyPvpRemoteSnapshot, HockeyPvpRole } from "../game/hockeyHealingPvp"; export interface OnlineAccount { id: number; @@ -19,15 +20,32 @@ export interface LeaderboardEntry { hunterName: string; slotId: SaveSlotId; value: number; + secondaryValue?: number; } export interface LeaderboardResult { - kind: "boss" | "roguelike" | "rogue-trials-endless"; + kind: "boss" | "roguelike" | "rogue-trials-endless" | "hockey-healing" | "hockey-pvp-wins" | "hockey-pvp-boss-kills" | "blockbreaker-bricks" | "blockbreaker-time" | "blockbreaker-score" | "aether-assault"; bossId?: BossId; top: LeaderboardEntry[]; current: LeaderboardEntry | null; } +export interface HockeyPvpQueueResult { + ticketId: string; + status: "waiting" | "matched"; + match?: { + id: string; + seed: number; + opponentName: string; + role: Exclude; + }; +} + +export interface HockeyPvpExchangeResult { + opponentSnapshot: HockeyPvpRemoteSnapshot | null; + hostSnapshot: HockeyPvpRemoteSnapshot | null; +} + interface TokenStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; @@ -151,6 +169,58 @@ export class OnlineRepository { rogueTrialsEndlessLeaderboard(slotId: SaveSlotId): Promise { return this.request(`/api/leaderboards/rogue-trials-endless?slot=${slotId}`); } + + hockeyHealingLeaderboard(slotId: SaveSlotId): Promise { + return this.request(`/api/leaderboards/hockey-healing?slot=${slotId}`); + } + + hockeyPvpWinsLeaderboard(slotId: SaveSlotId): Promise { + return this.request(`/api/leaderboards/hockey-pvp-wins?slot=${slotId}`); + } + + hockeyPvpBossKillsLeaderboard(slotId: SaveSlotId): Promise { + return this.request(`/api/leaderboards/hockey-pvp-boss-kills?slot=${slotId}`); + } + + blockbreakerBricksLeaderboard(slotId: SaveSlotId): Promise { + return this.request(`/api/leaderboards/blockbreaker-bricks?slot=${slotId}`); + } + + blockbreakerTimeLeaderboard(slotId: SaveSlotId): Promise { + return this.request(`/api/leaderboards/blockbreaker-time?slot=${slotId}`); + } + + blockbreakerScoreLeaderboard(slotId: SaveSlotId): Promise { + return this.request(`/api/leaderboards/blockbreaker-score?slot=${slotId}`); + } + + aetherAssaultLeaderboard(slotId: SaveSlotId): Promise { + return this.request(`/api/leaderboards/aether-assault?slot=${slotId}`); + } + + joinHockeyPvpQueue(slotId: SaveSlotId, hunterName: string): Promise { + return this.request("/api/hockey-pvp/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slotId, hunterName }), + }); + } + + pollHockeyPvpQueue(ticketId: string): Promise { + return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`); + } + + cancelHockeyPvpQueue(ticketId: string): Promise { + return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`, { method: "DELETE" }); + } + + exchangeHockeyPvpState(matchId: string, snapshot: HockeyPvpRemoteSnapshot): Promise { + return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/state`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ snapshot }), + }); + } } export const onlineRepository = new OnlineRepository(); diff --git a/src/frontend/profileSections.test.ts b/src/frontend/profileSections.test.ts new file mode 100644 index 0000000..16b0513 --- /dev/null +++ b/src/frontend/profileSections.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { buildCollections } from "./data"; +import { alphabeticalBosses, defaultStatForSection, profileSectionForStat } from "./profileSections"; + +describe("hunter profile sections", () => { + const bosses = alphabeticalBosses(buildCollections({ dropsFound: {}, petsFound: {} }, {})); + + it("groups related records under one section", () => { + expect(profileSectionForStat("blockbreaker-bricks")).toBe("blockbreaker"); + expect(profileSectionForStat("blockbreaker-time")).toBe("blockbreaker"); + expect(profileSectionForStat("hockey-pvp-boss-kills")).toBe("hockey-pvp"); + expect(profileSectionForStat("aether-assault")).toBe("aether-assault"); + expect(profileSectionForStat("bulldrome")).toBe("bosses"); + }); + + it("builds one alphabetical boss index across mechanic groups", () => { + const names = bosses.map((boss) => boss.bossName); + expect(names).toEqual([...names].sort((left, right) => left.localeCompare(right))); + expect(new Set(bosses.map((boss) => boss.bossId)).size).toBe(bosses.length); + }); + + it("opens each section on its primary record", () => { + expect(defaultStatForSection("hockey-pvp", bosses)).toBe("hockey-pvp-wins"); + expect(defaultStatForSection("blockbreaker", bosses)).toBe("blockbreaker-score"); + expect(defaultStatForSection("aether-assault", bosses)).toBe("aether-assault"); + expect(defaultStatForSection("bosses", bosses)).toBe(bosses[0].bossId); + }); +}); diff --git a/src/frontend/profileSections.ts b/src/frontend/profileSections.ts new file mode 100644 index 0000000..420946b --- /dev/null +++ b/src/frontend/profileSections.ts @@ -0,0 +1,45 @@ +import { AVAILABLE_BOSS_IDS } from "../game/bossCatalog"; +import type { BossId } from "../game/types"; +import type { GroupBossCollection, ProfileStatId } from "./types"; + +export type ProfileSectionId = "roguelike" | "rogue-trials" | "hockey" | "hockey-pvp" | "blockbreaker" | "aether-assault" | "bosses"; + +export interface ProfileSectionDefinition { + id: ProfileSectionId; + label: string; + copy: string; + icon: string; + statIds: readonly ProfileStatId[]; +} + +export const PROFILE_SECTIONS: readonly ProfileSectionDefinition[] = [ + { id: "roguelike", label: "Roguelike", copy: "Highest completed round", icon: "∞", statIds: ["roguelike"] }, + { id: "rogue-trials", label: "Trials Endless", copy: "Best endless boss run", icon: "Ⅲ", statIds: ["rogue-trials-endless"] }, + { id: "hockey", label: "Hockey", copy: "Returns and survival", icon: "◌", statIds: ["hockey-healing"] }, + { id: "hockey-pvp", label: "Hockey PVP", copy: "Record and race kills", icon: "◇", statIds: ["hockey-pvp-wins", "hockey-pvp-boss-kills"] }, + { id: "blockbreaker", label: "Blockbreaker", copy: "Score, bricks, survival", icon: "▦", statIds: ["blockbreaker-score", "blockbreaker-bricks", "blockbreaker-time"] }, + { id: "aether-assault", label: "Aether Assault", copy: "Score, wave, survival", icon: "⌁", statIds: ["aether-assault"] }, + { id: "bosses", label: "Bosses", copy: "Kills and boss pets", icon: "♛", statIds: [] }, +] as const; + +const BOSS_IDS = new Set(AVAILABLE_BOSS_IDS); + +export function isBossProfileStat(statId: ProfileStatId): statId is BossId { + return BOSS_IDS.has(statId); +} + +export function profileSectionForStat(statId: ProfileStatId): ProfileSectionId { + if (isBossProfileStat(statId)) return "bosses"; + return PROFILE_SECTIONS.find((section) => section.statIds.includes(statId))?.id ?? "roguelike"; +} + +export function alphabeticalBosses(groups: readonly { bosses: readonly GroupBossCollection[] }[]): GroupBossCollection[] { + return groups + .flatMap((group) => group.bosses) + .sort((left, right) => left.bossName.localeCompare(right.bossName)); +} + +export function defaultStatForSection(sectionId: ProfileSectionId, bosses: readonly GroupBossCollection[]): ProfileStatId { + if (sectionId === "bosses") return bosses[0]?.bossId ?? "roguelike"; + return PROFILE_SECTIONS.find((section) => section.id === sectionId)?.statIds[0] ?? "roguelike"; +} diff --git a/src/frontend/saveRepository.test.ts b/src/frontend/saveRepository.test.ts index 5cf87e1..ce070ff 100644 --- a/src/frontend/saveRepository.test.ts +++ b/src/frontend/saveRepository.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { SaveRepository, type StorageAdapter } from "./saveRepository"; import { groupDrop } from "../game/progression/loot"; import { RUN_BUFF_ORDER } from "../game/roguelike"; +import { HEALER_VISUAL_PROFILES } from "../game/healerVisuals"; function memoryStorage(): StorageAdapter { const data = new Map(); @@ -45,10 +46,22 @@ describe("SaveRepository", () => { const repository = new SaveRepository(memoryStorage(), () => now); const serverSave = repository.create(1, "Aelia"); serverSave.healers.priest.level = 40; + const legacyStats = structuredClone(serverSave.stats) as unknown as Record; + delete legacyStats.highestAetherAssaultScore; + delete legacyStats.highestAetherAssaultWaveAtBest; + delete legacyStats.longestAetherAssaultSecondsAtBest; now = "2026-07-10T14:00:00.000Z"; - repository.replaceLocal(serverSave); + repository.replaceLocal({ ...serverSave, schemaVersion: 5, stats: legacyStats } as never); expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40); expect(repository.listLocal()[0].local?.updatedAt).toBe(now); + expect(repository.listLocal()[0].local).toMatchObject({ + schemaVersion: 6, + stats: { + highestAetherAssaultScore: 0, + highestAetherAssaultWaveAtBest: 0, + longestAetherAssaultSecondsAtBest: 0, + }, + }); }); it("deletes the local copy without inventing an online record", () => { @@ -68,7 +81,11 @@ describe("SaveRepository", () => { activeClassId: "druid", healers: { ...save.healers, - druid: { level: 8, inventory: [...save.healers.druid.inventory, { ...save.healers.druid.inventory[0], id: "druid-drop" }] }, + druid: { + ...save.healers.druid, + level: 8, + inventory: [...save.healers.druid.inventory, { ...save.healers.druid.inventory[0], id: "druid-drop" }], + }, }, })); @@ -81,7 +98,95 @@ describe("SaveRepository", () => { expect(save.healers.priest.inventory).toHaveLength(4); }); - it("resets every legacy save into fresh v5 progression while preserving identity and timestamp", () => { + it("adds default appearances to existing v6 saves without resetting progression or timestamps", () => { + const storage = memoryStorage(); + const repository = new SaveRepository(storage, () => "2026-07-16T12:00:00.000Z"); + const created = repository.create(1, "Veteran"); + const legacy = structuredClone(created) as unknown as { + updatedAt: string; + healers: Record; + }; + legacy.healers.priest.level = 37; + legacy.updatedAt = "2026-07-15T09:30:00.000Z"; + for (const healer of Object.values(legacy.healers)) delete healer.appearance; + storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy })); + + const migrated = repository.listLocal()[0].local!; + expect(migrated.schemaVersion).toBe(6); + expect(migrated.updatedAt).toBe("2026-07-15T09:30:00.000Z"); + expect(migrated.healers.priest.level).toBe(37); + for (const [classId, profile] of Object.entries(HEALER_VISUAL_PROFILES)) { + expect(migrated.healers[classId as keyof typeof migrated.healers].appearance).toEqual(profile.appearance); + } + const persisted = JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}") as Record; + expect(persisted["1"].updatedAt).toBe("2026-07-15T09:30:00.000Z"); + expect(persisted["1"].healers.paladin.appearance).toEqual(HEALER_VISUAL_PROFILES.paladin.appearance); + }); + + it("repairs corrupt appearance fields without resetting safe choices or another healer", () => { + const storage = memoryStorage(); + const repository = new SaveRepository(storage, () => "2026-07-16T12:00:00.000Z"); + const created = repository.create(1, "Mixer"); + const druidAppearance = structuredClone(created.healers.druid.appearance); + const corrupt = structuredClone(created) as unknown as Record; + const corruptHealers = (corrupt.healers as Record>); + corruptHealers.priest.level = 22; + corruptHealers.priest.appearance = { + version: 1, + rigId: "medium", + scaleSourceMemberId: "unknown-member", + headPartId: "knight-upper", + upperBodyPartId: "knight-upper", + lowerBodyPartId: "unknown-lower", + headwearPartId: "druid-backpack", + backPartId: "ranger-cape", + mainHand: { modelId: "sword", grip: "staff" }, + offHand: { modelId: "unknown-weapon", grip: "prop" }, + }; + storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: corrupt })); + + const normalized = repository.listLocal()[0].local!; + expect(normalized.healers.priest.level).toBe(22); + expect(normalized.healers.priest.appearance).toEqual({ + ...HEALER_VISUAL_PROFILES.priest.appearance, + upperBodyPartId: "knight-upper", + backPartId: "ranger-cape", + mainHand: { modelId: "cc/adv_sword_1handed", grip: "upright" }, + }); + expect(normalized.healers.priest.appearance.offHand).toBeUndefined(); + expect(normalized.healers.druid.appearance).toEqual(druidAppearance); + }); + + it("round-trips and deep-copies a valid mixed appearance", () => { + const repository = new SaveRepository(memoryStorage(), () => "2026-07-16T12:00:00.000Z"); + repository.create(1, "Mixer"); + repository.updateLocal(1, (save) => ({ + ...save, + healers: { + ...save.healers, + priest: { + ...save.healers.priest, + appearance: { + ...save.healers.priest.appearance, + headPartId: "rogue-head", + upperBodyPartId: "knight-upper", + lowerBodyPartId: "ranger-lower", + headwearPartId: "mage-hat", + backPartId: "druid-backpack", + mainHand: { modelId: "cc/wand_b", grip: "wand" }, + offHand: { modelId: "cc/spellbook_open", grip: "prop" }, + }, + }, + }, + })); + repository.copyLocal(1, 2); + const [source, copy] = repository.listLocal().map((slot) => slot.local); + expect(copy?.healers.priest.appearance).toEqual(source?.healers.priest.appearance); + expect(copy?.healers.priest.appearance).not.toBe(source?.healers.priest.appearance); + expect(copy?.healers.priest.appearance.mainHand).not.toBe(source?.healers.priest.appearance.mainHand); + }); + + it("resets every legacy save into fresh v6 progression while preserving identity and timestamp", () => { const storage = memoryStorage(); const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); const created = repository.create(1, "Legacy"); @@ -103,16 +208,35 @@ describe("SaveRepository", () => { storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy })); const migrated = repository.listLocal()[0].local!; - expect(migrated.schemaVersion).toBe(5); + expect(migrated.schemaVersion).toBe(6); expect(migrated.hunterName).toBe("Legacy"); expect(migrated.activeClassId).toBe("priest"); expect(migrated.playSeconds).toBe(0); expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true); - expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0, highestRogueTrialsEndlessKills: 0 }); + expect(migrated.stats).toEqual({ + totalBossKills: 0, + flawlessClears: 0, + alliesSaved: 0, + healingDone: 0, + bossKills: {}, + highestRoguelikeRound: 0, + highestRogueTrialsEndlessKills: 0, + highestHockeyHealingReturns: 0, + longestHockeyHealingSecondsAtBest: 0, + hockeyHealingPvpWins: 0, + hockeyHealingPvpLosses: 0, + hockeyHealingPvpBossKills: 0, + highestBlockbreakerBricks: 0, + longestBlockbreakerSeconds: 0, + highestBlockbreakerScore: 0, + highestAetherAssaultScore: 0, + highestAetherAssaultWaveAtBest: 0, + longestAetherAssaultSecondsAtBest: 0, + }); expect(migrated.materials).toEqual([]); expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} }); expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true); - expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(5); + expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(6); }); it("preserves valid v5 progression and group-drop inventory", () => { @@ -123,15 +247,31 @@ describe("SaveRepository", () => { created.healers.priest.level = 8; created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } }; created.stats.highestRogueTrialsEndlessKills = 14; + created.stats.highestHockeyHealingReturns = 31; + created.stats.longestHockeyHealingSecondsAtBest = 188.5; + created.stats.highestBlockbreakerBricks = 52; + created.stats.longestBlockbreakerSeconds = 245.25; + created.stats.highestBlockbreakerScore = 9_800; + created.stats.highestAetherAssaultScore = 12_400; + created.stats.highestAetherAssaultWaveAtBest = 7; + created.stats.longestAetherAssaultSecondsAtBest = 191.5; created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }]; created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } }; - storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); + storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } })); const migrated = repository.listLocal()[0].local!; - expect(migrated.schemaVersion).toBe(5); + expect(migrated.schemaVersion).toBe(6); expect(migrated.healers.priest.level).toBe(8); expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 }); expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14); + expect(migrated.stats.highestHockeyHealingReturns).toBe(31); + expect(migrated.stats.longestHockeyHealingSecondsAtBest).toBe(188.5); + expect(migrated.stats.highestBlockbreakerBricks).toBe(52); + expect(migrated.stats.longestBlockbreakerSeconds).toBe(245.25); + expect(migrated.stats.highestBlockbreakerScore).toBe(9_800); + expect(migrated.stats.highestAetherAssaultScore).toBe(12_400); + expect(migrated.stats.highestAetherAssaultWaveAtBest).toBe(7); + expect(migrated.stats.longestAetherAssaultSecondsAtBest).toBe(191.5); expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 }); expect(migrated.collectionLog).toEqual(created.collectionLog); }); @@ -145,10 +285,10 @@ describe("SaveRepository", () => { created.gearProgress.druid.passiveInfusionId = "mend-echo"; created.gearProgress.brann.infusionAbilityId = "removed-infusion"; created.gearProgress.brann.passiveInfusionId = "deep-wells" as never; - storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); + storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } })); const migrated = repository.listLocal()[0].local!; - expect(migrated.schemaVersion).toBe(5); + expect(migrated.schemaVersion).toBe(6); expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary"); expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull(); expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo"); diff --git a/src/frontend/saveRepository.ts b/src/frontend/saveRepository.ts index f4fdddc..73cfc41 100644 --- a/src/frontend/saveRepository.ts +++ b/src/frontend/saveRepository.ts @@ -1,10 +1,11 @@ import { createHunterSave } from "./data"; -import { createClassInventory } from "../game/healers"; +import { createClassInventory, HEALER_CLASS_ORDER } from "../game/healers"; import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } from "../game/bossCatalog"; import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL, type GearProgress } from "../game/progression/gear"; import { normalizeActiveInfusionId, normalizePassiveInfusionId } from "../game/progression/infusions"; import { GROUP_DROP_TABLES, type CollectionLog, type MaterialStack } from "../game/progression/loot"; import type { BossId, HealerClassId } from "../game/types"; +import { normalizeHealerAppearance } from "../game/healerVisuals"; import type { HunterSave, SaveSlotId, SaveSlotState } from "./types"; export interface StorageAdapter { @@ -48,7 +49,7 @@ interface LegacyHunterSave { gearProgress?: GearProgress; } -const HEALER_IDS: HealerClassId[] = ["priest", "druid", "shaman"]; +const HEALER_IDS: readonly HealerClassId[] = HEALER_CLASS_ORDER; function positiveCounts(value: unknown): Record { if (!value || typeof value !== "object") return {}; @@ -121,7 +122,7 @@ function normalizeSave(value: unknown): HunterSave | null { if (!value || typeof value !== "object") return null; const candidate = value as LegacyHunterSave; if (!candidate.slotId || !candidate.hunterName) return null; - if (candidate.schemaVersion !== 5) { + if (candidate.schemaVersion !== 5 && candidate.schemaVersion !== 6) { try { return createHunterSave(candidate.slotId, typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), candidate.hunterName); } catch { @@ -132,13 +133,14 @@ function normalizeSave(value: unknown): HunterSave | null { const bossKills = normalizeBossKills(candidate.stats?.bossKills); const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest"; return { - schemaVersion: 5, + schemaVersion: 6, slotId: candidate.slotId, hunterName: candidate.hunterName, activeClassId, healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, { level: Math.max(1, candidate.healers?.[classId]?.level ?? (classId === "priest" ? candidate.level ?? 1 : 1)), inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId), + appearance: normalizeHealerAppearance(classId, candidate.healers?.[classId]?.appearance), }])) as HunterSave["healers"], location: candidate.location ?? "Ember Vault Approach", playSeconds: Math.max(0, candidate.playSeconds ?? 0), @@ -151,6 +153,17 @@ function normalizeSave(value: unknown): HunterSave | null { bossKills, highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)), highestRogueTrialsEndlessKills: Math.max(0, Math.floor(candidate.stats?.highestRogueTrialsEndlessKills ?? 0)), + highestHockeyHealingReturns: Math.max(0, Math.floor(candidate.stats?.highestHockeyHealingReturns ?? 0)), + longestHockeyHealingSecondsAtBest: Math.max(0, Number(candidate.stats?.longestHockeyHealingSecondsAtBest) || 0), + hockeyHealingPvpWins: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpWins ?? 0)), + hockeyHealingPvpLosses: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpLosses ?? 0)), + hockeyHealingPvpBossKills: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpBossKills ?? 0)), + highestBlockbreakerBricks: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerBricks ?? 0)), + longestBlockbreakerSeconds: Math.max(0, Number(candidate.stats?.longestBlockbreakerSeconds) || 0), + highestBlockbreakerScore: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerScore ?? 0)), + highestAetherAssaultScore: Math.max(0, Math.floor(candidate.stats?.highestAetherAssaultScore ?? 0)), + highestAetherAssaultWaveAtBest: Math.max(0, Math.floor(candidate.stats?.highestAetherAssaultWaveAtBest ?? 0)), + longestAetherAssaultSecondsAtBest: Math.max(0, Number(candidate.stats?.longestAetherAssaultSecondsAtBest) || 0), }, materials: normalizeMaterials(candidate.materials, collectionLog), collectionLog, @@ -224,7 +237,7 @@ export class SaveRepository { } replaceLocal(save: HunterSave): HunterSave { - const normalized = { ...cloneSave(save), updatedAt: this.now() }; + const normalized = { ...(normalizeSave(save) ?? cloneSave(save)), updatedAt: this.now() }; this.setLocal(normalized); return normalized; } diff --git a/src/frontend/saveSync.test.ts b/src/frontend/saveSync.test.ts new file mode 100644 index 0000000..9fab8ba --- /dev/null +++ b/src/frontend/saveSync.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { PendingSaveSyncRepository, SAVE_SYNC_RETRY_DELAYS_MS, SaveSyncRetryCoordinator } from "./saveSync"; + +function memoryStorage(initial = "[]") { + const values = new Map([["i-want-to-heal:saves:pending-sync:v1", initial]]); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + }; +} + +describe("pending save sync", () => { + it("persists unique dirty slots and tolerates corrupt state", () => { + const storage = memoryStorage(); + const pending = new PendingSaveSyncRepository(storage); + pending.mark(2); + pending.mark(2); + pending.mark(1); + expect(pending.list()).toEqual([2, 1]); + pending.clear(2); + expect(pending.list()).toEqual([1]); + expect(new PendingSaveSyncRepository(memoryStorage("not-json")).list()).toEqual([]); + }); + + it("waits offline and caps automatic retries", async () => { + const pending = new PendingSaveSyncRepository(memoryStorage()); + pending.mark(1); + let online = false; + const uploads: number[] = []; + const tasks: Array<{ run: () => void; delay: number }> = []; + const coordinator = new SaveSyncRetryCoordinator( + pending, + async (slotId) => { uploads.push(slotId); return false; }, + () => online, + (run, delay) => { + tasks.push({ run, delay }); + return tasks.length as unknown as ReturnType; + }, + () => undefined, + ); + + coordinator.flush(); + expect(uploads).toEqual([]); + online = true; + coordinator.flush(true); + await Promise.resolve(); + expect(uploads).toEqual([1]); + + for (const delay of SAVE_SYNC_RETRY_DELAYS_MS) { + coordinator.failed(1); + expect(tasks[0]?.delay).toBe(delay); + tasks.shift()!.run(); + await Promise.resolve(); + } + coordinator.failed(1); + expect(tasks).toEqual([]); + expect(uploads).toHaveLength(1 + SAVE_SYNC_RETRY_DELAYS_MS.length); + coordinator.dispose(); + }); +}); diff --git a/src/frontend/saveSync.ts b/src/frontend/saveSync.ts new file mode 100644 index 0000000..bd7ce35 --- /dev/null +++ b/src/frontend/saveSync.ts @@ -0,0 +1,153 @@ +import type { SaveSlotId } from "./types"; + +interface StorageAdapter { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +type TimerHandle = ReturnType; +type UploadSave = (slotId: SaveSlotId) => Promise; + +const PENDING_KEY = "i-want-to-heal:saves:pending-sync:v1"; +export const SAVE_SYNC_RETRY_DELAYS_MS = [2_000, 10_000, 30_000, 120_000] as const; +const fallbackMemory = new Map(); +const fallbackStorage: StorageAdapter = { + getItem: (key) => fallbackMemory.get(key) ?? null, + setItem: (key, value) => { fallbackMemory.set(key, value); }, +}; + +function browserStorage(): StorageAdapter { + try { + if (typeof localStorage !== "undefined") return localStorage; + } catch { + // Android WebView can deny storage before its host is ready. + } + return fallbackStorage; +} + +function isSlotId(value: number): value is SaveSlotId { + return value === 1 || value === 2 || value === 3; +} + +export function networkAppearsOnline() { + return typeof navigator === "undefined" || navigator.onLine !== false; +} + +export class PendingSaveSyncRepository { + constructor(private readonly storage: StorageAdapter = browserStorage()) {} + + list(): SaveSlotId[] { + try { + const parsed = JSON.parse(this.storage.getItem(PENDING_KEY) ?? "[]") as unknown; + if (!Array.isArray(parsed)) return []; + return [...new Set(parsed.map(Number).filter(isSlotId))]; + } catch { + return []; + } + } + + has(slotId: SaveSlotId) { + return this.list().includes(slotId); + } + + mark(slotId: SaveSlotId) { + const slots = this.list(); + if (!slots.includes(slotId)) this.write([...slots, slotId]); + } + + clear(slotId: SaveSlotId) { + this.write(this.list().filter((candidate) => candidate !== slotId)); + } + + private write(slots: readonly SaveSlotId[]) { + this.storage.setItem(PENDING_KEY, JSON.stringify(slots)); + } +} + +export class SaveSyncRetryCoordinator { + private readonly attempts = new Map(); + private readonly timers = new Map(); + private readonly active = new Set(); + private disposed = false; + + constructor( + private readonly pending: PendingSaveSyncRepository, + private readonly upload: UploadSave, + private readonly isOnline: () => boolean = networkAppearsOnline, + private readonly schedule: (run: () => void, delay: number) => TimerHandle = setTimeout, + private readonly cancel: (timer: TimerHandle) => void = clearTimeout, + ) {} + + flush(resetAttempts = false) { + if (resetAttempts) this.attempts.clear(); + if (!this.isOnline()) return; + for (const slotId of this.pending.list()) this.run(slotId); + } + + failed(slotId: SaveSlotId) { + if (this.disposed || !this.pending.has(slotId) || !this.isOnline() || this.timers.has(slotId)) return; + const attempt = this.attempts.get(slotId) ?? 0; + const delay = SAVE_SYNC_RETRY_DELAYS_MS[attempt]; + if (delay === undefined) return; + this.attempts.set(slotId, attempt + 1); + const timer = this.schedule(() => { + this.timers.delete(slotId); + this.run(slotId); + }, delay); + this.timers.set(slotId, timer); + } + + succeeded(slotId: SaveSlotId) { + this.attempts.delete(slotId); + const timer = this.timers.get(slotId); + if (timer !== undefined) this.cancel(timer); + this.timers.delete(slotId); + } + + dispose() { + this.disposed = true; + for (const timer of this.timers.values()) this.cancel(timer); + this.timers.clear(); + this.active.clear(); + } + + private run(slotId: SaveSlotId) { + if (this.disposed || this.active.has(slotId) || this.timers.has(slotId) || !this.pending.has(slotId) || !this.isOnline()) return; + this.active.add(slotId); + void this.upload(slotId).finally(() => this.active.delete(slotId)); + } +} + +const pendingSaveSync = new PendingSaveSyncRepository(); +let activeCoordinator: SaveSyncRetryCoordinator | null = null; + +export function markSaveSyncPending(slotId: SaveSlotId) { + pendingSaveSync.mark(slotId); +} + +export function hasPendingSaveSync(slotId: SaveSlotId) { + return pendingSaveSync.has(slotId); +} + +export function clearSaveSyncPending(slotId: SaveSlotId) { + pendingSaveSync.clear(slotId); + activeCoordinator?.succeeded(slotId); +} + +export function scheduleSaveSyncRetry(slotId: SaveSlotId) { + activeCoordinator?.failed(slotId); +} + +export function startSaveSyncCoordinator(upload: UploadSave) { + const coordinator = new SaveSyncRetryCoordinator(pendingSaveSync, upload); + activeCoordinator?.dispose(); + activeCoordinator = coordinator; + const onOnline = () => coordinator.flush(true); + window.addEventListener("online", onOnline); + coordinator.flush(); + return () => { + window.removeEventListener("online", onOnline); + coordinator.dispose(); + if (activeCoordinator === coordinator) activeCoordinator = null; + }; +} diff --git a/src/frontend/store.ts b/src/frontend/store.ts index ba9fc03..9923471 100644 --- a/src/frontend/store.ts +++ b/src/frontend/store.ts @@ -2,9 +2,27 @@ import { create } from "zustand"; import { DEFAULT_SETTINGS, normalizeHunterName } from "./data"; import { SaveRepository } from "./saveRepository"; import { AccountRepository, type AccountResult } from "./accountRepository"; -import { onlineRepository, type OnlineSaveSlot } from "./onlineRepository"; -import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types"; -import type { AbilityId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types"; +import { OnlineApiError, onlineRepository, type OnlineSaveSlot } from "./onlineRepository"; +import { leaderboardCache } from "./leaderboardCache"; +import { + clearSaveSyncPending, + markSaveSyncPending, + networkAppearsOnline, + scheduleSaveSyncRetry, +} from "./saveSync"; +import type { + AppScreen, + GameModeId, + GameSettings, + HunterSave, + ProfileCollectionView, + ProfileStatId, + SaveSlotId, + SaveSlotState, +} from "./types"; +import type { BossGroupId } from "../game/bossCatalog"; +import type { AbilitySlotId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types"; +import { HEALER_CLASS_ORDER } from "../game/healers"; import { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike"; import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear"; import { @@ -13,20 +31,27 @@ import { infusionsForOwner, } from "../game/progression/infusions"; import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot"; -import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats"; +import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch } from "../game/progression/hunterStats"; +import { + cloneCharacterAppearance, + type CharacterAppearanceV1, + type CharacterModelMode, +} from "../game/characterAppearance"; +import { createDefaultHealerAppearance, normalizeHealerAppearance } from "../game/healerVisuals"; const repository = new SaveRepository(); const accounts = new AccountRepository(); const SETTINGS_KEY = "i-want-to-heal:settings:v1"; -const onlineSaveQueues = new Map>(); +const onlineSaveQueues = new Map }>(); function writeServerSaveSerially(save: HunterSave): Promise { const previous = onlineSaveQueues.get(save.slotId); - const next = (previous ? previous.catch(() => save) : Promise.resolve(save)) + if (previous?.updatedAt === save.updatedAt) return previous.promise; + const next = (previous ? previous.promise.catch(() => save) : Promise.resolve(save)) .then(() => onlineRepository.writeSave(save)); - onlineSaveQueues.set(save.slotId, next); + onlineSaveQueues.set(save.slotId, { updatedAt: save.updatedAt, promise: next }); void next.finally(() => { - if (onlineSaveQueues.get(save.slotId) === next) onlineSaveQueues.delete(save.slotId); + if (onlineSaveQueues.get(save.slotId)?.promise === next) onlineSaveQueues.delete(save.slotId); }).catch(() => undefined); return next; } @@ -76,6 +101,16 @@ function replaceOnlineSlot(current: readonly SaveSlotState[], save: HunterSave): return refreshLocalSlots(current).map((slot) => slot.id === save.slotId ? { ...slot, online: save } : slot); } +export type AppearancePreviewAnimation = "idle" | "walk" | "cast"; +export type AppearanceDrafts = Record; + +function appearanceDraftsFor(save: HunterSave | null): AppearanceDrafts { + return Object.fromEntries(HEALER_CLASS_ORDER.map((classId) => [ + classId, + cloneCharacterAppearance(save?.healers[classId].appearance ?? createDefaultHealerAppearance(classId)), + ])) as AppearanceDrafts; +} + export interface FrontendState { screen: AppScreen; accountId: string | null; @@ -89,8 +124,15 @@ export interface FrontendState { selectedGearSlotId: GearSlotId; gearWorkshopMode: "upgrade" | "infusion"; selectedInfusionId: string; - selectedPassiveAbilityId: AbilityId; + selectedPassiveAbilityId: AbilitySlotId; selectedPassiveInfusionId: RunBuffId; + profileCollectionView: ProfileCollectionView; + selectedProfileGroupId: BossGroupId; + selectedProfileStatId: ProfileStatId; + appearanceClassId: HealerClassId; + appearanceDrafts: AppearanceDrafts; + previewMode: CharacterModelMode; + previewAnimation: AppearancePreviewAnimation; recentRewards: BossRewardAward[]; settings: GameSettings; notice: string; @@ -105,7 +147,7 @@ export interface FrontendState { playSlot: (slotId: SaveSlotId) => void; deleteSlot: (slotId: SaveSlotId) => void; copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void; - uploadSlot: (slotId: SaveSlotId) => Promise; + uploadSlot: (slotId: SaveSlotId) => Promise; downloadSlot: (slotId: SaveSlotId) => Promise; selectMode: (mode: GameModeId) => void; selectBoss: (bossId: BossId) => void; @@ -114,8 +156,19 @@ export interface FrontendState { selectGearSlot: (slotId: GearSlotId) => void; selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void; selectInfusion: (infusionId: string) => void; - selectPassiveAbility: (abilityId: AbilityId) => void; + selectPassiveAbility: (abilityId: AbilitySlotId) => void; selectPassiveInfusion: (passiveId: RunBuffId) => void; + selectProfileCollectionView: (view: ProfileCollectionView) => void; + selectProfileGroup: (groupId: BossGroupId) => void; + selectProfileStat: (statId: ProfileStatId) => void; + openAppearanceLab: () => void; + selectAppearanceClass: (classId: HealerClassId) => void; + updateAppearanceDraft: (appearance: CharacterAppearanceV1) => void; + resetAppearanceDraft: () => void; + saveAppearanceDraft: () => boolean; + closeAppearanceLab: () => void; + setAppearancePreviewMode: (mode: CharacterModelMode) => void; + setAppearancePreviewAnimation: (animation: AppearancePreviewAnimation) => void; upgradeSelectedGear: () => boolean; equipSelectedInfusion: () => boolean; equipPassiveInfusion: (passiveId: RunBuffId) => boolean; @@ -126,6 +179,11 @@ export interface FrontendState { recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null; recordRoguelikeDefeat: (round: number) => void; recordRogueTrialsEndlessDefeat: (bossKills: number) => void; + recordHockeyHealingDefeat: (returns: number, durationSeconds: number) => void; + recordHockeyPvpResult: (won: boolean) => void; + recordHockeyPvpBossKill: () => void; + recordBlockbreakerDefeat: (bricks: number, durationSeconds: number, score: number) => void; + recordAetherAssaultDefeat: (score: number, wave: number, durationSeconds: number) => void; clearRecentRewards: () => void; clearNotice: () => void; } @@ -147,8 +205,15 @@ export const useFrontendStore = create((set, get) => ({ selectedGearSlotId: "weapon", gearWorkshopMode: "upgrade", selectedInfusionId: infusionsForOwner("priest")[0].id, - selectedPassiveAbilityId: "mend", + selectedPassiveAbilityId: "ability1", selectedPassiveInfusionId: "mend-echo", + profileCollectionView: "stats", + selectedProfileGroupId: "charge", + selectedProfileStatId: "roguelike", + appearanceClassId: "priest", + appearanceDrafts: appearanceDraftsFor(null), + previewMode: "modular", + previewAnimation: "idle", recentRewards: [], settings: loadSettings(), notice: "", @@ -200,7 +265,15 @@ export const useFrontendStore = create((set, get) => ({ void accounts.logout(); set({ accountId: null, slots: repository.listLocal(), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." }); }, - navigate: (screen) => set({ screen, notice: "" }), + navigate: (screen) => set(screen === "profile" + ? { + screen, + profileCollectionView: "stats", + selectedProfileGroupId: "charge", + selectedProfileStatId: "roguelike", + notice: "", + } + : { screen, notice: "" }), selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }), createSlot: (slotId, rawHunterName) => { const hunterName = normalizeHunterName(rawHunterName); @@ -209,6 +282,7 @@ export const useFrontendStore = create((set, get) => ({ return false; } repository.create(slotId, hunterName); + leaderboardCache.clearSlot(slotId); set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` })); return true; }, @@ -219,6 +293,8 @@ export const useFrontendStore = create((set, get) => ({ }, deleteSlot: (slotId) => { repository.deleteLocal(slotId); + leaderboardCache.clearSlot(slotId); + clearSaveSyncPending(slotId); set((state) => ({ slots: refreshLocalSlots(state.slots), activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId, @@ -228,30 +304,48 @@ export const useFrontendStore = create((set, get) => ({ copySlot: (sourceId, targetId) => { const copy = repository.copyLocal(sourceId, targetId); if (!copy) return; + leaderboardCache.clearSlot(targetId); set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` })); }, uploadSlot: async (slotId) => { const { accountId } = get(); - if (!accountId) return set({ notice: "Sign in before syncing online." }); + if (!accountId) { + set({ notice: "Sign in before syncing online." }); + return false; + } const local = repository.listLocal().find((slot) => slot.id === slotId)?.local; - if (!local) return set({ notice: "No offline save to sync." }); + if (!local) { + set({ notice: "No offline save to sync." }); + return false; + } + markSaveSyncPending(slotId); + if (!networkAppearsOnline()) { + set({ notice: `Slot ${slotId} saved locally. Online sync waits for connection.` }); + return false; + } try { const uploaded = await writeServerSaveSerially(local); if (get().accountId === accountId) { set((state) => ({ slots: replaceOnlineSlot(state.slots, uploaded), notice: `Slot ${slotId} synced to TrueNAS.` })); } + clearSaveSyncPending(slotId); + return true; } catch (error) { + const retryable = !(error instanceof OnlineApiError) || error.status === 0 || error.status >= 500; + if (retryable) scheduleSaveSyncRetry(slotId); set({ notice: error instanceof Error ? error.message : "Save upload failed." }); + return false; } }, downloadSlot: async (slotId) => { const { accountId } = get(); if (!accountId) return set({ notice: "Sign in before downloading an online save." }); try { - await onlineSaveQueues.get(slotId)?.catch(() => undefined); + await onlineSaveQueues.get(slotId)?.promise.catch(() => undefined); const serverSave = await onlineRepository.readSave(slotId); if (!serverSave) return set({ notice: "No online version exists for this slot." }); const downloaded = repository.replaceLocal(serverSave); + clearSaveSyncPending(slotId); if (get().accountId === accountId) { set((state) => ({ slots: replaceOnlineSlot(state.slots, downloaded), notice: `Slot ${slotId} downloaded from TrueNAS.` })); } @@ -271,14 +365,88 @@ export const useFrontendStore = create((set, get) => ({ selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }), selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }), selectPassiveAbility: (selectedPassiveAbilityId) => { - const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilityId === selectedPassiveAbilityId) ?? "mend-echo"; + const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilitySlotId === selectedPassiveAbilityId) ?? "mend-echo"; set({ selectedPassiveAbilityId, selectedPassiveInfusionId, notice: "" }); }, selectPassiveInfusion: (selectedPassiveInfusionId) => set({ - selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilityId, + selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilitySlotId, selectedPassiveInfusionId, notice: "", }), + selectProfileCollectionView: (profileCollectionView) => set({ profileCollectionView }), + selectProfileGroup: (selectedProfileGroupId) => set({ selectedProfileGroupId }), + selectProfileStat: (selectedProfileStatId) => set({ selectedProfileStatId }), + openAppearanceLab: () => set((state) => { + const save = activeSave(state.slots, state.activeSlotId); + return { + screen: "appearance", + appearanceClassId: save?.activeClassId ?? state.appearanceClassId, + appearanceDrafts: appearanceDraftsFor(save), + previewMode: "modular", + previewAnimation: "idle", + notice: "Appearance Lab opened. Changes remain drafts until saved.", + }; + }), + selectAppearanceClass: (appearanceClassId) => set({ appearanceClassId, notice: "" }), + updateAppearanceDraft: (appearance) => set((state) => ({ + appearanceDrafts: { + ...state.appearanceDrafts, + [state.appearanceClassId]: cloneCharacterAppearance(appearance), + }, + notice: "Preview updated. Save to keep this look.", + })), + resetAppearanceDraft: () => set((state) => ({ + appearanceDrafts: { + ...state.appearanceDrafts, + [state.appearanceClassId]: createDefaultHealerAppearance(state.appearanceClassId), + }, + notice: "Class default restored in preview. Save to keep it.", + })), + saveAppearanceDraft: () => { + const { activeSlotId, appearanceClassId, appearanceDrafts } = get(); + if (!activeSlotId) { + set({ notice: "Load a hunter save before changing appearance." }); + return false; + } + const appearance = normalizeHealerAppearance(appearanceClassId, appearanceDrafts[appearanceClassId]); + const updated = repository.updateLocal(activeSlotId, (save) => ({ + ...save, + healers: { + ...save.healers, + [appearanceClassId]: { + ...save.healers[appearanceClassId], + appearance, + }, + }, + })); + if (!updated) { + set({ notice: "Appearance could not be saved." }); + return false; + } + markSaveSyncPending(activeSlotId); + set((state) => ({ + slots: refreshLocalSlots(state.slots), + appearanceDrafts: { + ...state.appearanceDrafts, + [appearanceClassId]: cloneCharacterAppearance(appearance), + }, + notice: `${appearanceClassId[0].toUpperCase() + appearanceClassId.slice(1)} appearance saved locally.`, + })); + return true; + }, + closeAppearanceLab: () => set((state) => { + const save = activeSave(state.slots, state.activeSlotId); + return { + screen: "home", + appearanceClassId: save?.activeClassId ?? state.appearanceClassId, + appearanceDrafts: appearanceDraftsFor(save), + previewMode: "modular", + previewAnimation: "idle", + notice: "Appearance Lab closed.", + }; + }), + setAppearancePreviewMode: (previewMode) => set({ previewMode }), + setAppearancePreviewAnimation: (previewAnimation) => set({ previewAnimation }), upgradeSelectedGear: () => { const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get(); if (!activeSlotId) return false; @@ -392,6 +560,7 @@ export const useFrontendStore = create((set, get) => ({ recentRewards: awarded ? [...state.recentRewards, awarded].slice(-12) : state.recentRewards, notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.", })); + markSaveSyncPending(activeSlotId); return awarded; }, recordRoguelikeDefeat: (round) => { @@ -405,6 +574,7 @@ export const useFrontendStore = create((set, get) => ({ }, })); if (!updated) return; + markSaveSyncPending(activeSlotId); set((state) => ({ slots: refreshLocalSlots(state.slots) })); }, recordRogueTrialsEndlessDefeat: (bossKills) => { @@ -418,6 +588,118 @@ export const useFrontendStore = create((set, get) => ({ }, })); if (!updated) return; + markSaveSyncPending(activeSlotId); + set((state) => ({ slots: refreshLocalSlots(state.slots) })); + }, + recordHockeyHealingDefeat: (returns, durationSeconds) => { + const { activeSlotId } = get(); + if (!activeSlotId) return; + const updated = repository.updateLocal(activeSlotId, (save) => { + const record = bestHockeyHealingRecord( + save.stats.highestHockeyHealingReturns, + save.stats.longestHockeyHealingSecondsAtBest, + returns, + durationSeconds, + ); + return { + ...save, + stats: { + ...save.stats, + highestHockeyHealingReturns: record.returns, + longestHockeyHealingSecondsAtBest: record.durationSeconds, + }, + }; + }); + if (!updated) return; + markSaveSyncPending(activeSlotId); + set((state) => ({ slots: refreshLocalSlots(state.slots) })); + }, + recordHockeyPvpResult: (won) => { + const { activeSlotId } = get(); + if (!activeSlotId) return; + const updated = repository.updateLocal(activeSlotId, (save) => { + const record = hockeyPvpRecordAfterMatch( + save.stats.hockeyHealingPvpWins, + save.stats.hockeyHealingPvpLosses, + won, + ); + return { + ...save, + stats: { + ...save.stats, + hockeyHealingPvpWins: record.wins, + hockeyHealingPvpLosses: record.losses, + }, + }; + }); + if (!updated) return; + markSaveSyncPending(activeSlotId); + set((state) => ({ slots: refreshLocalSlots(state.slots) })); + }, + recordHockeyPvpBossKill: () => { + const { activeSlotId } = get(); + if (!activeSlotId) return; + const updated = repository.updateLocal(activeSlotId, (save) => ({ + ...save, + stats: { + ...save.stats, + hockeyHealingPvpBossKills: save.stats.hockeyHealingPvpBossKills + 1, + }, + })); + if (!updated) return; + markSaveSyncPending(activeSlotId); + set((state) => ({ slots: refreshLocalSlots(state.slots) })); + }, + recordBlockbreakerDefeat: (bricks, durationSeconds, score) => { + const { activeSlotId } = get(); + if (!activeSlotId) return; + const updated = repository.updateLocal(activeSlotId, (save) => { + const record = bestBlockbreakerRecords( + save.stats.highestBlockbreakerBricks, + save.stats.longestBlockbreakerSeconds, + save.stats.highestBlockbreakerScore, + bricks, + durationSeconds, + score, + ); + return { + ...save, + stats: { + ...save.stats, + highestBlockbreakerBricks: record.bricks, + longestBlockbreakerSeconds: record.durationSeconds, + highestBlockbreakerScore: record.score, + }, + }; + }); + if (!updated) return; + markSaveSyncPending(activeSlotId); + set((state) => ({ slots: refreshLocalSlots(state.slots) })); + }, + recordAetherAssaultDefeat: (score, wave, durationSeconds) => { + const { activeSlotId } = get(); + if (!activeSlotId) return; + const updated = repository.updateLocal(activeSlotId, (save) => { + const record = bestAetherAssaultRecord( + save.stats.highestAetherAssaultScore, + save.stats.highestAetherAssaultWaveAtBest, + save.stats.longestAetherAssaultSecondsAtBest, + score, + wave, + durationSeconds, + ); + return { + ...save, + stats: { + ...save.stats, + highestAetherAssaultScore: record.score, + highestAetherAssaultWaveAtBest: record.wave, + longestAetherAssaultSecondsAtBest: record.durationSeconds, + }, + }; + }); + if (!updated) return; + markSaveSyncPending(activeSlotId); set((state) => ({ slots: refreshLocalSlots(state.slots) })); }, clearRecentRewards: () => set({ recentRewards: [] }), @@ -447,6 +729,17 @@ export type FrontendSnapshot = Omit; @@ -485,6 +783,17 @@ export function getFrontendSnapshot(): FrontendSnapshot { selectInfusion: _selectInfusion, selectPassiveAbility: _selectPassiveAbility, selectPassiveInfusion: _selectPassiveInfusion, + selectProfileCollectionView: _selectProfileCollectionView, + selectProfileGroup: _selectProfileGroup, + selectProfileStat: _selectProfileStat, + openAppearanceLab: _openAppearanceLab, + selectAppearanceClass: _selectAppearanceClass, + updateAppearanceDraft: _updateAppearanceDraft, + resetAppearanceDraft: _resetAppearanceDraft, + saveAppearanceDraft: _saveAppearanceDraft, + closeAppearanceLab: _closeAppearanceLab, + setAppearancePreviewMode: _setAppearancePreviewMode, + setAppearancePreviewAnimation: _setAppearancePreviewAnimation, upgradeSelectedGear: _upgradeSelectedGear, equipSelectedInfusion: _equipSelectedInfusion, equipPassiveInfusion: _equipPassiveInfusion, @@ -495,6 +804,11 @@ export function getFrontendSnapshot(): FrontendSnapshot { recordBossVictory: _recordBossVictory, recordRoguelikeDefeat: _recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat, + recordHockeyHealingDefeat: _recordHockeyHealingDefeat, + recordHockeyPvpResult: _recordHockeyPvpResult, + recordHockeyPvpBossKill: _recordHockeyPvpBossKill, + recordBlockbreakerDefeat: _recordBlockbreakerDefeat, + recordAetherAssaultDefeat: _recordAetherAssaultDefeat, clearRecentRewards: _clearRecentRewards, clearNotice: _clearNotice, ...snapshot diff --git a/src/frontend/types.ts b/src/frontend/types.ts index d74ca73..ffbfe9b 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -2,10 +2,13 @@ import type { BossGroupId } from "../game/bossCatalog"; import type { BossId, HealerClassId, InventoryItem } from "../game/types"; import type { GearProgress } from "../game/progression/gear"; import type { CollectionLog, MaterialStack } from "../game/progression/loot"; +import type { CharacterAppearanceV1 } from "../game/characterAppearance"; export type SaveSlotId = 1 | 2 | 3; -export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "settings" | "mode" | "game"; -export type GameModeId = "roguelike-pve" | "rogue-trials" | "dungeons" | "roguelike-pvp" | "stadium-pvp"; +export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "appearance" | "settings" | "mode" | "game"; +export type GameModeId = "roguelike-pve" | "rogue-trials" | "dungeons" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault" | "roguelike-pvp" | "stadium-pvp"; +export type ProfileCollectionView = "loot" | "trophies" | "stats"; +export type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless" | "hockey-healing" | "hockey-pvp-wins" | "hockey-pvp-boss-kills" | "blockbreaker-bricks" | "blockbreaker-time" | "blockbreaker-score" | "aether-assault"; export interface CollectionDrop { id: string; @@ -43,15 +46,27 @@ export interface HunterStats { bossKills: Record; highestRoguelikeRound: number; highestRogueTrialsEndlessKills: number; + highestHockeyHealingReturns: number; + longestHockeyHealingSecondsAtBest: number; + hockeyHealingPvpWins: number; + hockeyHealingPvpLosses: number; + hockeyHealingPvpBossKills: number; + highestBlockbreakerBricks: number; + longestBlockbreakerSeconds: number; + highestBlockbreakerScore: number; + highestAetherAssaultScore: number; + highestAetherAssaultWaveAtBest: number; + longestAetherAssaultSecondsAtBest: number; } export interface HealerProgress { level: number; inventory: InventoryItem[]; + appearance: CharacterAppearanceV1; } export interface HunterSave { - schemaVersion: 5; + schemaVersion: 6; slotId: SaveSlotId; hunterName: string; activeClassId: HealerClassId; diff --git a/src/game/abilityLoadout.test.ts b/src/game/abilityLoadout.test.ts new file mode 100644 index 0000000..39b4a54 --- /dev/null +++ b/src/game/abilityLoadout.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createClassAbilityLoadout, HEALER_ABILITIES, HEALER_ABILITY_IDS, HEALER_CLASSES, HEALER_CLASS_ORDER, resolveSlottedAbility } from "./healers"; +import { healingEffect } from "./healerEffects"; +import { useGameStore } from "./store"; + +describe("composable healer ability loadouts", () => { + beforeEach(() => { + useGameStore.getState().configureHealer("priest", "Aelia", []); + }); + + it("projects every class kit through the flat spell registry", () => { + for (const classId of HEALER_CLASS_ORDER) { + const loadout = createClassAbilityLoadout(classId); + for (const [slotId, definition] of Object.entries(HEALER_CLASSES[classId].abilities)) { + expect(resolveSlottedAbility(loadout, slotId as keyof typeof loadout)).toBe(HEALER_ABILITIES[definition.id]); + } + } + expect(new Set(HEALER_ABILITY_IDS).size).toBe(HEALER_ABILITY_IDS.length); + expect(Object.keys(HEALER_ABILITIES)).toEqual(HEALER_ABILITY_IDS); + }); + + it("casts a spell assigned outside its native class and slot", () => { + useGameStore.getState().setAbilityLoadout({ ability1: "shaman-riptide" }); + useGameStore.getState().startEncounter(); + useGameStore.getState().selectMember("brann"); + + expect(useGameStore.getState().castAbility("ability1")).toBe(true); + expect(healingEffect(useGameStore.getState().party[1], "riptide")).toBeDefined(); + }); + + it("resolves cast completion from recorded spell identity after cross-slot assignment", () => { + useGameStore.getState().setAbilityLoadout({ ability6: "druid-regrowth" }); + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + party: state.party.map((member) => member.id === "brann" ? { ...member, hp: 50 } : member), + })); + useGameStore.getState().selectMember("brann"); + + expect(useGameStore.getState().castAbility("ability6")).toBe(true); + useGameStore.getState().tick(0.51); + + const brann = useGameStore.getState().party[1]; + expect(brann.hp).toBeGreaterThan(50); + expect(healingEffect(brann, "regrowth")).toBeDefined(); + }); +}); diff --git a/src/game/aetherAssault.test.ts b/src/game/aetherAssault.test.ts new file mode 100644 index 0000000..b905e34 --- /dev/null +++ b/src/game/aetherAssault.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; +import { + AETHER_ARMORED_SCORE, + AETHER_DIVE_DAMAGE, + AETHER_ENEMY_SHOT_DAMAGE, + AETHER_MAX_ENEMY_SHOTS, + AETHER_MAX_PLAYER_SHOTS, + AETHER_MAX_SHIPS, + AETHER_STANDARD_SCORE, + advanceAetherAssault, + aetherMultiplier, + aetherShipCount, + aetherWaveClearBonus, + createAetherAssaultState, +} from "./aetherAssault"; + +describe("Aether Assault", () => { + it("creates deterministic eight-ship opening formations", () => { + const first = createAetherAssaultState(true, 42); + const second = createAetherAssaultState(true, 42); + expect(first.ships).toEqual(second.ships); + expect(first.ships).toHaveLength(8); + expect(first.wave).toBe(1); + }); + + it("grows formations by two ships and caps them at twenty", () => { + expect(aetherShipCount(1)).toBe(8); + expect(aetherShipCount(4)).toBe(14); + expect(aetherShipCount(99)).toBe(AETHER_MAX_SHIPS); + }); + + it("fires five fixed-forward player shots per second only while enabled", () => { + let state = createAetherAssaultState(true, 7); + state = advanceAetherAssault(state, { + delta: 0.8, + time: 0.8, + playerPosition: [3, 8], + canAutoFire: true, + }).state; + expect(state.playerShots.length).toBe(5); + expect(state.playerShots.every((shot) => shot.velocity[0] === 0 && shot.velocity[1] < 0)).toBe(true); + + const paused = advanceAetherAssault(state, { + delta: 1, + time: 1.8, + playerPosition: [3, 8], + canAutoFire: false, + }).state; + expect(paused.playerShots.length).toBeLessThanOrEqual(state.playerShots.length); + }); + + it("awards kills, streak multiplier, armored points, and wave bonuses", () => { + expect(aetherMultiplier(0)).toBe(1); + expect(aetherMultiplier(5)).toBe(1.25); + expect(aetherMultiplier(100)).toBe(5); + expect(AETHER_STANDARD_SCORE).toBe(100); + expect(AETHER_ARMORED_SCORE).toBe(250); + expect(aetherWaveClearBonus(3)).toBe(2250); + }); + + it("applies the fifth-kill multiplier to an armored kill", () => { + const state = createAetherAssaultState(true, 17); + const ship = state.ships[0]; + ship.kind = "armored"; + ship.hp = 1; + ship.maxHp = 3; + ship.phase = "formation"; + ship.phaseStartedAt = 0; + ship.position = [0, -5]; + ship.formationPosition = [0, -5]; + state.ships = [ship]; + state.killStreak = 4; + state.multiplier = 1; + state.playerShots = [{ id: 99, position: [0, 5], velocity: [0, -18] }]; + state.nextPlayerShotAt = 10; + + const next = advanceAetherAssault(state, { + delta: 0.75, + time: 1, + playerPosition: [4, 8], + canAutoFire: false, + }).state; + expect(next.killStreak).toBe(5); + expect(next.multiplier).toBe(1.25); + expect(next.lastKillScore).toBe(Math.round(AETHER_ARMORED_SCORE * 1.25)); + expect(next.score).toBe(next.lastKillScore + aetherWaveClearBonus(1)); + }); + + it("uses segment collision for fast player projectiles", () => { + const state = createAetherAssaultState(true, 9); + const ship = state.ships[0]; + ship.phase = "formation"; + ship.phaseStartedAt = 0; + ship.position = [0, -5]; + ship.formationPosition = [0, -5]; + state.ships = [ship]; + state.playerShots = [{ id: 99, position: [0, 5], velocity: [0, -18] }]; + state.nextPlayerShotAt = 10; + + const next = advanceAetherAssault(state, { + delta: 0.75, + time: 1, + playerPosition: [4, 8], + canAutoFire: false, + }).state; + expect(next.ships).toHaveLength(0); + expect(next.kills).toBe(1); + expect(next.score).toBe(AETHER_STANDARD_SCORE + aetherWaveClearBonus(1)); + }); + + it("damages only through returned player damage and resets streak with hit grace", () => { + const state = createAetherAssaultState(true, 3); + state.killStreak = 8; + state.multiplier = aetherMultiplier(8); + state.enemyShots = [ + { id: 1, position: [0, 7], velocity: [0, 5] }, + { id: 2, position: [0.1, 7], velocity: [0, 5] }, + ]; + state.nextPlayerShotAt = 10; + + const hit = advanceAetherAssault(state, { + delta: 0.4, + time: 1, + playerPosition: [0, 8], + canAutoFire: false, + }); + expect(hit.playerDamage).toBe(AETHER_ENEMY_SHOT_DAMAGE); + expect(hit.state.killStreak).toBe(0); + expect(hit.state.multiplier).toBe(1); + }); + + it("launches formation dives and resolves dive contact once", () => { + const state = createAetherAssaultState(true, 23); + const ship = state.ships[0]; + ship.phase = "formation"; + ship.phaseStartedAt = 0; + ship.position = [0, -5]; + ship.formationPosition = [0, -5]; + state.ships = [ship]; + state.nextDiveAt = 0; + state.nextEnemyShotAt = 99; + state.nextPlayerShotAt = 99; + + const launched = advanceAetherAssault(state, { + delta: 0.01, + time: 0.01, + playerPosition: [3, 8], + canAutoFire: false, + }).state; + expect(launched.ships[0].phase).toBe("diving"); + expect(launched.ships[0].targetPosition[0]).toBe(3); + + launched.ships[0].position = [0, 0]; + launched.ships[0].startPosition = [0, 0]; + launched.ships[0].targetPosition = [0, 0]; + launched.ships[0].phaseStartedAt = 0; + launched.nextDiveAt = 99; + const contact = advanceAetherAssault(launched, { + delta: 0.1, + time: 0.1, + playerPosition: [0, 0], + canAutoFire: false, + }); + expect(contact.playerDamage).toBe(AETHER_DIVE_DAMAGE); + expect(contact.state.ships[0].contactResolved).toBe(true); + }); + + it("advances to a ten-ship second wave after the clear delay", () => { + const state = createAetherAssaultState(true, 31); + const ship = state.ships[0]; + ship.phase = "formation"; + ship.phaseStartedAt = 0; + ship.position = [0, -5]; + ship.formationPosition = [0, -5]; + state.ships = [ship]; + state.playerShots = [{ id: 99, position: [0, 5], velocity: [0, -18] }]; + state.nextPlayerShotAt = 99; + state.nextEnemyShotAt = 99; + state.nextDiveAt = 99; + + const cleared = advanceAetherAssault(state, { + delta: 0.75, + time: 1, + playerPosition: [4, 8], + canAutoFire: false, + }).state; + expect(cleared.nextWaveAt).toBe(2.5); + + const next = advanceAetherAssault(cleared, { + delta: 1.6, + time: 2.6, + playerPosition: [4, 8], + canAutoFire: false, + }).state; + expect(next.wave).toBe(2); + expect(next.ships).toHaveLength(10); + expect(next.nextWaveAt).toBeNull(); + }); + + it("never exceeds projectile caps", () => { + const state = createAetherAssaultState(true, 12); + state.nextPlayerShotAt = 0; + state.nextEnemyShotAt = 0; + state.playerShots = Array.from({ length: AETHER_MAX_PLAYER_SHOTS }, (_, id) => ({ + id, + position: [9, 0] as [number, number], + velocity: [0, -18] as [number, number], + })); + state.enemyShots = Array.from({ length: AETHER_MAX_ENEMY_SHOTS }, (_, id) => ({ + id: 100 + id, + position: [9, -8] as [number, number], + velocity: [0, 5] as [number, number], + })); + + const next = advanceAetherAssault(state, { + delta: 0.01, + time: 0.01, + playerPosition: [-9, 12], + canAutoFire: true, + }).state; + expect(next.playerShots.length).toBeLessThanOrEqual(AETHER_MAX_PLAYER_SHOTS); + expect(next.enemyShots.length).toBeLessThanOrEqual(AETHER_MAX_ENEMY_SHOTS); + }); +}); diff --git a/src/game/aetherAssault.ts b/src/game/aetherAssault.ts new file mode 100644 index 0000000..b987487 --- /dev/null +++ b/src/game/aetherAssault.ts @@ -0,0 +1,432 @@ +import { + HOCKEY_ARENA_MAX_X, + HOCKEY_ARENA_MAX_Z, + HOCKEY_ARENA_MIN_X, + HOCKEY_ARENA_MIN_Z, +} from "./hockeyHealing"; +import type { WorldPosition } from "./types"; + +export type AetherAssaultStatus = "inactive" | "live"; +export type AetherShipKind = "standard" | "armored"; +export type AetherShipPhase = "entering" | "formation" | "diving" | "returning"; + +export interface AetherShip { + id: string; + kind: AetherShipKind; + hp: number; + maxHp: number; + position: WorldPosition; + formationPosition: WorldPosition; + phase: AetherShipPhase; + phaseStartedAt: number; + phaseEndsAt: number; + startPosition: WorldPosition; + targetPosition: WorldPosition; + contactResolved: boolean; +} + +export interface AetherProjectile { + id: number; + position: WorldPosition; + velocity: WorldPosition; +} + +export interface AetherAssaultState { + status: AetherAssaultStatus; + seed: number; + randomState: number; + wave: number; + score: number; + kills: number; + killStreak: number; + multiplier: number; + ships: AetherShip[]; + playerShots: AetherProjectile[]; + enemyShots: AetherProjectile[]; + nextProjectileId: number; + nextPlayerShotAt: number; + nextEnemyShotAt: number; + nextDiveAt: number; + nextWaveAt: number | null; + lastPlayerHitAt: number; + lastKillAt: number; + lastKillScore: number; +} + +export interface AetherAssaultStep { + delta: number; + time: number; + playerPosition: WorldPosition; + canAutoFire: boolean; +} + +export interface AetherAssaultAdvance { + state: AetherAssaultState; + playerDamage: number; +} + +export const AETHER_MAX_SHIPS = 20; +export const AETHER_MAX_PLAYER_SHOTS = 32; +export const AETHER_MAX_ENEMY_SHOTS = 64; +export const AETHER_PLAYER_SHOT_DAMAGE = 1; +export const AETHER_ENEMY_SHOT_DAMAGE = 10; +export const AETHER_DIVE_DAMAGE = 24; +export const AETHER_HIT_GRACE_SECONDS = 0.6; +export const AETHER_PLAYER_SHOTS_PER_SECOND = 5; +export const AETHER_WAVE_CLEAR_DELAY = 1.5; +export const AETHER_STANDARD_SCORE = 100; +export const AETHER_ARMORED_SCORE = 250; + +const PLAYER_SHOT_SPEED = 18; +const PLAYER_SHOT_RADIUS = 0.24; +const ENEMY_SHOT_RADIUS = 0.3; +const SHIP_RADIUS = 0.72; +const PLAYER_HIT_RADIUS = 0.62; +const DIVE_HIT_RADIUS = 1.05; +const ENTRY_DURATION = 1.45; +const RETURN_DURATION = 1.25; +const DIVE_DURATION = 2.15; + +function normalizeSeed(seed: number) { + const normalized = Math.floor(Number(seed)) >>> 0; + return normalized || 0x9e3779b9; +} + +function nextRandom(state: number) { + let next = normalizeSeed(state); + next ^= next << 13; + next ^= next >>> 17; + next ^= next << 5; + return next >>> 0; +} + +function randomUnit(state: number) { + const next = nextRandom(state); + return { state: next, value: next / 0x100000000 }; +} + +export function createAetherAssaultSeed(random: () => number = Math.random) { + const sample = Number(random()); + const normalized = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999999, sample)) : 0; + return normalizeSeed(Math.floor(normalized * 0x100000000)); +} + +export function aetherShipCount(wave: number) { + return Math.min(AETHER_MAX_SHIPS, 8 + Math.max(0, Math.floor(wave) - 1) * 2); +} + +export function aetherMultiplier(killStreak: number) { + return Math.min(5, 1 + Math.floor(Math.max(0, killStreak) / 5) * 0.25); +} + +export function aetherWaveClearBonus(wave: number) { + return Math.max(1, Math.floor(wave)) * 750; +} + +function formationPosition(index: number, count: number): WorldPosition { + const columns = 5; + const rows = Math.ceil(count / columns); + const row = Math.floor(index / columns); + const column = index % columns; + const itemsInRow = row === rows - 1 && count % columns !== 0 ? count % columns : columns; + const centeredColumn = column - (itemsInRow - 1) * 0.5; + return [centeredColumn * 3.35, -10.7 + row * 2.25]; +} + +function armoredCount(wave: number, count: number) { + if (wave % 3 !== 0) return 0; + return Math.min(count, 1 + Math.floor(wave / 6)); +} + +function createWave(wave: number, startsAt: number, randomState: number) { + const count = aetherShipCount(wave); + const armored = armoredCount(wave, count); + const ships: AetherShip[] = []; + let nextState = randomState; + for (let index = 0; index < count; index += 1) { + const random = randomUnit(nextState); + nextState = random.state; + const formation = formationPosition(index, count); + const entersFromLeft = index % 2 === 0; + const start: WorldPosition = [ + entersFromLeft ? HOCKEY_ARENA_MIN_X - 4 - random.value * 3 : HOCKEY_ARENA_MAX_X + 4 + random.value * 3, + HOCKEY_ARENA_MIN_Z - 2.5 - (index % 4) * 0.65, + ]; + const kind: AetherShipKind = index >= count - armored ? "armored" : "standard"; + const spawnAt = startsAt + index * 0.11; + ships.push({ + id: `${wave}:${index}`, + kind, + hp: kind === "armored" ? 3 : 1, + maxHp: kind === "armored" ? 3 : 1, + position: [...start], + formationPosition: formation, + phase: "entering", + phaseStartedAt: spawnAt, + phaseEndsAt: spawnAt + ENTRY_DURATION, + startPosition: [...start], + targetPosition: [...formation], + contactResolved: false, + }); + } + return { ships, randomState: nextState }; +} + +export function createAetherAssaultState(active = false, requestedSeed = 1): AetherAssaultState { + const seed = normalizeSeed(requestedSeed); + const wave = createWave(1, 0, seed); + return { + status: active ? "live" : "inactive", + seed, + randomState: wave.randomState, + wave: 1, + score: 0, + kills: 0, + killStreak: 0, + multiplier: 1, + ships: active ? wave.ships : [], + playerShots: [], + enemyShots: [], + nextProjectileId: 1, + nextPlayerShotAt: 0, + nextEnemyShotAt: 1.2, + nextDiveAt: 4, + nextWaveAt: null, + lastPlayerHitAt: Number.NEGATIVE_INFINITY, + lastKillAt: Number.NEGATIVE_INFINITY, + lastKillScore: 0, + }; +} + +function cloneShip(ship: AetherShip): AetherShip { + return { + ...ship, + position: [...ship.position], + formationPosition: [...ship.formationPosition], + startPosition: [...ship.startPosition], + targetPosition: [...ship.targetPosition], + }; +} + +function easeOutCubic(value: number) { + return 1 - (1 - value) ** 3; +} + +function lerpPosition(start: WorldPosition, end: WorldPosition, progress: number): WorldPosition { + return [start[0] + (end[0] - start[0]) * progress, start[1] + (end[1] - start[1]) * progress]; +} + +function divePosition(ship: AetherShip, progress: number): WorldPosition { + const controlX = ship.targetPosition[0] + Math.sign(ship.targetPosition[0] - ship.startPosition[0] || 1) * 3.2; + const controlZ = (ship.startPosition[1] + ship.targetPosition[1]) * 0.5 - 1.5; + const inverse = 1 - progress; + return [ + inverse * inverse * ship.startPosition[0] + 2 * inverse * progress * controlX + progress * progress * ship.targetPosition[0], + inverse * inverse * ship.startPosition[1] + 2 * inverse * progress * controlZ + progress * progress * ship.targetPosition[1], + ]; +} + +function segmentDistanceSquared(start: WorldPosition, end: WorldPosition, point: WorldPosition) { + const dx = end[0] - start[0]; + const dz = end[1] - start[1]; + const lengthSquared = dx * dx + dz * dz; + const projection = lengthSquared < 0.000001 + ? 0 + : Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared)); + const nearestX = start[0] + dx * projection; + const nearestZ = start[1] + dz * projection; + return (point[0] - nearestX) ** 2 + (point[1] - nearestZ) ** 2; +} + +function canDamagePlayer(state: AetherAssaultState, time: number) { + return time - state.lastPlayerHitAt >= AETHER_HIT_GRACE_SECONDS; +} + +function registerPlayerHit(state: AetherAssaultState, time: number, damage: number) { + if (!canDamagePlayer(state, time)) return 0; + state.lastPlayerHitAt = time; + state.killStreak = 0; + state.multiplier = 1; + return damage; +} + +function awardKill(state: AetherAssaultState, ship: AetherShip, time: number) { + state.kills += 1; + state.killStreak += 1; + state.multiplier = aetherMultiplier(state.killStreak); + const base = ship.kind === "armored" ? AETHER_ARMORED_SCORE : AETHER_STANDARD_SCORE; + const award = Math.round(base * state.multiplier); + state.score += award; + state.lastKillAt = time; + state.lastKillScore = award; +} + +function chooseShip(state: AetherAssaultState, candidates: readonly AetherShip[]) { + if (!candidates.length) return undefined; + const random = randomUnit(state.randomState); + state.randomState = random.state; + return candidates[Math.min(candidates.length - 1, Math.floor(random.value * candidates.length))]; +} + +function enemyFireInterval(wave: number) { + return Math.max(0.52, 1.85 - Math.max(0, wave - 1) * 0.055); +} + +function diveInterval(wave: number) { + return Math.max(1.7, 4.4 - Math.max(0, wave - 1) * 0.11); +} + +export function advanceAetherAssault(source: AetherAssaultState, step: AetherAssaultStep): AetherAssaultAdvance { + if (source.status !== "live" || step.delta <= 0) return { state: source, playerDamage: 0 }; + + const state: AetherAssaultState = { + ...source, + ships: source.ships.map(cloneShip), + playerShots: source.playerShots.map((shot) => ({ ...shot, position: [...shot.position], velocity: [...shot.velocity] })), + enemyShots: source.enemyShots.map((shot) => ({ ...shot, position: [...shot.position], velocity: [...shot.velocity] })), + }; + let playerDamage = 0; + + if (step.canAutoFire) { + while (state.nextPlayerShotAt <= step.time && state.playerShots.length < AETHER_MAX_PLAYER_SHOTS) { + state.playerShots.push({ + id: state.nextProjectileId, + position: [step.playerPosition[0], step.playerPosition[1] - 0.7], + velocity: [0, -PLAYER_SHOT_SPEED], + }); + state.nextProjectileId += 1; + state.nextPlayerShotAt += 1 / AETHER_PLAYER_SHOTS_PER_SECOND; + } + } else if (state.nextPlayerShotAt < step.time) { + state.nextPlayerShotAt = step.time; + } + + for (const ship of state.ships) { + if (step.time < ship.phaseStartedAt) continue; + const previous = [...ship.position] as WorldPosition; + if (ship.phase === "entering") { + const progress = Math.max(0, Math.min(1, (step.time - ship.phaseStartedAt) / Math.max(0.001, ship.phaseEndsAt - ship.phaseStartedAt))); + ship.position = lerpPosition(ship.startPosition, ship.formationPosition, easeOutCubic(progress)); + if (progress >= 1) { + ship.phase = "formation"; + ship.position = [...ship.formationPosition]; + } + } else if (ship.phase === "diving") { + const progress = Math.max(0, Math.min(1, (step.time - ship.phaseStartedAt) / DIVE_DURATION)); + ship.position = divePosition(ship, progress); + if (!ship.contactResolved + && segmentDistanceSquared(previous, ship.position, step.playerPosition) <= DIVE_HIT_RADIUS ** 2) { + ship.contactResolved = true; + playerDamage += registerPlayerHit(state, step.time, AETHER_DIVE_DAMAGE); + } + if (progress >= 1) { + ship.phase = "returning"; + ship.phaseStartedAt = step.time; + ship.phaseEndsAt = step.time + RETURN_DURATION; + ship.startPosition = [...ship.position]; + ship.targetPosition = [...ship.formationPosition]; + } + } else if (ship.phase === "returning") { + const progress = Math.max(0, Math.min(1, (step.time - ship.phaseStartedAt) / RETURN_DURATION)); + ship.position = lerpPosition(ship.startPosition, ship.formationPosition, easeOutCubic(progress)); + if (progress >= 1) { + ship.phase = "formation"; + ship.position = [...ship.formationPosition]; + ship.contactResolved = false; + } + } + } + + if (step.time >= state.nextDiveAt) { + const diver = chooseShip(state, state.ships.filter((ship) => ship.phase === "formation")); + if (diver) { + diver.phase = "diving"; + diver.phaseStartedAt = step.time; + diver.phaseEndsAt = step.time + DIVE_DURATION; + diver.startPosition = [...diver.position]; + diver.targetPosition = [step.playerPosition[0], HOCKEY_ARENA_MAX_Z + 1.8]; + diver.contactResolved = false; + state.nextDiveAt = step.time + diveInterval(state.wave); + } else { + state.nextDiveAt = step.time + 0.25; + } + } + + if (step.time >= state.nextEnemyShotAt && state.enemyShots.length < AETHER_MAX_ENEMY_SHOTS) { + const shooter = chooseShip(state, state.ships.filter((ship) => ship.phase === "formation" || ship.phase === "diving")); + if (shooter) { + const dx = step.playerPosition[0] - shooter.position[0]; + const dz = step.playerPosition[1] - shooter.position[1]; + const length = Math.max(0.001, Math.hypot(dx, dz)); + const speed = Math.min(8.5, 5.6 + state.wave * 0.08); + state.enemyShots.push({ + id: state.nextProjectileId, + position: [...shooter.position], + velocity: [dx / length * speed, dz / length * speed], + }); + state.nextProjectileId += 1; + state.nextEnemyShotAt = step.time + enemyFireInterval(state.wave); + } else { + state.nextEnemyShotAt = step.time + 0.25; + } + } + + const survivingPlayerShots: AetherProjectile[] = []; + for (const shot of state.playerShots) { + const start = [...shot.position] as WorldPosition; + const end: WorldPosition = [start[0] + shot.velocity[0] * step.delta, start[1] + shot.velocity[1] * step.delta]; + let hit: AetherShip | undefined; + let hitDistance = Number.POSITIVE_INFINITY; + for (const ship of state.ships) { + if (step.time < ship.phaseStartedAt) continue; + if (segmentDistanceSquared(start, end, ship.position) > (SHIP_RADIUS + PLAYER_SHOT_RADIUS) ** 2) continue; + const distance = Math.hypot(ship.position[0] - start[0], ship.position[1] - start[1]); + if (distance < hitDistance) { + hit = ship; + hitDistance = distance; + } + } + if (hit) { + hit.hp -= AETHER_PLAYER_SHOT_DAMAGE; + if (hit.hp <= 0) awardKill(state, hit, step.time); + continue; + } + shot.position = end; + if (end[1] >= HOCKEY_ARENA_MIN_Z - 3 && Math.abs(end[0]) <= HOCKEY_ARENA_MAX_X + 4) survivingPlayerShots.push(shot); + } + state.playerShots = survivingPlayerShots; + state.ships = state.ships.filter((ship) => ship.hp > 0); + + const survivingEnemyShots: AetherProjectile[] = []; + for (const shot of state.enemyShots) { + const start = [...shot.position] as WorldPosition; + const end: WorldPosition = [start[0] + shot.velocity[0] * step.delta, start[1] + shot.velocity[1] * step.delta]; + if (segmentDistanceSquared(start, end, step.playerPosition) <= (PLAYER_HIT_RADIUS + ENEMY_SHOT_RADIUS) ** 2) { + playerDamage += registerPlayerHit(state, step.time, AETHER_ENEMY_SHOT_DAMAGE); + continue; + } + shot.position = end; + if (end[1] <= HOCKEY_ARENA_MAX_Z + 3 + && end[1] >= HOCKEY_ARENA_MIN_Z - 3 + && Math.abs(end[0]) <= HOCKEY_ARENA_MAX_X + 4) { + survivingEnemyShots.push(shot); + } + } + state.enemyShots = survivingEnemyShots; + + if (state.ships.length === 0 && state.nextWaveAt === null) { + state.score += aetherWaveClearBonus(state.wave); + state.nextWaveAt = step.time + AETHER_WAVE_CLEAR_DELAY; + } + if (state.nextWaveAt !== null && step.time >= state.nextWaveAt) { + state.wave += 1; + const wave = createWave(state.wave, step.time, state.randomState); + state.ships = wave.ships; + state.randomState = wave.randomState; + state.nextWaveAt = null; + state.nextEnemyShotAt = step.time + Math.min(1.2, enemyFireInterval(state.wave)); + state.nextDiveAt = step.time + Math.min(3, diveInterval(state.wave)); + } + + return { state, playerDamage }; +} diff --git a/src/game/aetherAssaultStore.test.ts b/src/game/aetherAssaultStore.test.ts new file mode 100644 index 0000000..a6de7df --- /dev/null +++ b/src/game/aetherAssaultStore.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createClassInventory } from "./healers"; +import { HOCKEY_ARENA_MAX_Z, HOCKEY_ARENA_MIN_Z } from "./hockeyHealing"; +import { useGameStore } from "./store"; + +function disableBossPressure() { + useGameStore.setState((state) => ({ + boss: { ...state.boss, nextMeleeAt: 999 }, + additionalBosses: state.additionalBosses.map((entry) => ({ + ...entry, + boss: { ...entry.boss, nextMeleeAt: 999 }, + motion: { ...entry.motion, nextMechanicAt: 999 }, + })), + bossMotion: { ...state.bossMotion, nextMechanicAt: 999 }, + })); +} + +describe("Aether Assault store integration", () => { + beforeEach(() => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + ["bulldrome", "broodfang-spider"], + "aether-assault", + ); + useGameStore.getState().startEncounter(); + disableBossPressure(); + }); + + it("keeps auto-fire running while the healer casts and uses the global cooldown", () => { + useGameStore.setState((state) => ({ + party: state.party.map((member) => member.id === "brann" ? { ...member, hp: member.hp - 40 } : member), + })); + useGameStore.getState().selectMember("brann"); + + expect(useGameStore.getState().castAbility("ability1")).toBe(true); + expect(useGameStore.getState().activeCast).not.toBeNull(); + useGameStore.getState().tick(0.21); + + const state = useGameStore.getState(); + expect(state.activeCast).not.toBeNull(); + expect(state.globalCooldownUntil).toBeGreaterThan(state.time); + expect(state.aetherAssault.playerShots.length).toBeGreaterThanOrEqual(2); + }); + + it("lets the healer traverse the full rink", () => { + useGameStore.getState().setPlayerPosition([0, -100]); + expect(useGameStore.getState().playerPosition[1]).toBeCloseTo(HOCKEY_ARENA_MIN_Z + 0.65); + + useGameStore.getState().setPlayerPosition([0, 100]); + expect(useGameStore.getState().playerPosition[1]).toBeCloseTo(HOCKEY_ARENA_MAX_Z - 0.65); + }); + + it("applies mitigated ship damage only to the healer and grants hit protection", () => { + const before = useGameStore.getState(); + const healerPosition = before.partyPositions.aelia; + const initialHp = before.party.map((member) => member.hp); + useGameStore.setState((state) => ({ + barrier: { ...state.barrier, kind: "barrier", center: [...healerPosition], expiresAt: 10 }, + aetherAssault: { + ...state.aetherAssault, + killStreak: 5, + multiplier: 1.25, + enemyShots: [{ id: 900, position: [...healerPosition], velocity: [0, 0] }], + }, + })); + + useGameStore.getState().tick(0.01); + const afterFirst = useGameStore.getState(); + expect(afterFirst.party[0].hp).toBeCloseTo(initialHp[0] - 7); + expect(afterFirst.party.slice(1).map((member) => member.hp)).toEqual(initialHp.slice(1)); + expect(afterFirst.aetherAssault.killStreak).toBe(0); + expect(afterFirst.aetherAssault.multiplier).toBe(1); + + useGameStore.setState((state) => ({ + aetherAssault: { + ...state.aetherAssault, + enemyShots: [{ id: 901, position: [...state.partyPositions.aelia], velocity: [0, 0] }], + }, + })); + useGameStore.getState().tick(0.1); + expect(useGameStore.getState().party[0].hp).toBeCloseTo(afterFirst.party[0].hp); + }); + + it("keeps boss replacement independent from arcade waves", () => { + const originalInstance = useGameStore.getState().bossInstanceId; + useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 1 } })); + useGameStore.getState().tick(2); + expect(useGameStore.getState().endlessBossKills).toBe(1); + expect(useGameStore.getState().boss.hp).toBe(0); + + useGameStore.getState().tick(2); + useGameStore.getState().tick(1.3); + + const state = useGameStore.getState(); + expect(state.endlessBossKills).toBe(1); + expect(state.boss.hp).toBeGreaterThan(0); + expect(state.bossInstanceId).not.toBe(originalInstance); + expect(state.aetherAssault.status).toBe("live"); + }); + + it("pauses arcade simulation, resets a run, and ends only on party wipe", () => { + useGameStore.getState().tick(0.21); + const beforePause = useGameStore.getState().aetherAssault; + useGameStore.getState().setPaused(true); + useGameStore.getState().tick(1); + expect(useGameStore.getState().aetherAssault).toBe(beforePause); + + useGameStore.getState().setPaused(false); + useGameStore.setState((state) => ({ + party: state.party.map((member) => ({ ...member, hp: 0 })), + })); + useGameStore.getState().tick(0.01); + expect(useGameStore.getState().phase).toBe("defeat"); + + useGameStore.getState().restart(); + const restarted = useGameStore.getState(); + expect(restarted.phase).toBe("briefing"); + expect(restarted.aetherAssault.wave).toBe(1); + expect(restarted.aetherAssault.score).toBe(0); + expect(restarted.aetherAssault.ships).toHaveLength(8); + }); +}); diff --git a/src/game/appearanceLab.test.ts b/src/game/appearanceLab.test.ts new file mode 100644 index 0000000..42e0c14 --- /dev/null +++ b/src/game/appearanceLab.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { createDefaultHealerAppearance } from "./healerVisuals"; +import { + APPEARANCE_SLOT_DEFINITIONS, + appearanceSlotEnabled, + appearanceSlotLabel, + appearancesMatch, + cycleAppearanceSlot, +} from "./appearanceLab"; +import { weaponDefinitionsForSlot } from "./weaponCatalog"; + +describe("appearance lab choices", () => { + it("cycles every visible slot without mutating the saved appearance", () => { + const original = createDefaultHealerAppearance("priest"); + let draft = original; + for (const slot of APPEARANCE_SLOT_DEFINITIONS) { + const before = appearanceSlotLabel(draft, slot.id); + draft = cycleAppearanceSlot(draft, slot.id, 1); + expect(appearanceSlotLabel(draft, slot.id), slot.id).not.toBe(before); + } + expect(createDefaultHealerAppearance("priest")).toEqual(original); + expect(appearancesMatch(draft, original)).toBe(false); + }); + + it("wraps choices in both directions", () => { + const original = createDefaultHealerAppearance("druid"); + const previous = cycleAppearanceSlot(original, "main-hand", -1); + const restored = cycleAppearanceSlot(previous, "main-hand", 1); + expect(restored.mainHand).toEqual(original.mainHand); + }); + + it("makes every imported held design reachable without a 49-item flat cycle", () => { + let appearance = createDefaultHealerAppearance("priest"); + const firstFamily = appearanceSlotLabel(appearance, "main-hand-family"); + const visitedFamilies = new Set(); + const visitedMainHands = new Set(); + do { + const family = appearanceSlotLabel(appearance, "main-hand-family"); + expect(visitedFamilies.has(family)).toBe(false); + visitedFamilies.add(family); + const firstModelId = appearance.mainHand.modelId; + do { + visitedMainHands.add(appearance.mainHand.modelId); + appearance = cycleAppearanceSlot(appearance, "main-hand", 1); + } while (appearance.mainHand.modelId !== firstModelId); + appearance = cycleAppearanceSlot(appearance, "main-hand-family", 1); + } while (appearanceSlotLabel(appearance, "main-hand-family") !== firstFamily); + + expect(visitedFamilies).toHaveLength(10); + expect(visitedMainHands).toHaveLength(49); + expect(visitedMainHands).toEqual(new Set(weaponDefinitionsForSlot("main").map((entry) => entry.id))); + }); + + it("preserves a saved offhand while a two-handed style disables its row", () => { + const paladin = createDefaultHealerAppearance("paladin"); + const twoHanded = { + ...paladin, + mainHand: { modelId: "cc/staff_d", grip: "staff" } as const, + }; + expect(appearanceSlotEnabled(twoHanded, "off-hand")).toBe(false); + expect(appearanceSlotLabel(twoHanded, "off-hand")).toBe("Hidden by two-hander"); + expect(cycleAppearanceSlot(twoHanded, "off-hand", 1)).toBe(twoHanded); + expect(twoHanded.offHand).toEqual(paladin.offHand); + }); +}); diff --git a/src/game/appearanceLab.ts b/src/game/appearanceLab.ts new file mode 100644 index 0000000..d98cf49 --- /dev/null +++ b/src/game/appearanceLab.ts @@ -0,0 +1,203 @@ +import type { + CharacterAppearanceV1, + CharacterHeldItemVisual, + CharacterPartIdFor, +} from "./characterAppearance"; +import { + heldItemForModel, + weaponDefinition, + weaponDefinitionsForSlot, + weaponUsesBothHands, + type CharacterWeaponCategory, + type CharacterWeaponModelId, + type WeaponDefinition, +} from "./weaponCatalog"; + +export type AppearanceSlotId = "head" | "upper" | "lower" | "headwear" | "back" | "main-hand-family" | "main-hand" | "off-hand"; + +interface AppearanceChoice { + value: Value; + label: string; +} + +export interface AppearanceSlotDefinition { + id: AppearanceSlotId; + label: string; + assetNote: string; +} + +export const APPEARANCE_SLOT_DEFINITIONS: readonly AppearanceSlotDefinition[] = [ + { id: "head", label: "Face + hair", assetNote: "Combined in current character assets" }, + { id: "upper", label: "Shirt + arms", assetNote: "Combined in current character assets" }, + { id: "lower", label: "Pants + shoes", assetNote: "Combined in current character assets" }, + { id: "headwear", label: "Hat", assetNote: "Separate skinned equipment" }, + { id: "back", label: "Back item", assetNote: "Capes, packs, and skinned quiver" }, + { id: "main-hand-family", label: "Weapon type", assetNote: "10 Claudecraft families" }, + { id: "main-hand", label: "Weapon style", assetNote: "49 imported designs" }, + { id: "off-hand", label: "Offhand", assetNote: "Saved while a two-hander is active" }, +] as const; + +const HEAD_CHOICES: readonly AppearanceChoice>[] = [ + { value: "druid-head", label: "Grove" }, + { value: "mage-head", label: "Mystic" }, + { value: "ranger-head", label: "Wayfinder" }, + { value: "knight-head", label: "Vanguard" }, + { value: "rogue-head", label: "Chronicle" }, +]; + +const UPPER_CHOICES: readonly AppearanceChoice>[] = [ + { value: "druid-upper", label: "Grove leathers" }, + { value: "mage-upper", label: "Mystic robes" }, + { value: "ranger-upper", label: "Wayfinder mail" }, + { value: "knight-upper", label: "Vanguard plate" }, + { value: "rogue-upper", label: "Chronicle coat" }, +]; + +const LOWER_CHOICES: readonly AppearanceChoice>[] = [ + { value: "druid-lower", label: "Grove boots" }, + { value: "mage-lower", label: "Mystic boots" }, + { value: "ranger-lower", label: "Wayfinder boots" }, + { value: "knight-lower", label: "Vanguard greaves" }, + { value: "rogue-lower", label: "Chronicle boots" }, +]; + +const HEADWEAR_CHOICES: readonly AppearanceChoice | null>[] = [ + { value: null, label: "None" }, + { value: "mage-hat", label: "Mystic hat" }, + { value: "knight-helmet", label: "Vanguard helm" }, +]; + +const BACK_CHOICES: readonly AppearanceChoice | null>[] = [ + { value: null, label: "None" }, + { value: "druid-backpack", label: "Grove pack" }, + { value: "mage-cape", label: "Mystic cape" }, + { value: "ranger-cape", label: "Wayfinder cape" }, + { value: "ranger-quiver", label: "Wayfinder quiver" }, + { value: "knight-cape", label: "Vanguard cape" }, + { value: "rogue-cape", label: "Chronicle cape" }, +]; + +const MAIN_HAND_CATEGORY_ORDER = [ + "staff", + "wand", + "sword", + "axe", + "hammer", + "dagger", + "crossbow", + "halberd", + "scythe", + "spear", +] as const satisfies readonly CharacterWeaponCategory[]; + +const WEAPON_CATEGORY_LABELS: Record = { + axe: "Axes", + crossbow: "Crossbows", + dagger: "Daggers", + halberd: "Halberds", + hammer: "Hammers", + scythe: "Scythes", + shield: "Shields", + spear: "Spears", + spellbook: "Spellbooks", + staff: "Staves", + sword: "Swords", + wand: "Wands", +}; +const MAIN_HAND_CATEGORY_CHOICES: readonly AppearanceChoice[] = MAIN_HAND_CATEGORY_ORDER.map((value) => ({ + value, + label: WEAPON_CATEGORY_LABELS[value], +})); + +function definitionsInCategory( + definitions: readonly WeaponDefinition[], + category: CharacterWeaponCategory, +) { + return definitions.filter((definition) => definition.category === category); +} + +const MAIN_HAND_DEFINITIONS = MAIN_HAND_CATEGORY_ORDER.flatMap((category) => + definitionsInCategory(weaponDefinitionsForSlot("main"), category)); +const MAIN_HAND_CHOICES: readonly AppearanceChoice[] = MAIN_HAND_DEFINITIONS.map((definition) => ({ + value: heldItemForModel(definition.id as CharacterWeaponModelId), + label: definition.label, +})); +const OFF_HAND_CATEGORY_ORDER = ["shield", "spellbook", "dagger", "sword", "axe"] as const; +const OFF_HAND_DEFINITIONS = OFF_HAND_CATEGORY_ORDER.flatMap((category) => + definitionsInCategory(weaponDefinitionsForSlot("off"), category)); +const OFF_HAND_CHOICES: readonly AppearanceChoice[] = [ + { value: undefined, label: "None" }, + ...OFF_HAND_DEFINITIONS.map((definition) => ({ + value: heldItemForModel(definition.id as CharacterWeaponModelId), + label: definition.label, + })), +]; + +function cycleChoice( + choices: readonly AppearanceChoice[], + current: Value, + direction: -1 | 1, + equals: (left: Value, right: Value) => boolean = Object.is, +) { + const currentIndex = Math.max(0, choices.findIndex((choice) => equals(choice.value, current))); + return choices[(currentIndex + direction + choices.length) % choices.length]; +} + +function heldItemEquals(left: CharacterHeldItemVisual | undefined, right: CharacterHeldItemVisual | undefined) { + return left?.modelId === right?.modelId && left?.grip === right?.grip; +} + +function mainHandCategory(appearance: CharacterAppearanceV1): CharacterWeaponCategory { + return weaponDefinition(appearance.mainHand.modelId).category as CharacterWeaponCategory; +} + +export function appearanceSlotEnabled(appearance: CharacterAppearanceV1, slotId: AppearanceSlotId) { + return slotId !== "off-hand" || !weaponUsesBothHands(appearance.mainHand.modelId); +} + +export function appearanceSlotLabel(appearance: CharacterAppearanceV1, slotId: AppearanceSlotId) { + if (slotId === "head") return HEAD_CHOICES.find((choice) => choice.value === appearance.headPartId)?.label ?? appearance.headPartId; + if (slotId === "upper") return UPPER_CHOICES.find((choice) => choice.value === appearance.upperBodyPartId)?.label ?? appearance.upperBodyPartId; + if (slotId === "lower") return LOWER_CHOICES.find((choice) => choice.value === appearance.lowerBodyPartId)?.label ?? appearance.lowerBodyPartId; + if (slotId === "headwear") return HEADWEAR_CHOICES.find((choice) => choice.value === appearance.headwearPartId)?.label ?? "None"; + if (slotId === "back") return BACK_CHOICES.find((choice) => choice.value === appearance.backPartId)?.label ?? "None"; + if (slotId === "main-hand-family") return WEAPON_CATEGORY_LABELS[mainHandCategory(appearance)]; + if (slotId === "main-hand") return MAIN_HAND_CHOICES.find((choice) => heldItemEquals(choice.value, appearance.mainHand))?.label ?? appearance.mainHand.modelId; + if (!appearanceSlotEnabled(appearance, slotId)) return "Hidden by two-hander"; + return OFF_HAND_CHOICES.find((choice) => heldItemEquals(choice.value, appearance.offHand))?.label ?? "None"; +} + +export function cycleAppearanceSlot( + appearance: CharacterAppearanceV1, + slotId: AppearanceSlotId, + direction: -1 | 1, +): CharacterAppearanceV1 { + if (slotId === "head") return { ...appearance, headPartId: cycleChoice(HEAD_CHOICES, appearance.headPartId, direction).value }; + if (slotId === "upper") return { ...appearance, upperBodyPartId: cycleChoice(UPPER_CHOICES, appearance.upperBodyPartId, direction).value }; + if (slotId === "lower") return { ...appearance, lowerBodyPartId: cycleChoice(LOWER_CHOICES, appearance.lowerBodyPartId, direction).value }; + if (slotId === "headwear") return { ...appearance, headwearPartId: cycleChoice(HEADWEAR_CHOICES, appearance.headwearPartId, direction).value }; + if (slotId === "back") return { ...appearance, backPartId: cycleChoice(BACK_CHOICES, appearance.backPartId, direction).value }; + if (slotId === "main-hand-family") { + const category = cycleChoice(MAIN_HAND_CATEGORY_CHOICES, mainHandCategory(appearance), direction).value; + const definition = MAIN_HAND_DEFINITIONS.find((candidate) => candidate.category === category)!; + return { ...appearance, mainHand: heldItemForModel(definition.id as CharacterWeaponModelId) }; + } + if (slotId === "main-hand") { + const choices = MAIN_HAND_CHOICES.filter((choice) => weaponDefinition(choice.value.modelId).category === mainHandCategory(appearance)); + const mainHand = cycleChoice(choices, appearance.mainHand, direction, heldItemEquals).value; + return { ...appearance, mainHand: { ...mainHand } }; + } + if (!appearanceSlotEnabled(appearance, slotId)) return appearance; + const offHand = cycleChoice(OFF_HAND_CHOICES, appearance.offHand, direction, heldItemEquals).value; + return { ...appearance, offHand: offHand ? { ...offHand } : undefined }; +} + +export function appearancesMatch(left: CharacterAppearanceV1, right: CharacterAppearanceV1) { + return left.headPartId === right.headPartId + && left.upperBodyPartId === right.upperBodyPartId + && left.lowerBodyPartId === right.lowerBodyPartId + && left.headwearPartId === right.headwearPartId + && left.backPartId === right.backPartId + && heldItemEquals(left.mainHand, right.mainHand) + && heldItemEquals(left.offHand, right.offHand); +} diff --git a/src/game/arena.ts b/src/game/arena.ts index 6411d76..55a4f5a 100644 --- a/src/game/arena.ts +++ b/src/game/arena.ts @@ -1,4 +1,11 @@ import type { BossMotionState, WorldPosition } from "./types"; +import { + HOCKEY_ARENA_MAX_X, + HOCKEY_ARENA_MAX_Z, + HOCKEY_ARENA_MIN_X, + HOCKEY_ARENA_MIN_Z, + HOCKEY_MIDLINE_Z, +} from "./hockeyHealing"; export const ARENA_CENTER: WorldPosition = [0, -1]; /** Keeps simulation limits and the room renderer in lockstep. */ @@ -29,3 +36,22 @@ export function constrainBossMotion(motion: BossMotionState): BossMotionState { export function isInsideArena(position: WorldPosition, tolerance = 0.001) { return Math.hypot(position[0] - ARENA_CENTER[0], position[1] - ARENA_CENTER[1]) <= ARENA_RADIUS + tolerance; } + +function clampToHockeyBounds(position: WorldPosition, minZ: number, maxZ: number, padding = 0): WorldPosition { + return [ + Math.max(HOCKEY_ARENA_MIN_X + padding, Math.min(HOCKEY_ARENA_MAX_X - padding, position[0])), + Math.max(minZ + padding, Math.min(maxZ - padding, position[1])), + ]; +} + +export function clampToHockeyArena(position: WorldPosition, padding = 0): WorldPosition { + return clampToHockeyBounds(position, HOCKEY_ARENA_MIN_Z, HOCKEY_ARENA_MAX_Z, padding); +} + +export function clampToHockeyHealerHalf(position: WorldPosition, padding = 0): WorldPosition { + return clampToHockeyBounds(position, HOCKEY_MIDLINE_Z, HOCKEY_ARENA_MAX_Z, padding); +} + +export function clampToHockeyEnemyHalf(position: WorldPosition, padding = 0): WorldPosition { + return clampToHockeyBounds(position, HOCKEY_ARENA_MIN_Z, HOCKEY_MIDLINE_Z, padding); +} diff --git a/src/game/blockbreaker.test.ts b/src/game/blockbreaker.test.ts new file mode 100644 index 0000000..a00af9e --- /dev/null +++ b/src/game/blockbreaker.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { + BLOCKBREAKER_BRICK_COLORS, + BLOCKBREAKER_DANGER_Z, + BLOCKBREAKER_MINIMUM_ROW_INTERVAL, + BLOCKBREAKER_PUCK_SPEED, + BLOCKBREAKER_RESERVE_SECONDS, + advanceBlockbreaker, + blockbreakerClusterScore, + blockbreakerColumnX, + blockbreakerConnectedCluster, + blockbreakerRowInterval, + blockbreakerRowZ, + blockbreakerTimeMultiplier, + createBlockbreakerState, + type BlockbreakerBrick, +} from "./blockbreaker"; +import { HOCKEY_HEALER_GOAL_Z } from "./hockeyHealing"; + +describe("Blockbreaker", () => { + it("creates deterministic five-brick rows from four colors", () => { + const first = createBlockbreakerState(true, 12345); + const second = createBlockbreakerState(true, 12345); + expect(first.bricks).toHaveLength(5); + expect(first.bricks.map((brick) => brick.color)).toEqual(second.bricks.map((brick) => brick.color)); + expect(first.bricks.every((brick) => BLOCKBREAKER_BRICK_COLORS.includes(brick.color))).toBe(true); + expect(Math.hypot(...first.puckVelocity)).toBeCloseTo(BLOCKBREAKER_PUCK_SPEED); + }); + + it("accelerates rows by ten percent per minute with a three-second floor", () => { + expect(blockbreakerRowInterval(0)).toBe(10); + expect(blockbreakerRowInterval(60)).toBe(9); + expect(blockbreakerRowInterval(600)).toBeCloseTo(3.486784401, 8); + expect(blockbreakerRowInterval(2_000)).toBe(BLOCKBREAKER_MINIMUM_ROW_INTERVAL); + }); + + it("uses the per-brick ladder and current uncapped time multiplier", () => { + expect(blockbreakerTimeMultiplier(0)).toBe(1); + expect(blockbreakerTimeMultiplier(30)).toBe(1.1); + expect(blockbreakerTimeMultiplier(60)).toBe(1.2); + expect(blockbreakerTimeMultiplier(600)).toBe(3); + expect(blockbreakerClusterScore(1, 0)).toBe(10); + expect(blockbreakerClusterScore(3, 0)).toBe(60); + expect(blockbreakerClusterScore(3, 30)).toBe(66); + expect(blockbreakerClusterScore(3, 60)).toBe(72); + }); + + it("propagates through orthogonal matches but not diagonals", () => { + const bricks: BlockbreakerBrick[] = [ + { id: "source", row: 1, column: 1, color: "cyan" }, + { id: "right", row: 1, column: 2, color: "cyan" }, + { id: "up", row: 0, column: 2, color: "cyan" }, + { id: "diagonal", row: 0, column: 0, color: "cyan" }, + { id: "other", row: 2, column: 1, color: "amber" }, + ]; + expect(blockbreakerConnectedCluster(bricks, bricks[0]).map((brick) => brick.id).sort()).toEqual(["right", "source", "up"]); + }); + + it("breaks the swept-hit cluster and applies score at collision time", () => { + const state = createBlockbreakerState(true, 7); + state.nextRowAt = 999; + state.bricks = [ + { id: "front", row: 0, column: 2, color: "magenta" }, + { id: "behind", row: 1, column: 2, color: "magenta" }, + ]; + state.puckPosition = [blockbreakerColumnX(2), blockbreakerRowZ(0) + 2.2]; + state.puckVelocity = [0, -6.25]; + const next = advanceBlockbreaker(state, { delta: 0.4, time: 60, playerPosition: [8, 8] }); + expect(next.bricks).toEqual([]); + expect(next.bricksBroken).toBe(2); + expect(next.score).toBe(36); + expect(next.puckVelocity[1]).toBeGreaterThan(0); + }); + + it("removes a breached row and keeps the run live", () => { + let state = createBlockbreakerState(true, 8); + state.puckVelocity = [0, 0]; + while (state.breaches === 0) { + state = advanceBlockbreaker(state, { delta: 0.1, time: state.nextRowAt, playerPosition: [0, 8.5] }); + } + expect(state.status).toBe("live"); + expect(state.breaches).toBe(1); + expect(state.bricks.every((brick) => blockbreakerRowZ(brick.row) < BLOCKBREAKER_DANGER_Z)).toBe(true); + expect(state.lostAt).toBeNull(); + }); + + it("adds complete five-column rows on schedule", () => { + const state = createBlockbreakerState(true, 81); + state.puckVelocity = [0, 0]; + state.puckPosition = [0, 8]; + const next = advanceBlockbreaker(state, { delta: 0.1, time: 10, playerPosition: [0, 8.5] }); + expect(next.bricks).toHaveLength(10); + expect(next.bricks.filter((brick) => brick.row === 0).map((brick) => brick.column).sort()).toEqual([0, 1, 2, 3, 4]); + expect(next.bricks.filter((brick) => brick.row === 1).map((brick) => brick.column).sort()).toEqual([0, 1, 2, 3, 4]); + }); + + it("rebounds from side and far walls", () => { + const side = createBlockbreakerState(true, 82); + side.nextRowAt = 999; + side.bricks = []; + side.puckPosition = [9.45, 0]; + side.puckVelocity = [6.25, 0]; + const sideBounce = advanceBlockbreaker(side, { delta: 0.2, time: 1, playerPosition: [0, 8.5] }); + expect(sideBounce.puckVelocity[0]).toBeLessThan(0); + + const far = createBlockbreakerState(true, 83); + far.nextRowAt = 999; + far.bricks = []; + far.puckPosition = [0, -14]; + far.puckVelocity = [0, -6.25]; + const farBounce = advanceBlockbreaker(far, { delta: 0.2, time: 1, playerPosition: [8, 8.5] }); + expect(farBounce.puckVelocity[1]).toBeGreaterThan(0); + }); + + it("uses healer position and aim for player rebounds", () => { + const state = createBlockbreakerState(true, 84); + state.nextRowAt = 999; + state.bricks = []; + state.aimDirection = [1, -1]; + state.puckPosition = [0, 7.2]; + state.puckVelocity = [0, 6.25]; + const returned = advanceBlockbreaker(state, { delta: 0.2, time: 1, playerPosition: [0, 8] }); + expect(returned.puckVelocity[0]).toBeGreaterThan(0); + expect(returned.puckVelocity[1]).toBeLessThan(0); + }); + + it("safe re-serves one second after a missed puck without ending the run", () => { + const state = createBlockbreakerState(true, 9); + state.nextRowAt = 999; + state.bricks = []; + state.puckPosition = [0, HOCKEY_HEALER_GOAL_Z - 0.1]; + state.puckVelocity = [0, 6.25]; + const missed = advanceBlockbreaker(state, { delta: 0.1, time: 5, playerPosition: [8, 8] }); + expect(missed.status).toBe("reserving"); + expect(missed.lostAt).toBeNull(); + expect(missed.reServeAt).toBe(5 + BLOCKBREAKER_RESERVE_SECONDS); + const served = advanceBlockbreaker(missed, { delta: 0.1, time: missed.reServeAt!, playerPosition: [8, 8] }); + expect(served.status).toBe("live"); + expect(served.puckVelocity[1]).toBeGreaterThan(0); + }); +}); diff --git a/src/game/blockbreaker.ts b/src/game/blockbreaker.ts new file mode 100644 index 0000000..44b450b --- /dev/null +++ b/src/game/blockbreaker.ts @@ -0,0 +1,394 @@ +import { + HOCKEY_ARENA_MAX_X, + HOCKEY_ARENA_MIN_X, + HOCKEY_ARENA_WIDTH, + HOCKEY_HEALER_GOAL_Z, + HOCKEY_MIDLINE_Z, + HOCKEY_NPC_GOAL_Z, + hockeyReturnDirection, +} from "./hockeyHealing"; +import type { WorldPosition } from "./types"; + +export const BLOCKBREAKER_COLUMN_COUNT = 5; +export const BLOCKBREAKER_BRICK_COLORS = ["cyan", "amber", "magenta", "lime"] as const; +export type BlockbreakerBrickColor = typeof BLOCKBREAKER_BRICK_COLORS[number]; + +export interface BlockbreakerBrick { + id: string; + row: number; + column: number; + color: BlockbreakerBrickColor; +} + +export type BlockbreakerStatus = "inactive" | "live" | "reserving" | "lost"; + +export interface BlockbreakerState { + status: BlockbreakerStatus; + seed: number; + randomState: number; + bricks: BlockbreakerBrick[]; + puckPosition: WorldPosition; + puckVelocity: WorldPosition; + aimDirection: WorldPosition; + bricksBroken: number; + score: number; + rowsSpawned: number; + breaches: number; + nextRowAt: number; + serveIndex: number; + reServeAt: number | null; + lastBreakAt: number; + lastBreakCount: number; + lastScoreAward: number; + lostAt: number | null; +} + +export interface BlockbreakerStep { + delta: number; + time: number; + playerPosition: WorldPosition; +} + +export const BLOCKBREAKER_PUCK_RADIUS = 0.42; +export const BLOCKBREAKER_BRICK_WIDTH = HOCKEY_ARENA_WIDTH / BLOCKBREAKER_COLUMN_COUNT - 0.28; +export const BLOCKBREAKER_BRICK_DEPTH = 1.34; +export const BLOCKBREAKER_ROW_SPACING = 1.72; +export const BLOCKBREAKER_SPAWN_Z = HOCKEY_NPC_GOAL_Z + 1.42; +export const BLOCKBREAKER_DANGER_Z = HOCKEY_MIDLINE_Z + (HOCKEY_HEALER_GOAL_Z - HOCKEY_MIDLINE_Z) * 0.5; +export const BLOCKBREAKER_RESERVE_SECONDS = 1; +export const BLOCKBREAKER_BREACH_DAMAGE = 25; +export const BLOCKBREAKER_PUCK_SPEED = 7.25; +export const BLOCKBREAKER_STARTING_ROW_INTERVAL = 10; +export const BLOCKBREAKER_MINIMUM_ROW_INTERVAL = 3; +export const BLOCKBREAKER_MAX_ROWS = Math.ceil((BLOCKBREAKER_DANGER_Z - BLOCKBREAKER_SPAWN_Z) / BLOCKBREAKER_ROW_SPACING) + 1; +export const BLOCKBREAKER_MAX_BRICKS = BLOCKBREAKER_MAX_ROWS * BLOCKBREAKER_COLUMN_COUNT; + +const PLAYER_INTERCEPT_RADIUS = 1.05; +const MAX_SUBSTEPS = 12; +const MAX_SUBSTEP_DISTANCE = 0.3; +const MAX_ROW_SPAWNS_PER_STEP = 8; +const SERVE_LANES = [0, -0.62, 0.68, -0.3, 0.36, -0.8, 0.82] as const; + +function normalizeSeed(seed: number) { + const normalized = Math.floor(Number(seed)) >>> 0; + return normalized || 0x9e3779b9; +} + +export function createBlockbreakerSeed(random: () => number = Math.random) { + const sample = Number(random()); + const normalized = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999999, sample)) : 0; + return normalizeSeed(Math.floor(normalized * 0x100000000)); +} + +function nextRandom(state: number) { + let next = normalizeSeed(state); + next ^= next << 13; + next ^= next >>> 17; + next ^= next << 5; + return next >>> 0; +} + +export function blockbreakerColumnX(column: number) { + const laneWidth = HOCKEY_ARENA_WIDTH / BLOCKBREAKER_COLUMN_COUNT; + return HOCKEY_ARENA_MIN_X + laneWidth * (Math.max(0, Math.min(BLOCKBREAKER_COLUMN_COUNT - 1, column)) + 0.5); +} + +export function blockbreakerRowZ(row: number) { + return BLOCKBREAKER_SPAWN_Z + Math.max(0, row) * BLOCKBREAKER_ROW_SPACING; +} + +export function blockbreakerRowInterval(elapsedSeconds: number) { + const minute = Math.max(0, Math.floor((Number(elapsedSeconds) || 0) / 60)); + return Math.max(BLOCKBREAKER_MINIMUM_ROW_INTERVAL, BLOCKBREAKER_STARTING_ROW_INTERVAL * 0.9 ** minute); +} + +export function blockbreakerTimeMultiplierTenths(elapsedSeconds: number) { + return 10 + Math.max(0, Math.floor((Number(elapsedSeconds) || 0) / 30)); +} + +export function blockbreakerTimeMultiplier(elapsedSeconds: number) { + return blockbreakerTimeMultiplierTenths(elapsedSeconds) / 10; +} + +export function blockbreakerClusterBase(clusterSize: number) { + const size = Math.max(0, Math.floor(Number(clusterSize) || 0)); + return 10 * size * (size + 1) / 2; +} + +export function blockbreakerClusterScore(clusterSize: number, elapsedSeconds: number) { + return blockbreakerClusterBase(clusterSize) * blockbreakerTimeMultiplierTenths(elapsedSeconds) / 10; +} + +function generateRow(randomState: number, rowSequence: number) { + const bricks: BlockbreakerBrick[] = []; + let nextState = randomState; + for (let column = 0; column < BLOCKBREAKER_COLUMN_COUNT; column += 1) { + nextState = nextRandom(nextState); + bricks.push({ + id: `${rowSequence}:${column}`, + row: 0, + column, + color: BLOCKBREAKER_BRICK_COLORS[nextState % BLOCKBREAKER_BRICK_COLORS.length], + }); + } + return { bricks, randomState: nextState }; +} + +function servePosition(serveIndex: number): WorldPosition { + const lane = SERVE_LANES[serveIndex % SERVE_LANES.length]; + return [lane * (HOCKEY_ARENA_MAX_X - 1.25), BLOCKBREAKER_SPAWN_Z + BLOCKBREAKER_BRICK_DEPTH * 0.5 + 0.72]; +} + +function serveVelocity(position: WorldPosition, serveIndex: number): WorldPosition { + const lane = SERVE_LANES[(serveIndex + 3) % SERVE_LANES.length]; + const target: WorldPosition = [lane * (HOCKEY_ARENA_MAX_X - 1.4), HOCKEY_HEALER_GOAL_Z]; + const dx = target[0] - position[0]; + const dz = target[1] - position[1]; + const length = Math.max(0.0001, Math.hypot(dx, dz)); + return [dx / length * BLOCKBREAKER_PUCK_SPEED, dz / length * BLOCKBREAKER_PUCK_SPEED]; +} + +export function createBlockbreakerState(active = false, requestedSeed = 1): BlockbreakerState { + const seed = normalizeSeed(requestedSeed); + const row = generateRow(seed, 0); + const puckPosition = servePosition(0); + return { + status: active ? "live" : "inactive", + seed, + randomState: row.randomState, + bricks: row.bricks, + puckPosition, + puckVelocity: active ? serveVelocity(puckPosition, 0) : [0, 0], + aimDirection: [0, -1], + bricksBroken: 0, + score: 0, + rowsSpawned: 1, + breaches: 0, + nextRowAt: BLOCKBREAKER_STARTING_ROW_INTERVAL, + serveIndex: 0, + reServeAt: null, + lastBreakAt: Number.NEGATIVE_INFINITY, + lastBreakCount: 0, + lastScoreAward: 0, + lostAt: null, + }; +} + +export function setBlockbreakerAim(state: BlockbreakerState, aim: WorldPosition): BlockbreakerState { + const x = Number.isFinite(aim[0]) ? aim[0] : 0; + const z = Number.isFinite(aim[1]) ? aim[1] : 0; + if (state.aimDirection[0] === x && state.aimDirection[1] === z) return state; + return { ...state, aimDirection: [x, z] }; +} + +export function blockbreakerConnectedCluster(bricks: readonly BlockbreakerBrick[], source: BlockbreakerBrick) { + const byCell = new Map(bricks.map((brick) => [`${brick.row}:${brick.column}`, brick])); + const connected: BlockbreakerBrick[] = []; + const visited = new Set(); + const pending: BlockbreakerBrick[] = [source]; + while (pending.length) { + const brick = pending.pop()!; + if (visited.has(brick.id) || brick.color !== source.color) continue; + visited.add(brick.id); + connected.push(brick); + for (const [row, column] of [ + [brick.row - 1, brick.column], + [brick.row + 1, brick.column], + [brick.row, brick.column - 1], + [brick.row, brick.column + 1], + ]) { + const neighbor = byCell.get(`${row}:${column}`); + if (neighbor && !visited.has(neighbor.id) && neighbor.color === source.color) pending.push(neighbor); + } + } + return connected; +} + +function segmentDistanceSquared( + startX: number, + startZ: number, + endX: number, + endZ: number, + point: WorldPosition, +) { + const dx = endX - startX; + const dz = endZ - startZ; + const lengthSquared = dx * dx + dz * dz; + const projection = lengthSquared < 0.000001 + ? 0 + : Math.max(0, Math.min(1, ((point[0] - startX) * dx + (point[1] - startZ) * dz) / lengthSquared)); + const nearestX = startX + dx * projection; + const nearestZ = startZ + dz * projection; + return (point[0] - nearestX) ** 2 + (point[1] - nearestZ) ** 2; +} + +type SegmentHit = { time: number; normalX: number; normalZ: number }; + +function segmentExpandedBrickHit(start: WorldPosition, end: WorldPosition, brick: BlockbreakerBrick): SegmentHit | null { + const centerX = blockbreakerColumnX(brick.column); + const centerZ = blockbreakerRowZ(brick.row); + const minX = centerX - BLOCKBREAKER_BRICK_WIDTH * 0.5 - BLOCKBREAKER_PUCK_RADIUS; + const maxX = centerX + BLOCKBREAKER_BRICK_WIDTH * 0.5 + BLOCKBREAKER_PUCK_RADIUS; + const minZ = centerZ - BLOCKBREAKER_BRICK_DEPTH * 0.5 - BLOCKBREAKER_PUCK_RADIUS; + const maxZ = centerZ + BLOCKBREAKER_BRICK_DEPTH * 0.5 + BLOCKBREAKER_PUCK_RADIUS; + const dx = end[0] - start[0]; + const dz = end[1] - start[1]; + let entry = 0; + let exit = 1; + let normalX = 0; + let normalZ = 0; + for (const axis of [ + { start: start[0], delta: dx, min: minX, max: maxX, nx: -Math.sign(dx), nz: 0 }, + { start: start[1], delta: dz, min: minZ, max: maxZ, nx: 0, nz: -Math.sign(dz) }, + ]) { + if (Math.abs(axis.delta) < 0.000001) { + if (axis.start < axis.min || axis.start > axis.max) return null; + continue; + } + const near = (axis.min - axis.start) / axis.delta; + const far = (axis.max - axis.start) / axis.delta; + const axisEntry = Math.min(near, far); + const axisExit = Math.max(near, far); + if (axisEntry > entry) { + entry = axisEntry; + normalX = axis.nx; + normalZ = axis.nz; + } + exit = Math.min(exit, axisExit); + if (entry > exit) return null; + } + if (entry < 0 || entry > 1 || exit < 0) return null; + if (normalX === 0 && normalZ === 0) { + if (Math.abs(dx) > Math.abs(dz)) normalX = -Math.sign(dx); + else normalZ = -Math.sign(dz); + } + return { time: entry, normalX, normalZ }; +} + +function addBlockbreakerRow(state: BlockbreakerState, spawnedAt: number) { + const shifted = state.bricks.map((brick) => ({ ...brick, row: brick.row + 1 })); + const survivors = shifted.filter((brick) => blockbreakerRowZ(brick.row) < BLOCKBREAKER_DANGER_Z); + if (survivors.length < shifted.length) state.breaches += 1; + const row = generateRow(state.randomState, state.rowsSpawned); + state.randomState = row.randomState; + state.bricks = [...survivors, ...row.bricks]; + state.rowsSpawned += 1; +} + +function breakCluster(state: BlockbreakerState, hitBrick: BlockbreakerBrick, time: number) { + const cluster = blockbreakerConnectedCluster(state.bricks, hitBrick); + const ids = new Set(cluster.map((brick) => brick.id)); + const award = blockbreakerClusterScore(cluster.length, time); + state.bricks = state.bricks.filter((brick) => !ids.has(brick.id)); + state.bricksBroken += cluster.length; + state.score += award; + state.lastBreakAt = time; + state.lastBreakCount = cluster.length; + state.lastScoreAward = award; +} + +function beginReserve(state: BlockbreakerState, time: number) { + state.status = "reserving"; + state.puckVelocity = [0, 0]; + state.reServeAt = time + BLOCKBREAKER_RESERVE_SECONDS; +} + +function completeReserve(state: BlockbreakerState) { + state.serveIndex += 1; + state.puckPosition = servePosition(state.serveIndex); + state.puckVelocity = serveVelocity(state.puckPosition, state.serveIndex); + state.status = "live"; + state.reServeAt = null; +} + +export function blockbreakerAimPreviewVisible(state: BlockbreakerState, playerPosition: WorldPosition) { + if (state.status !== "live" || state.puckVelocity[1] <= 0) return false; + const distance = Math.hypot(playerPosition[0] - state.puckPosition[0], playerPosition[1] - state.puckPosition[1]); + return distance / Math.max(0.001, Math.hypot(state.puckVelocity[0], state.puckVelocity[1])) <= 1.5; +} + +export function advanceBlockbreaker(source: BlockbreakerState, step: BlockbreakerStep): BlockbreakerState { + if (source.status === "inactive" || source.status === "lost" || step.delta <= 0) return source; + const state: BlockbreakerState = { + ...source, + // Bricks are immutable between row shifts and breaks; both operations replace + // this array, so sharing it avoids cloning every brick on each 10 Hz tick. + bricks: source.bricks, + puckPosition: [...source.puckPosition], + puckVelocity: [...source.puckVelocity], + aimDirection: [...source.aimDirection], + }; + + let spawnedRows = 0; + while (state.status !== "lost" && step.time + 0.0001 >= state.nextRowAt && spawnedRows < MAX_ROW_SPAWNS_PER_STEP) { + const spawnedAt = state.nextRowAt; + addBlockbreakerRow(state, spawnedAt); + state.nextRowAt = spawnedAt + blockbreakerRowInterval(spawnedAt); + spawnedRows += 1; + } + if (state.status === "lost") return state; + if (state.status === "reserving") { + if (state.reServeAt !== null && step.time >= state.reServeAt) completeReserve(state); + else return state; + } + + const speed = Math.hypot(state.puckVelocity[0], state.puckVelocity[1]); + const substeps = Math.max(1, Math.min(MAX_SUBSTEPS, Math.ceil(speed * step.delta / MAX_SUBSTEP_DISTANCE))); + const subDelta = step.delta / substeps; + const interceptRadiusSquared = (PLAYER_INTERCEPT_RADIUS + BLOCKBREAKER_PUCK_RADIUS) ** 2; + + for (let substep = 0; substep < substeps && state.status === "live"; substep += 1) { + const start: WorldPosition = [...state.puckPosition]; + const end: WorldPosition = [ + start[0] + state.puckVelocity[0] * subDelta, + start[1] + state.puckVelocity[1] * subDelta, + ]; + + const minX = HOCKEY_ARENA_MIN_X + BLOCKBREAKER_PUCK_RADIUS; + const maxX = HOCKEY_ARENA_MAX_X - BLOCKBREAKER_PUCK_RADIUS; + if (end[0] < minX || end[0] > maxX) { + end[0] = Math.max(minX, Math.min(maxX, end[0])); + state.puckVelocity[0] *= -1; + } + + let closest: { brick: BlockbreakerBrick; hit: SegmentHit } | null = null; + for (const brick of state.bricks) { + const hit = segmentExpandedBrickHit(start, end, brick); + if (hit && (!closest || hit.time < closest.hit.time)) closest = { brick, hit }; + } + if (closest) { + state.puckPosition = [ + start[0] + (end[0] - start[0]) * closest.hit.time + closest.hit.normalX * 0.012, + start[1] + (end[1] - start[1]) * closest.hit.time + closest.hit.normalZ * 0.012, + ]; + if (closest.hit.normalX) state.puckVelocity[0] *= -1; + if (closest.hit.normalZ) state.puckVelocity[1] *= -1; + breakCluster(state, closest.brick, step.time); + continue; + } + + if (state.puckVelocity[1] > 0 + && segmentDistanceSquared(start[0], start[1], end[0], end[1], step.playerPosition) <= interceptRadiusSquared) { + const direction = hockeyReturnDirection(state.aimDirection); + state.puckPosition = [end[0], Math.min(end[1], step.playerPosition[1])]; + state.puckVelocity = [direction[0] * BLOCKBREAKER_PUCK_SPEED, direction[1] * BLOCKBREAKER_PUCK_SPEED]; + continue; + } + + if (state.puckVelocity[1] < 0 && end[1] <= HOCKEY_NPC_GOAL_Z + BLOCKBREAKER_PUCK_RADIUS) { + state.puckPosition = [end[0], HOCKEY_NPC_GOAL_Z + BLOCKBREAKER_PUCK_RADIUS]; + state.puckVelocity[1] = Math.abs(state.puckVelocity[1]); + continue; + } + + if (state.puckVelocity[1] > 0 && end[1] >= HOCKEY_HEALER_GOAL_Z) { + state.puckPosition = [end[0], HOCKEY_HEALER_GOAL_Z]; + beginReserve(state, step.time); + continue; + } + + state.puckPosition = end; + } + + return state; +} diff --git a/src/game/blockbreakerBiomes.test.ts b/src/game/blockbreakerBiomes.test.ts new file mode 100644 index 0000000..d88f014 --- /dev/null +++ b/src/game/blockbreakerBiomes.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { BLOCKBREAKER_BRICK_COLORS } from "./blockbreaker"; +import { + BLOCKBREAKER_BIOMES, + BLOCKBREAKER_BIOME_IDS, + blockbreakerBiomeForSeed, +} from "./blockbreakerBiomes"; + +describe("Blockbreaker arena biomes", () => { + it("defines five distinct arena and fixture styles", () => { + expect(BLOCKBREAKER_BIOMES.map((biome) => biome.id)).toEqual(BLOCKBREAKER_BIOME_IDS); + expect(new Set(BLOCKBREAKER_BIOMES.map((biome) => biome.name)).size).toBe(5); + expect(new Set(BLOCKBREAKER_BIOMES.map((biome) => biome.fixture)).size).toBe(5); + expect(new Set(BLOCKBREAKER_BIOMES.map((biome) => biome.background)).size).toBe(5); + expect(new Set(BLOCKBREAKER_BIOMES.map((biome) => biome.keyLight)).size).toBe(5); + }); + + it("keeps every gameplay brick color visually distinct in every biome", () => { + for (const biome of BLOCKBREAKER_BIOMES) { + const colors = BLOCKBREAKER_BRICK_COLORS.map((color) => biome.bricks[color]); + expect(colors.every((color) => /^#[0-9a-f]{6}$/i.test(color))).toBe(true); + expect(new Set(colors).size).toBe(BLOCKBREAKER_BRICK_COLORS.length); + } + }); + + it("selects a deterministic biome from the match seed", () => { + expect(blockbreakerBiomeForSeed(0x12345678)).toBe(blockbreakerBiomeForSeed(0x12345678)); + const sampledIds = new Set(Array.from({ length: 256 }, (_, seed) => blockbreakerBiomeForSeed(seed).id)); + expect(sampledIds).toEqual(new Set(BLOCKBREAKER_BIOME_IDS)); + }); +}); diff --git a/src/game/blockbreakerBiomes.ts b/src/game/blockbreakerBiomes.ts new file mode 100644 index 0000000..6a05e43 --- /dev/null +++ b/src/game/blockbreakerBiomes.ts @@ -0,0 +1,253 @@ +import type { BlockbreakerBrickColor } from "./blockbreaker"; + +export const BLOCKBREAKER_BIOME_IDS = [ + "prism-circuit", + "ember-foundry", + "frost-vault", + "void-grid", + "verdant-reactor", +] as const; + +export type BlockbreakerBiomeId = typeof BLOCKBREAKER_BIOME_IDS[number]; +export type BlockbreakerBiomeFixture = "crystal" | "forge" | "spire" | "monolith" | "reactor"; + +export interface BlockbreakerArenaBiome { + id: BlockbreakerBiomeId; + name: string; + fixture: BlockbreakerBiomeFixture; + background: string; + fog: string; + ambient: string; + sky: string; + ground: string; + keyLight: string; + fillLightA: string; + fillLightB: string; + foundation: string; + floor: string; + playfield: string; + wall: string; + wallEmissive: string; + boundary: string; + railA: string; + railB: string; + midline: string; + fixtureColor: string; + fixtureEmissive: string; + ambientIntensity: number; + hemisphereIntensity: number; + keyLightIntensity: number; + fillLightIntensityA: number; + fillLightIntensityB: number; + wallEmissiveIntensity: number; + floorRoughness: number; + floorMetalness: number; + bricks: Readonly>; +} + +export const BLOCKBREAKER_BIOMES: readonly BlockbreakerArenaBiome[] = [ + { + id: "prism-circuit", + name: "Prism Circuit", + fixture: "crystal", + background: "#163640", + fog: "#214b55", + ambient: "#dffcff", + sky: "#efffff", + ground: "#24535b", + keyLight: "#f4ffff", + fillLightA: "#8cefff", + fillLightB: "#fff0bd", + foundation: "#173c44", + floor: "#2d6972", + playfield: "#367985", + wall: "#39717c", + wallEmissive: "#143a43", + boundary: "#b9f8ff", + railA: "#9ef6ff", + railB: "#fff2c2", + midline: "#e8feff", + fixtureColor: "#49a6b7", + fixtureEmissive: "#70efff", + ambientIntensity: 0.72, + hemisphereIntensity: 2.3, + keyLightIntensity: 3.15, + fillLightIntensityA: 5.5, + fillLightIntensityB: 4.8, + wallEmissiveIntensity: 0.32, + floorRoughness: 0.58, + floorMetalness: 0.08, + bricks: { + cyan: "#42ddff", + amber: "#ffc247", + magenta: "#ff55ad", + lime: "#8bf065", + }, + }, + { + id: "ember-foundry", + name: "Ember Foundry", + fixture: "forge", + background: "#2a0d0a", + fog: "#4b1b11", + ambient: "#ffd7a8", + sky: "#ffb45c", + ground: "#240a0b", + keyLight: "#ffe0b2", + fillLightA: "#ff5b32", + fillLightB: "#ffc54d", + foundation: "#250b0a", + floor: "#5a2416", + playfield: "#7a321e", + wall: "#52241d", + wallEmissive: "#6a170c", + boundary: "#ffb15f", + railA: "#ff6a38", + railB: "#ffd15c", + midline: "#ffe2a6", + fixtureColor: "#5d2117", + fixtureEmissive: "#ff5a26", + ambientIntensity: 0.58, + hemisphereIntensity: 1.85, + keyLightIntensity: 2.75, + fillLightIntensityA: 6.4, + fillLightIntensityB: 5.4, + wallEmissiveIntensity: 0.48, + floorRoughness: 0.76, + floorMetalness: 0.18, + bricks: { + cyan: "#54dcff", + amber: "#ffbd3d", + magenta: "#ff5b78", + lime: "#d7ff5c", + }, + }, + { + id: "frost-vault", + name: "Frost Vault", + fixture: "spire", + background: "#071a2f", + fog: "#143b56", + ambient: "#dff8ff", + sky: "#c6f4ff", + ground: "#10233d", + keyLight: "#ffffff", + fillLightA: "#55dfff", + fillLightB: "#9b8cff", + foundation: "#09192a", + floor: "#1c4c67", + playfield: "#255f7c", + wall: "#24455f", + wallEmissive: "#0d4662", + boundary: "#d4fbff", + railA: "#6be9ff", + railB: "#b5a7ff", + midline: "#ffffff", + fixtureColor: "#5b9db9", + fixtureEmissive: "#9defff", + ambientIntensity: 0.66, + hemisphereIntensity: 2.05, + keyLightIntensity: 3.35, + fillLightIntensityA: 5.3, + fillLightIntensityB: 4.2, + wallEmissiveIntensity: 0.38, + floorRoughness: 0.42, + floorMetalness: 0.16, + bricks: { + cyan: "#38e4ff", + amber: "#ffd36f", + magenta: "#b47cff", + lime: "#6ef2c0", + }, + }, + { + id: "void-grid", + name: "Void Grid", + fixture: "monolith", + background: "#05020f", + fog: "#120526", + ambient: "#cebaff", + sky: "#8d65ff", + ground: "#0a0313", + keyLight: "#e8dcff", + fillLightA: "#784cff", + fillLightB: "#ff38c7", + foundation: "#07020e", + floor: "#160a2b", + playfield: "#211044", + wall: "#1d1030", + wallEmissive: "#280966", + boundary: "#b68cff", + railA: "#7d5cff", + railB: "#ff4ccc", + midline: "#d9c4ff", + fixtureColor: "#16082e", + fixtureEmissive: "#8d5cff", + ambientIntensity: 0.44, + hemisphereIntensity: 1.48, + keyLightIntensity: 2.45, + fillLightIntensityA: 6.2, + fillLightIntensityB: 5.7, + wallEmissiveIntensity: 0.64, + floorRoughness: 0.34, + floorMetalness: 0.28, + bricks: { + cyan: "#41d8ff", + amber: "#ffac45", + magenta: "#ff4bd0", + lime: "#a1ff4f", + }, + }, + { + id: "verdant-reactor", + name: "Verdant Reactor", + fixture: "reactor", + background: "#0d211a", + fog: "#193f31", + ambient: "#e1ffe3", + sky: "#b8ffc9", + ground: "#102217", + keyLight: "#f2ffe8", + fillLightA: "#63e6a3", + fillLightB: "#d8ff67", + foundation: "#0d1f18", + floor: "#25543d", + playfield: "#2d684a", + wall: "#294f3f", + wallEmissive: "#0d4b2d", + boundary: "#b9ffd2", + railA: "#67f4a7", + railB: "#e7ff73", + midline: "#f1ffd0", + fixtureColor: "#245642", + fixtureEmissive: "#76f7a9", + ambientIntensity: 0.62, + hemisphereIntensity: 2.02, + keyLightIntensity: 2.9, + fillLightIntensityA: 5.1, + fillLightIntensityB: 4.6, + wallEmissiveIntensity: 0.42, + floorRoughness: 0.68, + floorMetalness: 0.06, + bricks: { + cyan: "#4be3ff", + amber: "#ffca52", + magenta: "#ff6fb5", + lime: "#99f75d", + }, + }, +]; + +function mixSeed(seed: number) { + let mixed = Math.floor(Number(seed)) >>> 0; + mixed ^= mixed >>> 16; + mixed = Math.imul(mixed, 0x7feb352d); + mixed ^= mixed >>> 15; + mixed = Math.imul(mixed, 0x846ca68b); + mixed ^= mixed >>> 16; + return mixed >>> 0; +} + +export function blockbreakerBiomeForSeed(seed: number) { + return BLOCKBREAKER_BIOMES[mixSeed(seed) % BLOCKBREAKER_BIOMES.length]; +} diff --git a/src/game/blockbreakerStore.test.ts b/src/game/blockbreakerStore.test.ts new file mode 100644 index 0000000..9c22488 --- /dev/null +++ b/src/game/blockbreakerStore.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath"; +import { BLOCKBREAKER_BREACH_DAMAGE, BLOCKBREAKER_MAX_ROWS, blockbreakerRowZ } from "./blockbreaker"; +import { createClassInventory } from "./healers"; +import { HOCKEY_HEALER_GOAL_Z, HOCKEY_MIDLINE_Z } from "./hockeyHealing"; +import { useGameStore } from "./store"; + +describe("Blockbreaker encounter integration", () => { + beforeEach(() => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + ["bulldrome", "broodfang-spider"], + "blockbreaker", + ); + }); + + it("starts an endless two-boss rink with one five-brick row", () => { + const state = useGameStore.getState(); + expect(state.endlessMode).toBe(true); + expect(state.additionalBosses).toHaveLength(1); + expect(state.blockbreaker.bricks).toHaveLength(5); + expect(state.bossMotion.position[1]).toBeLessThan(HOCKEY_MIDLINE_Z); + expect(state.partyPositions.aelia[1]).toBeGreaterThan(HOCKEY_MIDLINE_Z); + }); + + it("deals 25 partywide damage once and continues when a row breaches", () => { + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, nextMeleeAt: 999 }, + additionalBosses: state.additionalBosses.map((entry) => ({ + ...entry, + boss: { ...entry.boss, nextMeleeAt: 999 }, + })), + blockbreaker: { + ...state.blockbreaker, + nextRowAt: state.time, + puckVelocity: [0, 0], + bricks: [{ id: "danger", row: BLOCKBREAKER_MAX_ROWS - 1, column: 2, color: "cyan" }], + }, + })); + expect(blockbreakerRowZ(BLOCKBREAKER_MAX_ROWS)).toBeGreaterThan(HOCKEY_MIDLINE_Z); + + useGameStore.getState().tick(0.1); + const breached = useGameStore.getState(); + expect(breached.blockbreaker.status).toBe("live"); + expect(breached.blockbreaker.breaches).toBe(1); + expect(breached.blockbreaker.bricks.some((brick) => brick.id === "danger")).toBe(false); + expect(breached.party.every((member) => member.hp === member.maxHp - BLOCKBREAKER_BREACH_DAMAGE)).toBe(true); + expect(breached.phase).toBe("combat"); + expect(breached.combatLog[0]?.message).toContain("25 damage to every party member"); + + const healthAfterBreach = breached.party.map((member) => member.hp); + useGameStore.getState().tick(0.1); + expect(useGameStore.getState().party.map((member) => member.hp)).toEqual(healthAfterBreach); + }); + + it("misses safely re-serve without ending the run", () => { + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + blockbreaker: { + ...state.blockbreaker, + nextRowAt: 999, + bricks: [], + puckPosition: [0, HOCKEY_HEALER_GOAL_Z - 0.1], + puckVelocity: [0, 6.25], + }, + })); + useGameStore.getState().tick(0.1); + expect(useGameStore.getState().blockbreaker.status).toBe("reserving"); + expect(useGameStore.getState().phase).toBe("combat"); + useGameStore.getState().tick(1); + expect(useGameStore.getState().blockbreaker.status).toBe("live"); + expect(useGameStore.getState().phase).toBe("combat"); + }); + + it("fails when all four allies die even while the healer is alive", () => { + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, nextMeleeAt: 999 }, + additionalBosses: state.additionalBosses.map((entry) => ({ + ...entry, + boss: { ...entry.boss, nextMeleeAt: 999 }, + })), + blockbreaker: { ...state.blockbreaker, nextRowAt: 999, puckVelocity: [0, 0] }, + party: state.party.map((member) => ({ + ...member, + hp: member.id === "aelia" || member.id === "vale" ? member.maxHp : 0, + })), + })); + + useGameStore.getState().tick(0.1); + expect(useGameStore.getState().phase).toBe("combat"); + + useGameStore.setState((state) => ({ + party: state.party.map((member) => member.id === "vale" ? { ...member, hp: 0 } : member), + })); + useGameStore.getState().tick(0.1); + + const defeated = useGameStore.getState(); + expect(defeated.party.find((member) => member.id === "aelia")?.hp).toBeGreaterThan(0); + expect(defeated.blockbreaker.status).toBe("lost"); + expect(defeated.blockbreaker.puckVelocity).toEqual([0, 0]); + expect(defeated.phase).toBe("defeat"); + expect(defeated.combatLog[0]?.message).toContain("All four allies fell"); + const logLength = defeated.combatLog.length; + useGameStore.getState().tick(0.1); + expect(useGameStore.getState().combatLog).toHaveLength(logLength); + }); + + it("replaces fallen bosses and resets run state on retry", () => { + useGameStore.getState().startEncounter(); + const defeatedInstanceId = useGameStore.getState().bossInstanceId; + useGameStore.setState((state) => ({ + boss: { ...state.boss, hp: 1, nextMeleeAt: 999 }, + blockbreaker: { ...state.blockbreaker, nextRowAt: 999, puckVelocity: [0, 0], bricksBroken: 14, score: 720 }, + })); + let elapsed = 0; + while (useGameStore.getState().boss.hp > 0 && elapsed < 3) { + useGameStore.getState().tick(0.1); + elapsed += 0.1; + } + expect(useGameStore.getState().boss.hp).toBe(0); + while (elapsed <= BOSS_DEATH_DESPAWN_SECONDS + 3.2) { + useGameStore.getState().tick(0.1); + elapsed += 0.1; + } + const replaced = useGameStore.getState(); + expect(replaced.phase).toBe("combat"); + expect(replaced.bossInstanceId).not.toBe(defeatedInstanceId); + expect(replaced.bossInstanceId.startsWith("blockbreaker-")).toBe(true); + expect(replaced.endlessBossKills).toBe(1); + + replaced.restart(); + const restarted = useGameStore.getState(); + expect(restarted.phase).toBe("briefing"); + expect(restarted.blockbreaker.bricksBroken).toBe(0); + expect(restarted.blockbreaker.score).toBe(0); + expect(restarted.blockbreaker.bricks).toHaveLength(5); + }); +}); diff --git a/src/game/bossAnimation.test.ts b/src/game/bossAnimation.test.ts new file mode 100644 index 0000000..8f59c76 --- /dev/null +++ b/src/game/bossAnimation.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + BOSS_HIT_ANIMATION_SECONDS, + BOSS_MELEE_ANIMATION_SECONDS, + bossAnimationClipName, + bossAnimationTrigger, + isBossAnimationOneShot, + selectBossAnimationState, + shouldStartBossAnimation, + writeBossProceduralPose, + type BossAnimationClips, + type BossProceduralPose, +} from "./bossAnimation"; + +const CLIPS: BossAnimationClips = { + idle: "Idle", + move: "Move", + attack: "Attack", + special: "Special", + melee: "Melee", + death: "Death", +}; + +function pose(): BossProceduralPose { + return { x: 0, y: 0, z: 0, pitch: 0, yaw: 0, roll: 0, scaleX: 1, scaleY: 1, scaleZ: 1 }; +} + +describe("boss animation playback", () => { + it("prioritizes death, mechanics, melee, and hit reactions in gameplay order", () => { + const base = { defeated: false, activeMechanic: false, mechanicCue: "idle" as const }; + expect(selectBossAnimationState({ ...base, defeated: true, meleeElapsed: 0, hitElapsed: 0 })).toBe("death"); + expect(selectBossAnimationState({ ...base, activeMechanic: true, mechanicCue: "special", meleeElapsed: 0, hitElapsed: 0 })).toBe("special"); + expect(selectBossAnimationState({ ...base, meleeElapsed: 0, hitElapsed: 0 })).toBe("melee"); + expect(selectBossAnimationState({ ...base, meleeElapsed: BOSS_MELEE_ANIMATION_SECONDS, hitElapsed: 0 })).toBe("hit"); + expect(selectBossAnimationState({ ...base, meleeElapsed: BOSS_MELEE_ANIMATION_SECONDS, hitElapsed: BOSS_HIT_ANIMATION_SECONDS })).toBe("idle"); + }); + + it("restarts one-shot melee and hit clips only for new triggers", () => { + expect(shouldStartBossAnimation("melee", 2, "melee", 2)).toBe(false); + expect(shouldStartBossAnimation("melee", 2, "melee", 4.5)).toBe(true); + expect(shouldStartBossAnimation("hit", 1, "hit", 2)).toBe(true); + expect(shouldStartBossAnimation("move", 0, "move", 3)).toBe(false); + expect(isBossAnimationOneShot("special")).toBe(true); + }); + + it("uses idle plus procedural recoil when a source model lacks a hit clip", () => { + expect(bossAnimationClipName(CLIPS, "hit")).toBe("Idle"); + const next = pose(); + writeBossProceduralPose(next, "ricochet", "holding", "hit", BOSS_HIT_ANIMATION_SECONDS / 2, 1); + expect(next.z).toBeLessThan(0); + expect(next.pitch).toBeGreaterThan(0); + }); + + it("adds signature windup motion for breath and shockwave bosses", () => { + const dragon = pose(); + writeBossProceduralPose(dragon, "sky-sweeper", "breath_sweeping", "attack", 0.5, 1); + expect(dragon.y).toBeGreaterThan(0); + expect(dragon.pitch).toBeLessThan(0); + + const golem = pose(); + writeBossProceduralPose(golem, "golem", "golem_shockwave", "special", 0.5, 1); + expect(golem.scaleY).toBeLessThan(1); + expect(golem.scaleX).toBeGreaterThan(1); + }); + + it("uses semantic triggers for repeated one-shot clips", () => { + expect(bossAnimationTrigger("attack", 4, 2, 1)).toBe(4); + expect(bossAnimationTrigger("melee", 4, 2, 1)).toBe(2); + expect(bossAnimationTrigger("hit", 4, 2, 1)).toBe(1); + }); +}); diff --git a/src/game/bossAnimation.ts b/src/game/bossAnimation.ts new file mode 100644 index 0000000..5ad8b9e --- /dev/null +++ b/src/game/bossAnimation.ts @@ -0,0 +1,174 @@ +import type { BossArchetype } from "./bossCatalog"; +import type { BossAnimationCue, BossMotionMode } from "./types"; + +export type BossAnimationState = BossAnimationCue | "melee" | "hit" | "death"; + +export interface BossAnimationClips { + idle: string; + move: string; + attack: string; + special: string; + melee: string; + hit?: string; + death: string; +} + +export interface BossProceduralPose { + x: number; + y: number; + z: number; + pitch: number; + yaw: number; + roll: number; + scaleX: number; + scaleY: number; + scaleZ: number; +} + +export const BOSS_MELEE_ANIMATION_SECONDS = 0.62; +export const BOSS_HIT_ANIMATION_SECONDS = 0.42; +export const BOSS_HIT_REACTION_COOLDOWN_SECONDS = 0.55; + +export function bossAnimationClipName(clips: BossAnimationClips, state: BossAnimationState) { + return state === "hit" ? clips.hit ?? clips.idle : clips[state]; +} + +export function isBossAnimationOneShot(state: BossAnimationState) { + return state === "attack" + || state === "special" + || state === "melee" + || state === "hit" + || state === "death"; +} + +export function shouldStartBossAnimation( + activeState: BossAnimationState | undefined, + activeTrigger: number, + nextState: BossAnimationState, + nextTrigger: number, +) { + if (activeState !== nextState) return true; + if (nextState === "death" || !isBossAnimationOneShot(nextState)) return false; + return activeTrigger !== nextTrigger; +} + +export function selectBossAnimationState({ + defeated, + activeMechanic, + mechanicCue, + meleeElapsed, + hitElapsed, +}: { + defeated: boolean; + activeMechanic: boolean; + mechanicCue: BossAnimationCue; + meleeElapsed: number; + hitElapsed: number; +}): BossAnimationState { + if (defeated) return "death"; + if (activeMechanic) return mechanicCue; + if (meleeElapsed < BOSS_MELEE_ANIMATION_SECONDS) return "melee"; + if (hitElapsed < BOSS_HIT_ANIMATION_SECONDS) return "hit"; + return mechanicCue; +} + +export function bossAnimationTrigger( + state: BossAnimationState, + phaseStartedAt: number, + lastMeleeAt: number, + hitTrigger: number, +) { + if (state === "attack" || state === "special") return phaseStartedAt; + if (state === "melee") return lastMeleeAt; + if (state === "hit") return hitTrigger; + return 0; +} + +function pulse(elapsed: number, duration: number) { + if (elapsed < 0 || elapsed >= duration) return 0; + return Math.sin((elapsed / duration) * Math.PI); +} + +function normalizedPhase(elapsed: number, duration: number) { + return Math.max(0, Math.min(1, elapsed / Math.max(0.001, duration))); +} + +export function resetBossProceduralPose(pose: BossProceduralPose) { + pose.x = 0; + pose.y = 0; + pose.z = 0; + pose.pitch = 0; + pose.yaw = 0; + pose.roll = 0; + pose.scaleX = 1; + pose.scaleY = 1; + pose.scaleZ = 1; +} + +/** + * Adds readable root motion where source GLBs only provide generic combat clips. + * Caller owns and reuses `pose`; this function allocates nothing in render loops. + */ +export function writeBossProceduralPose( + pose: BossProceduralPose, + archetype: BossArchetype, + mode: BossMotionMode, + animationState: BossAnimationState, + stateElapsed: number, + phaseDuration: number, +) { + resetBossProceduralPose(pose); + + if (animationState === "melee") { + const amount = pulse(stateElapsed, BOSS_MELEE_ANIMATION_SECONDS); + pose.z = amount * 0.34; + pose.pitch = -amount * 0.09; + pose.scaleZ = 1 + amount * 0.035; + return; + } + + if (animationState === "hit") { + const amount = pulse(stateElapsed, BOSS_HIT_ANIMATION_SECONDS); + pose.z = -amount * 0.2; + pose.pitch = amount * 0.08; + pose.roll = Math.sin((stateElapsed / BOSS_HIT_ANIMATION_SECONDS) * Math.PI * 2) * 0.045; + return; + } + + const progress = normalizedPhase(stateElapsed, phaseDuration); + const windup = Math.sin(progress * Math.PI); + + if (archetype === "sky-sweeper" && (mode === "breath_telegraph" || mode === "breath_sweeping")) { + pose.y = windup * 0.24; + pose.pitch = -windup * 0.18; + pose.roll = mode === "breath_sweeping" ? Math.sin(progress * Math.PI * 2) * 0.07 : 0; + pose.scaleX = 1 + windup * 0.045; + pose.scaleZ = 1 + windup * 0.045; + return; + } + + if (archetype === "golem" && mode === "golem_shockwave") { + pose.y = -windup * 0.1; + pose.scaleX = 1 + windup * 0.08; + pose.scaleY = 1 - windup * 0.13; + pose.scaleZ = 1 + windup * 0.08; + return; + } + + if (archetype === "golem" && (mode === "golem_crownfall" || mode === "skyfall")) { + pose.y = windup * 0.3; + pose.pitch = -windup * 0.08; + pose.scaleX = 1 + windup * 0.04; + pose.scaleZ = 1 + windup * 0.04; + return; + } + + if ((archetype === "ghost" || archetype === "web-caster") + && (mode === "tethering" || mode === "venom_cast" || mode === "golem_crownfall")) { + pose.y = windup * 0.18; + pose.yaw = Math.sin(progress * Math.PI * 2) * 0.12; + pose.scaleX = 1 + windup * 0.045; + pose.scaleY = 1 + windup * 0.045; + pose.scaleZ = 1 + windup * 0.045; + } +} diff --git a/src/game/bossCatalog.test.ts b/src/game/bossCatalog.test.ts index 6a5f124..b6bc2e3 100644 --- a/src/game/bossCatalog.test.ts +++ b/src/game/bossCatalog.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog"; import { createBossMotionState, createBossState } from "./bossMechanics"; import { BOSS_MECHANIC_POOL, BOSS_MECHANIC_REGISTRY, bossMechanicName } from "./bosses/mechanicPool"; -import { ALTERNATE_BOSS_CONFIG } from "./bossVisuals"; +import { ALTERNATE_BOSS_CONFIG, BULL_BOSS_ANIMATION_CONFIG } from "./bossVisuals"; describe("boss catalog", () => { it("derives the available roster from every catalog definition", () => { @@ -66,6 +66,23 @@ describe("boss catalog", () => { expect(ALTERNATE_BOSS_CONFIG["bristlequake-boar"].death).toBe("Dying"); }); + it("maps every boss to melee and available hit-reaction clips", () => { + const profiles = [BULL_BOSS_ANIMATION_CONFIG, ...Object.values(ALTERNATE_BOSS_CONFIG)]; + expect(profiles).toHaveLength(AVAILABLE_BOSS_IDS.length); + expect(profiles.every((profile) => profile.melee.length > 0)).toBe(true); + expect(profiles.filter((profile) => profile.hit).length).toBe(24); + }); + + it("uses signature-ready remaps for pounce, weapon, and tusk bosses", () => { + expect(BULL_BOSS_ANIMATION_CONFIG.attack).toBe("Attack_Headbutt"); + expect(ALTERNATE_BOSS_CONFIG["stormwool-alpaca"].special).toBe("Gallop_Jump"); + expect(ALTERNATE_BOSS_CONFIG["thorncrown-stag"].special).toBe("Gallop_Jump"); + expect(ALTERNATE_BOSS_CONFIG["riftclaw-demon"].attack).toBe("Weapon"); + expect(ALTERNATE_BOSS_CONFIG["warcaller-orc"].attack).toBe("Weapon"); + expect(ALTERNATE_BOSS_CONFIG["rimeclaw-yeti"].attack).toBe("Weapon"); + expect(ALTERNATE_BOSS_CONFIG["bristlequake-boar"].special).toBe("Attack2 (tusks)"); + }); + it("assigns Gravehorn to the underfilled burrow group with its optimized animation set", () => { expect(BOSS_GROUPS.find((group) => group.id === "burrow-eruption")?.bossIds).toContain("gravehorn-triceratops"); expect(ALTERNATE_BOSS_CONFIG["gravehorn-triceratops"]).toMatchObject({ diff --git a/src/game/bossDeath.ts b/src/game/bossDeath.ts index 0e52efe..eb75cdb 100644 --- a/src/game/bossDeath.ts +++ b/src/game/bossDeath.ts @@ -1,8 +1,24 @@ +import type { BossId } from "./types"; + export const BOSS_DEATH_HOLD_SECONDS = 2.5; export const BOSS_DEATH_FADE_SECONDS = 0.75; export const BOSS_DEATH_DESPAWN_SECONDS = BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS; -export function bossDeathOpacity(elapsedSeconds: number) { - if (elapsedSeconds <= BOSS_DEATH_HOLD_SECONDS) return 1; - return Math.max(0, 1 - (elapsedSeconds - BOSS_DEATH_HOLD_SECONDS) / BOSS_DEATH_FADE_SECONDS); +const BOSS_DEATH_HOLD_OVERRIDES: Partial> = { + // Armature|Fall lasts 5.73 seconds. Hold through final authored pose before fade. + "gravehorn-triceratops": 5.85, +}; + +export function bossDeathHoldSeconds(bossId?: BossId) { + return bossId ? BOSS_DEATH_HOLD_OVERRIDES[bossId] ?? BOSS_DEATH_HOLD_SECONDS : BOSS_DEATH_HOLD_SECONDS; +} + +export function bossDeathDespawnSeconds(bossId?: BossId) { + return bossDeathHoldSeconds(bossId) + BOSS_DEATH_FADE_SECONDS; +} + +export function bossDeathOpacity(elapsedSeconds: number, bossId?: BossId) { + const holdSeconds = bossDeathHoldSeconds(bossId); + if (elapsedSeconds <= holdSeconds) return 1; + return Math.max(0, 1 - (elapsedSeconds - holdSeconds) / BOSS_DEATH_FADE_SECONDS); } diff --git a/src/game/bossHome.test.ts b/src/game/bossHome.test.ts index 7f94e8c..eb91c91 100644 --- a/src/game/bossHome.test.ts +++ b/src/game/bossHome.test.ts @@ -24,6 +24,7 @@ describe("boss home positioning", () => { ); const result = advanceBossMechanics({ + arenaLayout: "standard", boss: createBossState(bossId), motion, party: freshParty(), diff --git a/src/game/bossVisuals.ts b/src/game/bossVisuals.ts index c5bc5a8..10501dc 100644 --- a/src/game/bossVisuals.ts +++ b/src/game/bossVisuals.ts @@ -1,22 +1,27 @@ import type { BossId } from "./types"; +import type { BossAnimationClips } from "./bossAnimation"; export type AlternateBossKind = Exclude; -export interface AlternateBossConfig { +export interface AlternateBossConfig extends BossAnimationClips { url: string; optimizedUrl?: string; scale: number; - idle: string; - move: string; - attack: string; - special: string; - death: string; light: string; rotationOffset: number; floating?: boolean; } export const BULL_URL = new URL("../assets/game/models/claudecraft/creatures/bull.glb", import.meta.url).href; +export const BULL_BOSS_ANIMATION_CONFIG: BossAnimationClips = { + idle: "Idle", + move: "Gallop", + attack: "Attack_Headbutt", + special: "Gallop_Jump", + melee: "Attack_Kick", + hit: "Idle_HitReact_Left", + death: "Death", +}; const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href; const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href; @@ -61,34 +66,34 @@ const CLAUDE_BOSS_URLS: Record = { - "sandglass-scorpion": { url: SANDGLASS_URL, scale: 0.7, idle: "Idle", move: "Burrow", attack: "Eruption", special: "Hourglass", death: "Death", light: "#e9b94f", rotationOffset: 0 }, - "cragclaw-crab": { url: CRAGCLAW_URL, scale: 1.2, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Bite_InPlace", death: "Death", light: "#49d5df", rotationOffset: 0 }, - "mournveil-ghost": { url: MOURNVEIL_URL, scale: 1.1, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#9d72ff", rotationOffset: 0, floating: true }, - "crownshard-golem": { url: CROWNSHARD_URL, scale: 1.15, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#e0bd45", rotationOffset: 0, floating: true }, - "crystal-bat-matriarch": { url: CRYSTAL_BAT_MATRIARCH_URL, scale: 0.828, idle: "Idle", move: "Swoop", attack: "SonicPulse", special: "MirrorShatter", death: "Death", light: "#8eeaff", rotationOffset: 0, floating: true }, - "stormwool-alpaca": { url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0 }, + "sandglass-scorpion": { url: SANDGLASS_URL, scale: 0.7, idle: "Idle", move: "Burrow", attack: "Eruption", special: "Hourglass", melee: "ClawAttack", hit: "Stagger", death: "Death", light: "#e9b94f", rotationOffset: 0 }, + "cragclaw-crab": { url: CRAGCLAW_URL, scale: 1.2, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Bite_InPlace", melee: "Bite_Front", hit: "HitRecieve", death: "Death", light: "#49d5df", rotationOffset: 0 }, + "mournveil-ghost": { url: MOURNVEIL_URL, scale: 1.1, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", melee: "Punch", hit: "HitReact", death: "Death", light: "#9d72ff", rotationOffset: 0, floating: true }, + "crownshard-golem": { url: CROWNSHARD_URL, scale: 1.15, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", melee: "Punch", hit: "HitReact", death: "Death", light: "#e0bd45", rotationOffset: 0, floating: true }, + "crystal-bat-matriarch": { url: CRYSTAL_BAT_MATRIARCH_URL, scale: 0.828, idle: "Idle", move: "Swoop", attack: "SonicPulse", special: "MirrorShatter", melee: "SonicPulse", hit: "Stagger", death: "Death", light: "#8eeaff", rotationOffset: 0, floating: true }, + "stormwool-alpaca": { url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Gallop_Jump", melee: "Attack_Kick", hit: "Idle_HitReact_Left", death: "Death", light: "#8fc7ff", rotationOffset: 0 }, // IDs remain stable so existing saves and trophies keep working after visual replacement. - "cluckhorn-colossus": { url: BRASSBEAK_BASILISK_URL, scale: 0.75, idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", death: "Death", light: "#5cebd7", rotationOffset: 0 }, - "ashwing-demon": { url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, floating: true }, - "riftclaw-demon": { url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0 }, - "tempestscale-dragon": { url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, floating: true }, - emberfox: { url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0 }, - "mirelord-frog": { url: BOGBELL_MYCONID_URL, scale: 0.78, idle: "Idle", move: "BurrowRush", attack: "RootPummel", special: "SporeEruption", death: "Death", light: "#8df06b", rotationOffset: 0 }, - "stonebreaker-giant": { url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0 }, - "glub-sovereign": { url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, floating: true }, - "scrapking-goblin": { url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0 }, - "warcaller-orc": { url: CLAUDE_BOSS_URLS["warcaller-orc"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#e4533f", rotationOffset: 0 }, - "tuskmaw-orc": { url: CLAUDE_BOSS_URLS["tuskmaw-orc"], scale: 1.45, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#9eb25d", rotationOffset: 0 }, - "broodfang-spider": { url: CLAUDE_BOSS_URLS["broodfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#b56cff", rotationOffset: 0 }, - "silkfang-spider": { url: CLAUDE_BOSS_URLS["silkfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#9d68d8", rotationOffset: 0 }, - "thorncrown-stag": { url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0 }, - "sky-totem": { url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, floating: true }, - "razorcrest-raptor": { url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0 }, - "bristlequake-boar": { url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Dying", light: "#d47b45", rotationOffset: 0 }, - "moonfang-wolf": { url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0 }, - "frostmaw-yeti": { url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0 }, - "rimeclaw-yeti": { url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0 }, - "gravehorn-triceratops": { url: GRAVEHORN_URL, optimizedUrl: GRAVEHORN_OPTIMIZED_URL, scale: 0.82, idle: "Gravehorn|Idle", move: "Armature|Walk", attack: "Armature|Roar", special: "Armature|RiseUp", death: "Armature|Fall", light: "#ffd9a0", rotationOffset: 0 }, + "cluckhorn-colossus": { url: BRASSBEAK_BASILISK_URL, scale: 0.75, idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", melee: "BeakRend", hit: "Stagger", death: "Death", light: "#5cebd7", rotationOffset: 0 }, + "ashwing-demon": { url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", melee: "Punch", hit: "HitReact", death: "Death", light: "#df665d", rotationOffset: 0, floating: true }, + "riftclaw-demon": { url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Weapon", special: "Punch", melee: "Weapon", hit: "HitReact", death: "Death", light: "#d45cff", rotationOffset: 0 }, + "tempestscale-dragon": { url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", melee: "Punch", hit: "HitReact", death: "Death", light: "#5fc8e8", rotationOffset: 0, floating: true }, + emberfox: { url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", melee: "Attack", hit: "Idle_HitReact_Left", death: "Death", light: "#ff7b45", rotationOffset: 0 }, + "mirelord-frog": { url: BOGBELL_MYCONID_URL, scale: 0.78, idle: "Idle", move: "BurrowRush", attack: "RootPummel", special: "SporeEruption", melee: "RootPummel", hit: "Stagger", death: "Death", light: "#8df06b", rotationOffset: 0 }, + "stonebreaker-giant": { url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", melee: "Attack", hit: "HitRecieve", death: "Death", light: "#c89563", rotationOffset: 0 }, + "glub-sovereign": { url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", melee: "Punch", hit: "HitReact", death: "Death", light: "#6ce0b8", rotationOffset: 0, floating: true }, + "scrapking-goblin": { url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", melee: "Attack", hit: "HitRecieve", death: "Death", light: "#d7a34b", rotationOffset: 0 }, + "warcaller-orc": { url: CLAUDE_BOSS_URLS["warcaller-orc"], scale: 1.35, idle: "Idle", move: "Run", attack: "Weapon", special: "Punch", melee: "Weapon", hit: "HitReact", death: "Death", light: "#e4533f", rotationOffset: 0 }, + "tuskmaw-orc": { url: CLAUDE_BOSS_URLS["tuskmaw-orc"], scale: 1.45, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", melee: "Bite_Front", hit: "HitRecieve", death: "Death", light: "#9eb25d", rotationOffset: 0 }, + "broodfang-spider": { url: CLAUDE_BOSS_URLS["broodfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", melee: "Spider_Attack", death: "Spider_Death", light: "#b56cff", rotationOffset: 0 }, + "silkfang-spider": { url: CLAUDE_BOSS_URLS["silkfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", melee: "Spider_Attack", death: "Spider_Death", light: "#9d68d8", rotationOffset: 0 }, + "thorncrown-stag": { url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Gallop_Jump", melee: "Attack_Kick", hit: "Idle_HitReact_Left", death: "Death", light: "#7fc46b", rotationOffset: 0 }, + "sky-totem": { url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", melee: "Punch", hit: "HitReact", death: "Death", light: "#69d4d1", rotationOffset: 0, floating: true }, + "razorcrest-raptor": { url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", melee: "Velociraptor_Attack", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0 }, + "bristlequake-boar": { url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack2 (tusks)", melee: "Attack_Headbutt_AnimalArmature", hit: "Hurt", death: "Dying", light: "#d47b45", rotationOffset: 0 }, + "moonfang-wolf": { url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", melee: "Attack", hit: "Idle_HitReact_Left", death: "Death", light: "#9db9e5", rotationOffset: 0 }, + "frostmaw-yeti": { url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", melee: "Bite_Front", hit: "HitRecieve", death: "Death", light: "#8ed8ef", rotationOffset: 0 }, + "rimeclaw-yeti": { url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Weapon", special: "Punch", melee: "Weapon", hit: "HitReact", death: "Death", light: "#75bfe8", rotationOffset: 0 }, + "gravehorn-triceratops": { url: GRAVEHORN_URL, optimizedUrl: GRAVEHORN_OPTIMIZED_URL, scale: 0.82, idle: "Gravehorn|Idle", move: "Armature|Walk", attack: "Armature|Roar", special: "Armature|RiseUp", melee: "Armature|Roar", death: "Armature|Fall", light: "#ffd9a0", rotationOffset: 0 }, }; export function bossVisualUrl(bossId: BossId, optimized = false): string { diff --git a/src/game/bosses/mechanicPool.test.ts b/src/game/bosses/mechanicPool.test.ts index 290a6e2..a94b520 100644 --- a/src/game/bosses/mechanicPool.test.ts +++ b/src/game/bosses/mechanicPool.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import { freshParty } from "../data"; -import { createBaseMotion } from "./shared"; -import { advanceMechanicLoadout, BOSS_MECHANIC_POOL, bossAnimationCue, SOUL_SIPHON } from "./mechanicPool"; +import { HOCKEY_MIDLINE_Z } from "../hockeyHealing"; +import { createBaseMotion, selectMeleeTargetIndex } from "./shared"; +import { advanceMechanicLoadout, BOSS_MECHANIC_POOL, bossAnimationCue, BULL_POUNCE, MEMORY_SEQUENCE, SOUL_SIPHON } from "./mechanicPool"; import type { BossMechanicContext } from "./types"; import type { BossState, PoolTelegraph, WorldPosition } from "../types"; @@ -23,8 +24,22 @@ function state(): BossState { }; } +describe("basic melee target fallback", () => { + it("prefers Brann, then another living tank, companion, and finally healer", () => { + let party = freshParty(); + expect(party[selectMeleeTargetIndex(party)].id).toBe("brann"); + party = party.map((member) => member.id === "brann" ? { ...member, hp: 0 } : member.id === "nia" ? { ...member, role: "Tank" as const } : member); + expect(party[selectMeleeTargetIndex(party)].id).toBe("nia"); + party = party.map((member) => member.id === "nia" ? { ...member, hp: 0 } : member); + expect(party[selectMeleeTargetIndex(party)].id).toBe("orin"); + party = party.map((member) => member.id === "aelia" ? member : { ...member, hp: 0 }); + expect(party[selectMeleeTargetIndex(party)].id).toBe("aelia"); + }); +}); + function context(time: number, positions = POSITIONS): BossMechanicContext { return { + arenaLayout: "standard", boss: state(), motion: createBaseMotion("bulldrome"), party: freshParty(), @@ -121,6 +136,16 @@ function advanceSoulSiphon( } describe("shared boss mechanic pool", () => { + it("records each basic melee impact for renderer animation cues", () => { + const source = context(2); + source.boss.nextMeleeAt = 2; + const tankHp = source.party.find((member) => member.id === "brann")!.hp; + const advanced = advanceMechanicLoadout(source, ["basic-melee", "bull-charge"]); + + expect(advanced.party.find((member) => member.id === "brann")!.hp).toBeLessThan(tankHp); + expect(advanced.motion.lastMeleeAt).toBe(2); + }); + it.each(BOSS_MECHANIC_POOL.filter(({ id }) => id !== "basic-melee"))("starts $name directly from its canonical ID", ({ id }) => { const source = context(0); source.motion.nextMechanicAt = 0; @@ -141,6 +166,44 @@ describe("shared boss mechanic pool", () => { expect(bossAnimationCue(started.motion)).toBe("attack"); }); + it("uses NPC-only Crushing Pounce targets in Hockey Healing", () => { + const source = context(0); + source.arenaLayout = "hockey-healing"; + source.motion.mechanicCount = 4; + source.motion.nextMechanicAt = 0; + const started = advanceMechanicLoadout(source, ["crushing-pounce", "bull-charge"]); + + expect(started.motion.pounceTargetId).not.toBe("aelia"); + expect(["brann", "nia", "orin", "vale"]).toContain(started.motion.pounceTargetId); + }); + + it("limits Hockey Healing Crushing Pounce to 150 shared damage", () => { + const source = context(0.1, { + aelia: [0, 4.5], + brann: [0, 0], + nia: [0, 0], + orin: [0, 0], + vale: [0, 0], + }); + source.arenaLayout = "hockey-healing"; + source.motion = { + ...source.motion, + activeMechanicId: "crushing-pounce", + mode: "pouncing", + position: [0, 0], + chargeEnd: [0, 0], + pounceCenter: [0, 0], + pounceTargetId: "brann", + phaseEndsAt: 0, + }; + const startingHp = source.party.reduce((total, member) => total + member.hp, 0); + const resolved = advanceMechanicLoadout(source, ["crushing-pounce", "bull-charge"]); + const remainingHp = resolved.party.reduce((total, member) => total + member.hp, 0); + + expect(startingHp - remainingHp).toBe(BULL_POUNCE.hockeySharedDamage); + expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp); + }); + it("moves a web caster sideways during Binding Web and animates its return home", () => { const source = context(0); source.motion.nextMechanicAt = 0; @@ -241,6 +304,23 @@ describe("shared boss mechanic pool", () => { expect(current.party).toEqual(freshParty()); }); + it.each(["hockey-healing", "hockey-healing-pvp"] as const)( + "keeps every Memory Sequence tile inside the healer half in %s", + (arenaLayout) => { + const source = context(0); + source.arenaLayout = arenaLayout; + source.motion.nextMechanicAt = 0; + const started = advanceMechanicLoadout(source, ["memory-sequence", "bull-charge"]); + const tiles = started.motion.poolTelegraphs[0].tiles ?? []; + const halfSize = MEMORY_SEQUENCE.tileSize * 0.5; + + expect(tiles).toHaveLength(4); + for (const tile of tiles) { + expect(tile.center[1] - halfSize).toBeGreaterThan(HOCKEY_MIDLINE_Z); + } + }, + ); + it("ignores NPC positions but deals 15 raidwide damage when healer selects a wrong tile", () => { const waiting = advanceMemory(1, memoryTelegraph(), freshParty(), [0, 4.5]); expect(waiting.events).toEqual([]); @@ -267,6 +347,17 @@ describe("shared boss mechanic pool", () => { expect(cleansed.events[0].message).toContain("cleansing ward"); }); + it("keeps Soul Siphon fully inside the healer half in Hockey Healing", () => { + const source = context(0); + source.arenaLayout = "hockey-healing"; + source.motion.nextMechanicAt = 0; + const started = advanceMechanicLoadout(source, ["soul-siphon", "bull-charge"]); + const siphon = started.motion.poolTelegraphs[0].soulSiphon!; + + expect(siphon.wardPosition[1] - siphon.wardRadius).toBeGreaterThan(HOCKEY_MIDLINE_Z); + expect(siphon.ghostPosition[1]).toBeGreaterThan(HOCKEY_MIDLINE_Z); + }); + it("deals half-strength ramping Soul Siphon damage and caps the ramp", () => { const result = advanceSoulSiphon(SOUL_SIPHON.tickInterval * 6 + 0.01, soulSiphonTelegraph()); const aelia = result.party.find((member) => member.id === "aelia")!; diff --git a/src/game/bosses/mechanicPool.ts b/src/game/bosses/mechanicPool.ts index 3e4ef3d..c70fc62 100644 --- a/src/game/bosses/mechanicPool.ts +++ b/src/game/bosses/mechanicPool.ts @@ -1,4 +1,4 @@ -import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena"; +import { ARENA_CENTER, ARENA_RADIUS, clampToArena, clampToHockeyArena, clampToHockeyHealerHalf } from "../arena"; import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry"; import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, Debuff, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types"; import { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared"; @@ -90,6 +90,7 @@ export const BULL_POUNCE = { stackDuration: 5, stackRadius: 3.2, sharedDamage: 200, + hockeySharedDamage: 150, leapDuration: 0.55, cooldown: 4, } as const; @@ -148,6 +149,13 @@ const MEMORY_TILE_LAYOUT: readonly { symbol: MemorySymbolId; center: WorldPositi { symbol: "square", center: [2.35, -3.15] }, ]; +const HOCKEY_MEMORY_TILE_LAYOUT: readonly { symbol: MemorySymbolId; center: WorldPosition }[] = [ + { symbol: "triangle", center: [-2.35, 1.35] }, + { symbol: "cross", center: [2.35, 1.35] }, + { symbol: "circle", center: [-2.35, 5.85] }, + { symbol: "square", center: [2.35, 5.85] }, +]; + const MEMORY_SEQUENCES: readonly (readonly MemorySymbolId[])[] = [ ["triangle", "cross", "circle", "square"], ["circle", "triangle", "square", "cross"], @@ -221,8 +229,17 @@ function beamTelegraph(id: string, center: WorldPosition, target: WorldPosition, }; } -function memorySequenceTelegraph(id: string, bossPosition: WorldPosition, count: number, inputStartsAt: number): PoolTelegraph { +function memorySequenceTelegraph( + id: string, + bossPosition: WorldPosition, + count: number, + inputStartsAt: number, + arenaLayout: BossMechanicContext["arenaLayout"], +): PoolTelegraph { const sequence = MEMORY_SEQUENCES[count % MEMORY_SEQUENCES.length]; + const tileLayout = arenaLayout === "standard" || arenaLayout === "aether-assault" + ? MEMORY_TILE_LAYOUT + : HOCKEY_MEMORY_TILE_LAYOUT; return { id, kind: "memory", @@ -235,28 +252,47 @@ function memorySequenceTelegraph(id: string, bossPosition: WorldPosition, count: damage: MEMORY_SEQUENCE.raidwideDamage, targetId: "aelia", sequence: [...sequence], - tiles: MEMORY_TILE_LAYOUT.map((tile) => ({ symbol: tile.symbol, center: [tile.center[0], tile.center[1]] })), + tiles: tileLayout.map((tile) => ({ symbol: tile.symbol, center: [tile.center[0], tile.center[1]] })), inputIndex: 0, resolved: false, hitIds: [], }; } -function oppositeWardPosition(healerPosition: WorldPosition, count: number): WorldPosition { +function clampSoulSiphonPosition( + position: WorldPosition, + padding: number, + arenaLayout: BossMechanicContext["arenaLayout"], +): WorldPosition { + if (arenaLayout === "aether-assault") return clampToHockeyArena(position, padding); + return arenaLayout !== "standard" ? clampToHockeyHealerHalf(position, padding) : clampToArena(position, padding); +} + +function oppositeWardPosition( + healerPosition: WorldPosition, + count: number, + arenaLayout: BossMechanicContext["arenaLayout"], +): WorldPosition { const offsetX = healerPosition[0] - ARENA_CENTER[0]; const offsetZ = healerPosition[1] - ARENA_CENTER[1]; const offsetLength = Math.hypot(offsetX, offsetZ); const fallbackAngle = count % 2 === 0 ? 0 : Math.PI / 2; const directionX = offsetLength > 0.05 ? -offsetX / offsetLength : Math.sin(fallbackAngle); const directionZ = offsetLength > 0.05 ? -offsetZ / offsetLength : Math.cos(fallbackAngle); - return clampToArena([ + return clampSoulSiphonPosition([ ARENA_CENTER[0] + directionX * SOUL_SIPHON.wardDistance, ARENA_CENTER[1] + directionZ * SOUL_SIPHON.wardDistance, - ], 0.25); + ], arenaLayout !== "standard" ? SOUL_SIPHON.wardRadius + 0.25 : 0.25, arenaLayout); } -function soulSiphonTelegraph(id: string, healerPosition: WorldPosition, count: number, time: number): PoolTelegraph { - const wardPosition = oppositeWardPosition(healerPosition, count); +function soulSiphonTelegraph( + id: string, + healerPosition: WorldPosition, + count: number, + time: number, + arenaLayout: BossMechanicContext["arenaLayout"], +): PoolTelegraph { + const wardPosition = oppositeWardPosition(healerPosition, count, arenaLayout); return { id, kind: "soul-siphon", @@ -269,7 +305,11 @@ function soulSiphonTelegraph(id: string, healerPosition: WorldPosition, count: n targetId: "aelia", soulSiphon: { targetId: "aelia", - ghostPosition: clampToArena([healerPosition[0] - 0.85, healerPosition[1] + 0.85], 0.2), + ghostPosition: clampSoulSiphonPosition( + [healerPosition[0] - 0.85, healerPosition[1] + 0.85], + 0.2, + arenaLayout, + ), wardPosition, wardRadius: SOUL_SIPHON.wardRadius, nextDamageAt: time + SOUL_SIPHON.tickInterval, @@ -286,6 +326,7 @@ function beginPoolMechanic( positions: BossMechanicContext["partyPositions"], time: number, requestedId: BossMechanicId, + arenaLayout: BossMechanicContext["arenaLayout"], ) { const count = motion.poolMechanicCount + 1; const entry = MECHANIC_COPY_BY_ID[requestedId]; @@ -334,10 +375,16 @@ function beginPoolMechanic( telegraphs = [soak]; } else if (entry.id === "memory-sequence") { targetId = "aelia"; - telegraphs = [memorySequenceTelegraph(`pool-memory-${count}`, motion.position, count, time)]; + telegraphs = [memorySequenceTelegraph(`pool-memory-${count}`, motion.position, count, time, arenaLayout)]; } else if (entry.id === "soul-siphon") { targetId = "aelia"; - telegraphs = [soulSiphonTelegraph(`pool-soul-siphon-${count}`, positions.aelia, count, time)]; + telegraphs = [soulSiphonTelegraph( + `pool-soul-siphon-${count}`, + positions.aelia, + count, + time, + arenaLayout, + )]; } else { targetId = liveTarget(party, TARGET_ORDER[count % TARGET_ORDER.length]); telegraphs = [beamTelegraph(`pool-beam-${count}`, motion.position, positions[targetId], activatesAt)]; @@ -447,7 +494,7 @@ function resolveSoulSiphon( at: context.time, message: "Aelia reaches the cleansing ward. Soul Siphon collapses.", tone: "neutral", - pulseKind: "purify", + pulseKind: "cleanse", targetId: "aelia", }); return party; @@ -855,6 +902,8 @@ function laneAttackDefinition(config: LaneAttackConfig): BossMechanicDefinition } const BULL_TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"]; +const POUNCE_TARGETS: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"]; +const HOCKEY_POUNCE_TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "brann"]; const bullCharge = laneChargeDefinition({ id: "bull-charge", warning: BULL_CHARGE.warning, speed: BULL_CHARGE.speed, distance: BULL_CHARGE.distance, width: BULL_CHARGE.hitRadius * 2, damage: BULL_CHARGE.damage, knockdown: BULL_CHARGE.knockdown, cooldown: BULL_CHARGE.cooldown, targetOrder: BULL_TARGETS }); const destructionRush = laneChargeDefinition({ id: "destruction-rush", warning: 1.45, speed: 11.5, distance: 14, width: 2.5, damage: 27, knockdown: 0.55, cooldown: 3.8, targetOrder: BULL_TARGETS }); @@ -867,7 +916,10 @@ const crushingPounce: BossMechanicDefinition = { instruction: mechanicCopy("crushing-pounce").instruction, cooldown: 4, start(runtime) { - const targetId = chooseLivingTarget(runtime.party, ["aelia", "nia", "orin", "vale", "brann"], runtime.motion.mechanicCount); + const targetOrder = runtime.context.arenaLayout !== "standard" + ? HOCKEY_POUNCE_TARGETS + : POUNCE_TARGETS; + const targetId = chooseLivingTarget(runtime.party, targetOrder, runtime.motion.mechanicCount); runtime.motion.mode = "stacking"; runtime.motion.pounceTargetId = targetId; runtime.motion.pounceCenter = [...runtime.context.partyPositions[targetId]]; @@ -891,7 +943,10 @@ const crushingPounce: BossMechanicDefinition = { motion.position = moveToward(motion.position, motion.chargeEnd, 16 * context.delta); if (context.time < motion.phaseEndsAt && distance(motion.position, motion.chargeEnd) >= 0.08) return; const stackedIds = runtime.party.filter((member) => member.hp > 0 && distance(context.partyPositions[member.id], motion.pounceCenter) <= BULL_POUNCE.stackRadius).map((member) => member.id); - const damage = BULL_POUNCE.sharedDamage / Math.max(1, stackedIds.length); + const sharedDamage = context.arenaLayout !== "standard" + ? BULL_POUNCE.hockeySharedDamage + : BULL_POUNCE.sharedDamage; + const damage = sharedDamage / Math.max(1, stackedIds.length); runtime.party = runtime.party.map((member) => stackedIds.includes(member.id) ? context.damageMember(member, damage, context.partyPositions[member.id], context.time) : member); @@ -1142,7 +1197,14 @@ function telegraphDefinition(id: BossMechanicId): BossMechanicDefinition { const definition: BossMechanicDefinition = { id, name: copy.name, instruction: copy.instruction, cooldown: 5, start(runtime) { - const started = beginPoolMechanic(runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, id); + const started = beginPoolMechanic( + runtime.motion, + runtime.party, + runtime.context.partyPositions, + runtime.context.time, + id, + runtime.context.arenaLayout, + ); runtime.motion = started.motion; runtime.motion.mode = "golem_crownfall"; runtime.events.push(started.event); @@ -1180,7 +1242,7 @@ const basicMelee: BossMechanicDefinition = { advance(runtime) { applyMelee(runtime.boss, runtime.motion, runtime.party, runtime.context.partyPositions, runtime.context.time, 2.5, 15, runtime.context.damageMember); }, - animationCue: () => "idle", + animationCue: () => "attack", }; export const BOSS_MECHANIC_REGISTRY: Record = { diff --git a/src/game/bosses/shared.ts b/src/game/bosses/shared.ts index 2a19e09..edc4d61 100644 --- a/src/game/bosses/shared.ts +++ b/src/game/bosses/shared.ts @@ -94,6 +94,7 @@ export function createBaseMotion(bossId: BossId): BossMotionState { nextMechanicAt: Number.POSITIVE_INFINITY, mechanicCount: 0, phaseStartedAt: 0, + lastMeleeAt: -1, mechanicHitIds: [], mechanicNextDamageAt: {}, tetherIds: [], @@ -170,15 +171,28 @@ export function applyMelee( ) { while (boss.nextMeleeAt <= time) { if (motion.mode === "holding") { - const tankIndex = party.findIndex((member) => member.id === "brann"); - if (tankIndex >= 0 && party[tankIndex].hp > 0) { - party[tankIndex] = damageMember(party[tankIndex], amount, positions.brann, boss.nextMeleeAt); + const targetIndex = selectMeleeTargetIndex(party); + if (targetIndex >= 0) { + const target = party[targetIndex]; + party[targetIndex] = damageMember(target, amount, positions[target.id], boss.nextMeleeAt); + motion.lastMeleeAt = boss.nextMeleeAt; } } boss.nextMeleeAt += interval; } } +/** Keeps legacy Brann aggro, then fails over to another living tank or ally. */ +export function selectMeleeTargetIndex(party: readonly PartyMember[]): number { + const preferredTank = party.findIndex((member) => member.id === "brann" && member.hp > 0); + if (preferredTank >= 0) return preferredTank; + const otherTank = party.findIndex((member) => member.role === "Tank" && member.hp > 0); + if (otherTank >= 0) return otherTank; + const companion = party.findIndex((member) => member.role !== "Healer" && member.hp > 0); + if (companion >= 0) return companion; + return party.findIndex((member) => member.hp > 0); +} + export function resolveCircleHazards( motion: BossMotionState, party: PartyMember[], diff --git a/src/game/bosses/types.ts b/src/game/bosses/types.ts index 86fce65..4c886b9 100644 --- a/src/game/bosses/types.ts +++ b/src/game/bosses/types.ts @@ -16,6 +16,7 @@ export interface BossMechanicResult { } export interface BossMechanicContext { + arenaLayout: "standard" | "hockey-healing" | "hockey-healing-pvp" | "aether-assault"; boss: BossState; motion: BossMotionState; party: PartyMember[]; diff --git a/src/game/bottomTabs.test.ts b/src/game/bottomTabs.test.ts new file mode 100644 index 0000000..49c82ef --- /dev/null +++ b/src/game/bottomTabs.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { bottomTabsFor, cycleBottomTab } from "./bottomTabs"; + +describe("bottom display tabs", () => { + it("replaces Pack with PVP only during a PVP run", () => { + expect(bottomTabsFor("hockey-healing-pvp")).toEqual(["combat", "map", "pvp"]); + expect(bottomTabsFor("hockey-healing")).toEqual(["combat", "map", "pack"]); + expect(bottomTabsFor("encounter")).toEqual(["combat", "map", "pack"]); + }); + + it("cycles every controller-accessible PVP tab", () => { + expect(cycleBottomTab("combat", "hockey-healing-pvp")).toBe("map"); + expect(cycleBottomTab("map", "hockey-healing-pvp")).toBe("pvp"); + expect(cycleBottomTab("pvp", "hockey-healing-pvp")).toBe("combat"); + }); + + it("recovers to the first valid tab after a mode changes", () => { + expect(cycleBottomTab("pack", "hockey-healing-pvp")).toBe("combat"); + expect(cycleBottomTab("pvp", "encounter")).toBe("combat"); + }); +}); diff --git a/src/game/bottomTabs.ts b/src/game/bottomTabs.ts new file mode 100644 index 0000000..92d40dd --- /dev/null +++ b/src/game/bottomTabs.ts @@ -0,0 +1,15 @@ +import type { BottomTab, RunMode } from "./types"; +import { isPvpRunMode } from "./runModes"; + +const STANDARD_BOTTOM_TABS = ["combat", "map", "pack"] as const satisfies readonly BottomTab[]; +const PVP_BOTTOM_TABS = ["combat", "map", "pvp"] as const satisfies readonly BottomTab[]; + +export function bottomTabsFor(runMode: RunMode): readonly BottomTab[] { + return isPvpRunMode(runMode) ? PVP_BOTTOM_TABS : STANDARD_BOTTOM_TABS; +} + +export function cycleBottomTab(activeTab: BottomTab, runMode: RunMode): BottomTab { + const tabs = bottomTabsFor(runMode); + const currentIndex = tabs.indexOf(activeTab); + return tabs[(currentIndex + 1) % tabs.length]; +} diff --git a/src/game/characterAppearance.test.ts b/src/game/characterAppearance.test.ts new file mode 100644 index 0000000..bf9bd5d --- /dev/null +++ b/src/game/characterAppearance.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + CHARACTER_PART_CATALOG, + CHARACTER_WEAPON_GRIP_BY_MODEL, + characterAppearancePartIds, + normalizeCharacterAppearance, + resolveCharacterModelMode, + type CharacterAppearanceV1, + type CharacterPartSlot, +} from "./characterAppearance"; +import { createDefaultHealerAppearance, HEALER_VISUAL_PROFILES } from "./healerVisuals"; + +const EXPECTED_SLOTS: readonly CharacterPartSlot[] = ["head", "upper-body", "lower-body"]; + +describe("modular character appearance", () => { + it("defaults to modular rendering and supports query or build rollback", () => { + expect(resolveCharacterModelMode({})).toBe("modular"); + expect(resolveCharacterModelMode({ search: "?characterModels=legacy" })).toBe("legacy"); + expect(resolveCharacterModelMode({ search: "?legacyCharacterModels" })).toBe("legacy"); + expect(resolveCharacterModelMode({ envMode: "legacy", search: "?characterModels=modular" })).toBe("legacy"); + }); + + it("gives every healer a valid versioned appearance", () => { + for (const [classId, profile] of Object.entries(HEALER_VISUAL_PROFILES)) { + const appearance: CharacterAppearanceV1 = profile.appearance; + expect(appearance.version, classId).toBe(1); + expect(appearance.rigId, classId).toBe("medium"); + const slots = characterAppearancePartIds(appearance) + .map((partId) => CHARACTER_PART_CATALOG[partId].slot); + for (const requiredSlot of EXPECTED_SLOTS) expect(slots, `${classId}:${requiredSlot}`).toContain(requiredSlot); + expect(new Set(slots).size, classId).toBe(slots.length); + } + }); + + it("keeps every catalog part inside one declared slot", () => { + for (const [partId, definition] of Object.entries(CHARACTER_PART_CATALOG)) { + expect(definition.nodeNames.length, partId).toBeGreaterThan(0); + expect(new Set(definition.nodeNames).size, partId).toBe(definition.nodeNames.length); + } + }); + + it("normalizes mixed appearances and canonicalizes every weapon grip", () => { + const fallback = createDefaultHealerAppearance("priest"); + const mixed = normalizeCharacterAppearance({ + version: 1, + rigId: "medium", + scaleSourceMemberId: "brann", + headPartId: "rogue-head", + upperBodyPartId: "knight-upper", + lowerBodyPartId: "ranger-lower", + headwearPartId: "mage-hat", + backPartId: "druid-backpack", + mainHand: { modelId: "sword", grip: "staff" }, + offHand: { modelId: "spellbook", grip: "sword" }, + }, fallback); + + expect(mixed).toEqual({ + version: 1, + rigId: "medium", + scaleSourceMemberId: "brann", + headPartId: "rogue-head", + upperBodyPartId: "knight-upper", + lowerBodyPartId: "ranger-lower", + headwearPartId: "mage-hat", + backPartId: "druid-backpack", + mainHand: { modelId: "cc/adv_sword_1handed", grip: "upright" }, + offHand: { modelId: "cc/spellbook_open", grip: "prop" }, + }); + expect(mixed).not.toBe(fallback); + expect(mixed.mainHand).not.toBe(fallback.mainHand); + expect(Object.keys(CHARACTER_WEAPON_GRIP_BY_MODEL)).toHaveLength(54); + expect(CHARACTER_WEAPON_GRIP_BY_MODEL).toMatchObject({ + "cc/adv_druid_staff": "staff", + "cc/adv_sword_1handed": "upright", + "cc/shield_badge": "prop", + "cc/adv_wand": "wand", + "cc/adv_dagger": "dagger", + "cc/crossbow_2handed": "crossbow", + }); + }); + + it("repairs invalid fields without discarding safe choices", () => { + const fallback = createDefaultHealerAppearance("paladin"); + const normalized = normalizeCharacterAppearance({ + version: 1, + rigId: "medium", + scaleSourceMemberId: "intruder", + headPartId: "mage-upper", + upperBodyPartId: "rogue-upper", + lowerBodyPartId: "toString", + headwearPartId: "druid-backpack", + backPartId: null, + mainHand: { modelId: "wand", grip: "invalid-grip" }, + offHand: { modelId: "toString", grip: "prop" }, + }, fallback); + + expect(normalized).toMatchObject({ + scaleSourceMemberId: fallback.scaleSourceMemberId, + headPartId: fallback.headPartId, + upperBodyPartId: "rogue-upper", + lowerBodyPartId: fallback.lowerBodyPartId, + headwearPartId: fallback.headwearPartId, + backPartId: null, + mainHand: { modelId: "cc/adv_wand", grip: "wand" }, + }); + expect(normalized.offHand).toEqual(fallback.offHand); + }); + + it("falls back as one unit for unknown appearance versions or rigs", () => { + const fallback = createDefaultHealerAppearance("druid"); + expect(normalizeCharacterAppearance({ ...fallback, version: 2 }, fallback)).toEqual(fallback); + expect(normalizeCharacterAppearance({ ...fallback, rigId: "large" }, fallback)).toEqual(fallback); + expect(normalizeCharacterAppearance(null, fallback)).toEqual(fallback); + expect(normalizeCharacterAppearance(null, fallback)).not.toBe(fallback); + }); +}); diff --git a/src/game/characterAppearance.ts b/src/game/characterAppearance.ts new file mode 100644 index 0000000..9ad0a39 --- /dev/null +++ b/src/game/characterAppearance.ts @@ -0,0 +1,210 @@ +import type { MemberId } from "./types"; +import { + CHARACTER_WEAPON_MODEL_IDS, + heldItemForModel, + resolveCharacterWeaponModelId, + weaponDefinition, + weaponSupportsSlot, + type CharacterHeldItemVisual, + type CharacterWeaponGrip, + type CharacterWeaponModelId, +} from "./weaponCatalog"; + +export type { + CharacterHeldItemVisual, + CharacterWeaponGrip, + CharacterWeaponModelId, +} from "./weaponCatalog"; + +/** Compatibility map for callers that still need direct model-to-grip lookup. */ +export const CHARACTER_WEAPON_GRIP_BY_MODEL = Object.fromEntries( + CHARACTER_WEAPON_MODEL_IDS.map((modelId) => [modelId, weaponDefinition(modelId).grip]), +) as Record; + +export type CharacterPartSlot = "head" | "upper-body" | "lower-body" | "headwear" | "back"; + +export interface CharacterPartDefinition { + slot: CharacterPartSlot; + sourceMemberId: MemberId; + nodeNames: readonly string[]; +} + +export const CHARACTER_PART_CATALOG = { + "druid-head": { slot: "head", sourceMemberId: "aelia", nodeNames: ["Druid_Head"] }, + "mage-head": { slot: "head", sourceMemberId: "orin", nodeNames: ["Mage_Head"] }, + "ranger-head": { slot: "head", sourceMemberId: "nia", nodeNames: ["Ranger_Head"] }, + "knight-head": { slot: "head", sourceMemberId: "brann", nodeNames: ["Knight_Head"] }, + "rogue-head": { slot: "head", sourceMemberId: "vale", nodeNames: ["Rogue_Head"] }, + + "druid-upper": { slot: "upper-body", sourceMemberId: "aelia", nodeNames: ["Druid_ArmLeft", "Druid_ArmRight", "Druid_Body"] }, + "mage-upper": { slot: "upper-body", sourceMemberId: "orin", nodeNames: ["Mage_ArmLeft", "Mage_ArmRight", "Mage_Body"] }, + "ranger-upper": { slot: "upper-body", sourceMemberId: "nia", nodeNames: ["Ranger_ArmLeft", "Ranger_ArmRight", "Ranger_Body"] }, + "knight-upper": { slot: "upper-body", sourceMemberId: "brann", nodeNames: ["Knight_ArmLeft", "Knight_ArmRight", "Knight_Body"] }, + "rogue-upper": { slot: "upper-body", sourceMemberId: "vale", nodeNames: ["Rogue_ArmLeft", "Rogue_ArmRight", "Rogue_Body"] }, + + "druid-lower": { slot: "lower-body", sourceMemberId: "aelia", nodeNames: ["Druid_LegLeft", "Druid_LegRight"] }, + "mage-lower": { slot: "lower-body", sourceMemberId: "orin", nodeNames: ["Mage_LegLeft", "Mage_LegRight"] }, + "ranger-lower": { slot: "lower-body", sourceMemberId: "nia", nodeNames: ["Ranger_LegLeft", "Ranger_LegRight"] }, + "knight-lower": { slot: "lower-body", sourceMemberId: "brann", nodeNames: ["Knight_LegLeft", "Knight_LegRight"] }, + "rogue-lower": { slot: "lower-body", sourceMemberId: "vale", nodeNames: ["Rogue_LegLeft", "Rogue_LegRight"] }, + + "mage-hat": { slot: "headwear", sourceMemberId: "orin", nodeNames: ["Mage_Hat"] }, + "knight-helmet": { slot: "headwear", sourceMemberId: "brann", nodeNames: ["Knight_Helmet", "Knight_HelmetVisor"] }, + + "druid-backpack": { slot: "back", sourceMemberId: "aelia", nodeNames: ["Druid_Backpack"] }, + "mage-cape": { slot: "back", sourceMemberId: "orin", nodeNames: ["Mage_Cape"] }, + "ranger-cape": { slot: "back", sourceMemberId: "nia", nodeNames: ["Ranger_Cape"] }, + "ranger-quiver": { slot: "back", sourceMemberId: "nia", nodeNames: ["Ranger_Quiver"] }, + "knight-cape": { slot: "back", sourceMemberId: "brann", nodeNames: ["Knight_Cape"] }, + "rogue-cape": { slot: "back", sourceMemberId: "vale", nodeNames: ["Rogue_Cape"] }, +} as const satisfies Record; + +export type CharacterPartId = keyof typeof CHARACTER_PART_CATALOG; +export type CharacterPartIdFor = { + [PartId in CharacterPartId]: typeof CHARACTER_PART_CATALOG[PartId]["slot"] extends Slot ? PartId : never; +}[CharacterPartId]; + +export interface CharacterAppearanceV1 { + version: 1; + rigId: "medium"; + scaleSourceMemberId: MemberId; + headPartId: CharacterPartIdFor<"head">; + upperBodyPartId: CharacterPartIdFor<"upper-body">; + lowerBodyPartId: CharacterPartIdFor<"lower-body">; + headwearPartId: CharacterPartIdFor<"headwear"> | null; + backPartId: CharacterPartIdFor<"back"> | null; + mainHand: CharacterHeldItemVisual; + offHand?: CharacterHeldItemVisual; +} + +const MEMBER_IDS: readonly MemberId[] = ["aelia", "brann", "nia", "orin", "vale"]; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isMemberId(value: unknown): value is MemberId { + return typeof value === "string" && MEMBER_IDS.includes(value as MemberId); +} + +function canonicalHeldItem( + value: unknown, + fallback: CharacterHeldItemVisual, + slot: "main" | "off", +): CharacterHeldItemVisual { + const requestedModelId = isRecord(value) + ? resolveCharacterWeaponModelId(value.modelId) + : null; + const modelId = requestedModelId && weaponSupportsSlot(requestedModelId, slot) + ? requestedModelId + : fallback.modelId; + return heldItemForModel(modelId); +} + +function canonicalOptionalOffHand( + value: unknown, + fallback?: CharacterHeldItemVisual, +): CharacterHeldItemVisual | undefined { + if (value === undefined || value === null) return undefined; + const requestedModelId = isRecord(value) + ? resolveCharacterWeaponModelId(value.modelId) + : null; + if (requestedModelId && weaponSupportsSlot(requestedModelId, "off")) { + return heldItemForModel(requestedModelId); + } + return fallback ? heldItemForModel(fallback.modelId) : undefined; +} + +function partForSlot( + value: unknown, + slot: Slot, +): CharacterPartIdFor | null { + if (typeof value !== "string" + || !Object.prototype.hasOwnProperty.call(CHARACTER_PART_CATALOG, value)) return null; + const partId = value as CharacterPartId; + return CHARACTER_PART_CATALOG[partId].slot === slot + ? partId as CharacterPartIdFor + : null; +} + +function optionalPartForSlot( + value: unknown, + slot: Slot, + fallback: CharacterPartIdFor | null, +): CharacterPartIdFor | null { + if (value === null) return null; + return partForSlot(value, slot) ?? fallback; +} + +export function cloneCharacterAppearance(appearance: CharacterAppearanceV1): CharacterAppearanceV1 { + return { + ...appearance, + mainHand: heldItemForModel(appearance.mainHand.modelId), + ...(appearance.offHand + ? { offHand: heldItemForModel(appearance.offHand.modelId) } + : {}), + }; +} + +/** + * Repairs untrusted save/API data before it can select runtime model assets. + * Appearance has its own version so this backward-compatible save field does not + * require a hunter-save schema bump. + */ +export function normalizeCharacterAppearance( + value: unknown, + fallback: CharacterAppearanceV1, +): CharacterAppearanceV1 { + if (!isRecord(value) || value.version !== 1 || value.rigId !== "medium") { + return cloneCharacterAppearance(fallback); + } + + const mainHand = canonicalHeldItem(value.mainHand, fallback.mainHand, "main"); + const offHand = canonicalOptionalOffHand(value.offHand, fallback.offHand); + + return { + version: 1, + rigId: "medium", + scaleSourceMemberId: isMemberId(value.scaleSourceMemberId) + ? value.scaleSourceMemberId + : fallback.scaleSourceMemberId, + headPartId: partForSlot(value.headPartId, "head") ?? fallback.headPartId, + upperBodyPartId: partForSlot(value.upperBodyPartId, "upper-body") ?? fallback.upperBodyPartId, + lowerBodyPartId: partForSlot(value.lowerBodyPartId, "lower-body") ?? fallback.lowerBodyPartId, + headwearPartId: optionalPartForSlot(value.headwearPartId, "headwear", fallback.headwearPartId), + backPartId: optionalPartForSlot(value.backPartId, "back", fallback.backPartId), + mainHand, + ...(offHand ? { offHand } : {}), + }; +} + +export type CharacterModelMode = "modular" | "legacy"; + +export function resolveCharacterModelMode({ + envMode, + search, +}: { + envMode?: string; + search?: string; +}): CharacterModelMode { + if (envMode?.toLowerCase() === "legacy") return "legacy"; + const query = new URLSearchParams(search ?? ""); + return query.get("characterModels") === "legacy" || query.has("legacyCharacterModels") + ? "legacy" + : "modular"; +} + +export const CHARACTER_MODEL_MODE = resolveCharacterModelMode({ + envMode: import.meta.env.VITE_CHARACTER_MODEL_MODE, + search: typeof window === "undefined" ? "" : window.location.search, +}); + +export function characterAppearancePartIds(appearance: CharacterAppearanceV1): CharacterPartId[] { + return [ + appearance.headPartId, + appearance.upperBodyPartId, + appearance.lowerBodyPartId, + ...(appearance.headwearPartId ? [appearance.headwearPartId] : []), + ...(appearance.backPartId ? [appearance.backPartId] : []), + ]; +} diff --git a/src/game/characterEquipment.test.ts b/src/game/characterEquipment.test.ts new file mode 100644 index 0000000..4505f8d --- /dev/null +++ b/src/game/characterEquipment.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { createDefaultHealerAppearance } from "./healerVisuals"; +import { heldItemForModel, weaponDefinitionsForSlot } from "./weaponCatalog"; +import { resolveCharacterEquipment } from "./characterEquipment"; + +describe("character equipment resolution", () => { + it("hides but never deletes a saved offhand behind a two-handed weapon", () => { + const appearance = { + ...createDefaultHealerAppearance("paladin"), + mainHand: heldItemForModel("cc/staff_d"), + }; + const resolved = resolveCharacterEquipment(appearance); + expect(resolved.offHand).toBeUndefined(); + expect(appearance.offHand).toEqual(heldItemForModel("cc/shield_badge")); + + const restored = resolveCharacterEquipment({ + ...appearance, + mainHand: heldItemForModel("cc/adv_sword_1handed"), + }); + expect(restored.offHand).toEqual(appearance.offHand); + }); + + it("pairs every crossbow with the loose Claudecraft quiver only at render time", () => { + const base = createDefaultHealerAppearance("priest"); + const crossbows = weaponDefinitionsForSlot("main").filter((entry) => entry.category === "crossbow"); + expect(crossbows).toHaveLength(3); + for (const crossbow of crossbows) { + const appearance = { ...base, mainHand: heldItemForModel(crossbow.id) }; + expect(resolveCharacterEquipment(appearance)).toMatchObject({ + backPropModelId: "cc/quiver", + suppressSkinnedBack: true, + }); + expect(appearance.backPartId).toBe(base.backPartId); + } + expect(resolveCharacterEquipment(base).backPropModelId).toBeUndefined(); + }); +}); diff --git a/src/game/characterEquipment.ts b/src/game/characterEquipment.ts new file mode 100644 index 0000000..ae6db1e --- /dev/null +++ b/src/game/characterEquipment.ts @@ -0,0 +1,24 @@ +import type { CharacterAppearanceV1, CharacterHeldItemVisual } from "./characterAppearance"; +import { + weaponDefinition, + weaponUsesBothHands, + type CharacterBackPropModelId, +} from "./weaponCatalog"; + +export interface ResolvedCharacterEquipment { + mainHand: CharacterHeldItemVisual; + /** Effective render choice. The saved offhand remains untouched while hidden. */ + offHand?: CharacterHeldItemVisual; + backPropModelId?: CharacterBackPropModelId; + suppressSkinnedBack: boolean; +} + +export function resolveCharacterEquipment(appearance: CharacterAppearanceV1): ResolvedCharacterEquipment { + const crossbowEquipped = weaponDefinition(appearance.mainHand.modelId).category === "crossbow"; + return { + mainHand: appearance.mainHand, + ...(weaponUsesBothHands(appearance.mainHand.modelId) ? {} : appearance.offHand ? { offHand: appearance.offHand } : {}), + ...(crossbowEquipped ? { backPropModelId: "cc/quiver" as const } : {}), + suppressSkinnedBack: crossbowEquipped, + }; +} diff --git a/src/game/controllerBindings.test.ts b/src/game/controllerBindings.test.ts index c84ba1e..d67d2e1 100644 --- a/src/game/controllerBindings.test.ts +++ b/src/game/controllerBindings.test.ts @@ -4,12 +4,12 @@ import { ABILITY_BY_CONTROLLER_BUTTON, ABILITY_CONTROLLER_BINDINGS } from "./con describe("PlayStation controller ability bindings", () => { it("keeps prompts aligned with standard gamepad button indices", () => { expect(ABILITY_BY_CONTROLLER_BUTTON).toEqual({ - 0: "purify", - 1: "shield", - 2: "mend", - 3: "renew", - 4: "radiance", - 5: "barrier", + 0: "ability4", + 1: "ability3", + 2: "ability1", + 3: "ability2", + 4: "ability5", + 5: "ability6", }); expect(Object.values(ABILITY_CONTROLLER_BINDINGS).map(({ glyph }) => glyph)).toEqual([ "□", diff --git a/src/game/controllerBindings.ts b/src/game/controllerBindings.ts index 22d21aa..535b470 100644 --- a/src/game/controllerBindings.ts +++ b/src/game/controllerBindings.ts @@ -1,20 +1,20 @@ import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; -import type { AbilityId } from "./types"; +import type { AbilitySlotId } from "./types"; interface AbilityControllerBinding { buttonIndex: number; glyph: string; } -export const ABILITY_CONTROLLER_BINDINGS: Record = { - mend: { buttonIndex: 2, glyph: DEFAULT_CONTROLLER_GLYPHS.faceLeft }, - renew: { buttonIndex: 3, glyph: DEFAULT_CONTROLLER_GLYPHS.faceTop }, - shield: { buttonIndex: 1, glyph: DEFAULT_CONTROLLER_GLYPHS.faceRight }, - purify: { buttonIndex: 0, glyph: DEFAULT_CONTROLLER_GLYPHS.faceBottom }, - radiance: { buttonIndex: 4, glyph: DEFAULT_CONTROLLER_GLYPHS.leftShoulder }, - barrier: { buttonIndex: 5, glyph: DEFAULT_CONTROLLER_GLYPHS.rightShoulder }, +export const ABILITY_CONTROLLER_BINDINGS: Record = { + ability1: { buttonIndex: 2, glyph: DEFAULT_CONTROLLER_GLYPHS.faceLeft }, + ability2: { buttonIndex: 3, glyph: DEFAULT_CONTROLLER_GLYPHS.faceTop }, + ability3: { buttonIndex: 1, glyph: DEFAULT_CONTROLLER_GLYPHS.faceRight }, + ability4: { buttonIndex: 0, glyph: DEFAULT_CONTROLLER_GLYPHS.faceBottom }, + ability5: { buttonIndex: 4, glyph: DEFAULT_CONTROLLER_GLYPHS.leftShoulder }, + ability6: { buttonIndex: 5, glyph: DEFAULT_CONTROLLER_GLYPHS.rightShoulder }, }; export const ABILITY_BY_CONTROLLER_BUTTON = Object.fromEntries( Object.entries(ABILITY_CONTROLLER_BINDINGS).map(([abilityId, binding]) => [binding.buttonIndex, abilityId]), -) as Partial>; +) as Partial>; diff --git a/src/game/data.ts b/src/game/data.ts index cf6af4e..cf40c18 100644 --- a/src/game/data.ts +++ b/src/game/data.ts @@ -1,17 +1,17 @@ import { HEALER_CLASSES } from "./healers"; -import type { AbilityId, HealerClassId, PartyMember } from "./types"; +import type { AbilitySlotId, HealerClassId, PartyMember } from "./types"; export const ABILITIES = HEALER_CLASSES.priest.abilities; -export const ABILITY_ORDER: AbilityId[] = ["mend", "renew", "shield", "purify", "radiance", "barrier"]; +export const ABILITY_ORDER: AbilitySlotId[] = ["ability1", "ability2", "ability3", "ability4", "ability5", "ability6"]; export function freshParty(classId: HealerClassId = "priest", playerName = "Aelia"): PartyMember[] { const healer = HEALER_CLASSES[classId]; return [ - { id: "aelia", name: playerName, className: healer.specialization, role: "Healer", color: healer.color, maxHp: 100, hp: 100, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] }, - { id: "brann", name: "Brann", className: "Knight", role: "Tank", color: "#69a8dd", maxHp: 150, hp: 150, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] }, - { id: "nia", name: "Nia", className: "Ranger", role: "Damage", color: "#74c987", maxHp: 94, hp: 94, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] }, - { id: "orin", name: "Orin", className: "Mage", role: "Damage", color: "#b17ee6", maxHp: 86, hp: 86, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] }, - { id: "vale", name: "Vale", className: "Rogue", role: "Damage", color: "#d97171", maxHp: 92, hp: 92, absorb: 0, renewExpiresAt: 0, renewNextTickAt: 0, knockedUntil: 0, debuffs: [] }, + { id: "aelia", name: playerName, className: healer.specialization, role: "Healer", color: healer.color, maxHp: 100, hp: 100, absorb: 0, healingEffects: [], reactiveHeal: null, knockedUntil: 0, debuffs: [] }, + { id: "brann", name: "Brann", className: "Knight", role: "Tank", color: "#69a8dd", maxHp: 150, hp: 150, absorb: 0, healingEffects: [], reactiveHeal: null, knockedUntil: 0, debuffs: [] }, + { id: "nia", name: "Nia", className: "Ranger", role: "Damage", color: "#74c987", maxHp: 94, hp: 94, absorb: 0, healingEffects: [], reactiveHeal: null, knockedUntil: 0, debuffs: [] }, + { id: "orin", name: "Orin", className: "Mage", role: "Damage", color: "#b17ee6", maxHp: 86, hp: 86, absorb: 0, healingEffects: [], reactiveHeal: null, knockedUntil: 0, debuffs: [] }, + { id: "vale", name: "Vale", className: "Rogue", role: "Damage", color: "#d97171", maxHp: 92, hp: 92, absorb: 0, healingEffects: [], reactiveHeal: null, knockedUntil: 0, debuffs: [] }, ]; } diff --git a/src/game/healerClasses.test.ts b/src/game/healerClasses.test.ts new file mode 100644 index 0000000..0a40f75 --- /dev/null +++ b/src/game/healerClasses.test.ts @@ -0,0 +1,291 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { healingEffect } from "./healerEffects"; +import { createClassInventory } from "./healers"; +import { barrierProtects, useGameStore } from "./store"; +import type { HealerClassId, MemberId, WorldPosition } from "./types"; + +function startQuietEncounter(classId: HealerClassId) { + useGameStore.getState().configureHealer(classId, "Aelia", createClassInventory(classId)); + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, nextMeleeAt: 999 }, + bossMotion: { ...state.bossMotion, nextMechanicAt: 999 }, + })); +} + +function member(id: MemberId) { + return useGameStore.getState().party.find((candidate) => candidate.id === id)!; +} + +function setHealth(values: Partial>) { + useGameStore.setState((state) => ({ + party: state.party.map((candidate) => values[candidate.id] === undefined + ? candidate + : { ...candidate, hp: values[candidate.id]! }), + })); +} + +describe("Restoration Druid combat kit", () => { + beforeEach(() => startQuietEncounter("druid")); + + it("generates Verdancy from healing ticks and spends it on Regrowth", () => { + setHealth({ brann: 50 }); + expect(useGameStore.getState().castAbility("ability2")).toBe(true); + useGameStore.getState().tick(1.01); + expect(useGameStore.getState().healerMechanic.resource).toBe(1); + + expect(useGameStore.getState().castAbility("ability1")).toBe(true); + expect(useGameStore.getState().activeCast?.resourceSpent).toBe(1); + expect(useGameStore.getState().healerMechanic.resource).toBe(0); + useGameStore.getState().tick(0.51); + + expect(member("brann").hp).toBeCloseTo(87); + expect(healingEffect(member("brann"), "regrowth")).toBeDefined(); + }); + + it("lets Nature's Cure trigger every active healing effect immediately", () => { + setHealth({ brann: 50 }); + useGameStore.getState().castAbility("ability2"); + useGameStore.getState().tick(0.51); + useGameStore.setState((state) => ({ + party: state.party.map((candidate) => candidate.id === "brann" + ? { ...candidate, debuffs: [{ id: "test", name: "Test Hex", expiresAt: 10, nextTickAt: 9, tickDamage: 1 }] } + : candidate), + })); + + expect(useGameStore.getState().castAbility("ability4")).toBe(true); + expect(member("brann").hp).toBe(56); + expect(member("brann").debuffs).toEqual([]); + }); + + it("stacks Lifebloom, then spends Verdancy to force its bloom", () => { + setHealth({ brann: 40 }); + for (let stack = 1; stack <= 3; stack += 1) { + expect(useGameStore.getState().castAbility("ability3")).toBe(true); + expect(healingEffect(member("brann"), "lifebloom")?.stacks).toBe(stack); + useGameStore.getState().tick(0.51); + } + useGameStore.setState((state) => ({ + party: state.party.map((candidate) => candidate.id === "brann" ? { ...candidate, hp: 40 } : candidate), + healerMechanic: { ...state.healerMechanic, resource: 2 }, + })); + + expect(useGameStore.getState().castAbility("ability3")).toBe(true); + expect(healingEffect(member("brann"), "lifebloom")).toBeUndefined(); + expect(member("brann").hp).toBe(70); + expect(useGameStore.getState().healerMechanic.resource).toBe(0); + }); + + it("targets only three most injured allies with Wild Growth", () => { + setHealth({ aelia: 90, brann: 40, nia: 20, orin: 30, vale: 80 }); + expect(useGameStore.getState().castAbility("ability5")).toBe(true); + const grown = useGameStore.getState().party + .filter((candidate) => healingEffect(candidate, "wild-growth")) + .map((candidate) => candidate.id); + expect(grown).toEqual(["brann", "nia", "orin"]); + }); + + it("extends active growth and accelerates its next tick with Flourish", () => { + setHealth({ brann: 50 }); + useGameStore.getState().castAbility("ability2"); + useGameStore.getState().tick(0.51); + expect(useGameStore.getState().castAbility("ability6")).toBe(true); + + const effect = healingEffect(member("brann"), "rejuvenation")!; + expect(effect.expiresAt).toBe(13); + expect(effect.nextTickAt).toBeCloseTo(1); + expect(useGameStore.getState().healerMechanic.flourishExpiresAt).toBeCloseTo(6.51); + useGameStore.getState().tick(0.5); + expect(healingEffect(member("brann"), "rejuvenation")?.nextTickAt).toBeCloseTo(1.5); + }); +}); + +describe("Restoration Shaman combat kit", () => { + beforeEach(() => startQuietEncounter("shaman")); + + it("builds Tidal Surge with Riptide and spends it on Healing Wave", () => { + setHealth({ brann: 40 }); + expect(useGameStore.getState().castAbility("ability2")).toBe(true); + expect(useGameStore.getState().healerMechanic.resource).toBe(1); + useGameStore.getState().tick(0.51); + + expect(useGameStore.getState().castAbility("ability1")).toBe(true); + expect(useGameStore.getState().activeCast?.resourceSpent).toBe(1); + expect(useGameStore.getState().activeCast?.completesAt).toBeCloseTo(0.51 + 0.325); + useGameStore.getState().tick(0.34); + + expect(member("brann").hp).toBeCloseTo(107.9); + expect(useGameStore.getState().healerMechanic.resource).toBe(0); + }); + + it("consumes Earth Shield after damage and generates Tidal Surge", () => { + expect(useGameStore.getState().castAbility("ability3")).toBe(true); + useGameStore.getState().tick(0.51); + useGameStore.setState((state) => ({ + party: state.party.map((candidate) => candidate.id === "brann" + ? { ...candidate, debuffs: [{ id: "shock", name: "Shock", expiresAt: 2, nextTickAt: 0.6, tickDamage: 20 }] } + : candidate), + })); + + useGameStore.getState().tick(0.1); + expect(member("brann").reactiveHeal?.charges).toBe(5); + expect(member("brann").hp).toBeLessThan(member("brann").maxHp); + expect(useGameStore.getState().healerMechanic.resource).toBe(1); + }); + + it("uses Tidal Surge to add positional Chain Heal jumps", () => { + const partyPositions: Record = { + brann: [0, 0], nia: [0, 1], orin: [0, 2], aelia: [0, 3], vale: [20, 20], + }; + setHealth({ aelia: 40, brann: 40, nia: 20, orin: 20, vale: 20 }); + useGameStore.setState({ partyPositions }); + useGameStore.getState().castAbility("ability2"); + useGameStore.getState().tick(0.51); + useGameStore.setState({ partyPositions }); + const valeBefore = member("vale").hp; + + expect(useGameStore.getState().castAbility("ability5")).toBe(true); + expect(useGameStore.getState().healerMechanic.resource).toBe(0); + expect(member("brann").hp).toBeGreaterThan(52); + expect(member("nia").hp).toBeGreaterThan(20); + expect(member("orin").hp).toBeGreaterThan(20); + expect(member("aelia").hp).toBeGreaterThan(40); + expect(member("vale").hp).toBe(valeBefore); + }); + + it("equalizes nearby health percentages without granting Barrier reduction", () => { + const partyPositions: Record = { + aelia: [0, 0], brann: [0, 1], nia: [0, 2], orin: [20, 20], vale: [20, 21], + }; + useGameStore.setState((state) => ({ + partyPositions, + party: state.party.map((candidate) => candidate.id === "aelia" + ? { ...candidate, hp: 20 } + : candidate.id === "brann" + ? { ...candidate, hp: 120 } + : candidate.id === "nia" + ? { ...candidate, hp: 47 } + : candidate), + })); + + expect(useGameStore.getState().castAbility("ability6")).toBe(true); + expect(barrierProtects([0, 0], useGameStore.getState().barrier, 1)).toBe(false); + useGameStore.getState().tick(1.01); + + expect(member("aelia").hp / member("aelia").maxHp).toBeCloseTo(0.5); + expect(member("brann").hp / member("brann").maxHp).toBeCloseTo(0.5); + expect(member("nia").hp / member("nia").maxHp).toBeCloseTo(0.5); + }); +}); + +describe("Dawnforged Paladin combat kit", () => { + beforeEach(() => startQuietEncounter("paladin")); + + it("echoes single-target healing through Beacon of Light", () => { + setHealth({ brann: 50, nia: 30 }); + expect(useGameStore.getState().castAbility("ability3")).toBe(true); + useGameStore.getState().tick(0.51); + useGameStore.getState().selectMember("nia"); + + expect(useGameStore.getState().castAbility("ability1")).toBe(true); + useGameStore.getState().tick(0.56); + + expect(member("nia").hp).toBeCloseTo(64); + expect(member("brann").hp).toBeCloseTo(63.6); + }); + + it("turns Crusader Strike into boss damage, triage healing, and Conviction", () => { + setHealth({ brann: 40 }); + const bossHp = useGameStore.getState().boss.hp; + + expect(useGameStore.getState().castAbility("ability2")).toBe(true); + + expect(useGameStore.getState().boss.hp).toBe(bossHp - 18); + expect(member("brann").hp).toBe(58); + expect(useGameStore.getState().healerMechanic.resource).toBe(1); + }); + + it("spends full Conviction on Word of Glory and splashes party healing", () => { + setHealth({ brann: 30, nia: 40 }); + useGameStore.setState((state) => ({ + healerMechanic: { ...state.healerMechanic, resource: 3 }, + })); + + expect(useGameStore.getState().castAbility("ability5")).toBe(true); + + expect(useGameStore.getState().healerMechanic.resource).toBe(0); + expect(member("brann").hp).toBe(98); + expect(member("nia").hp).toBe(48); + }); + + it("converts party attack damage into triage healing during Avenging Crusader", () => { + setHealth({ brann: 20 }); + expect(useGameStore.getState().castAbility("ability6")).toBe(true); + useGameStore.getState().tick(2); + + expect(useGameStore.getState().partyDamageEvents.length).toBeGreaterThan(0); + expect(member("brann").hp).toBeGreaterThan(20); + }); +}); + +describe("Continuum Chronomancer combat kit", () => { + beforeEach(() => startQuietEncounter("chronomancer")); + + it("anchors health, rewinds later damage, and gains a Chronoshard", () => { + setHealth({ brann: 80 }); + expect(useGameStore.getState().castAbility("ability2")).toBe(true); + useGameStore.getState().tick(0.51); + setHealth({ brann: 35 }); + + expect(useGameStore.getState().castAbility("ability2")).toBe(true); + + expect(member("brann").hp).toBe(80); + expect(useGameStore.getState().healerMechanic.resource).toBe(1); + expect(useGameStore.getState().healerMechanic.temporalAnchor).toBeNull(); + }); + + it("schedules Echo of Tomorrow without front-loading its delayed heal", () => { + setHealth({ brann: 30 }); + expect(useGameStore.getState().castAbility("ability3")).toBe(true); + expect(member("brann").hp).toBe(40); + + useGameStore.getState().tick(2); + useGameStore.getState().tick(0.9); + expect(member("brann").hp).toBe(40); + useGameStore.getState().tick(0.11); + + expect(member("brann").hp).toBe(68); + expect(healingEffect(member("brann"), "temporal-echo")).toBeUndefined(); + }); + + it("spends Chronoshards to heal the party and advance cooldowns", () => { + setHealth({ aelia: 70, brann: 50, nia: 40 }); + useGameStore.setState((state) => ({ + healerMechanic: { ...state.healerMechanic, resource: 3 }, + cooldowns: { ...state.cooldowns, ability3: 10 }, + })); + + expect(useGameStore.getState().castAbility("ability5")).toBe(true); + + expect(useGameStore.getState().healerMechanic.resource).toBe(0); + expect(useGameStore.getState().cooldowns.ability3).toBe(7); + expect(member("aelia").hp).toBe(100); + expect(member("brann").hp).toBe(84); + expect(member("nia").hp).toBe(74); + }); + + it("restores only health lost during Time Loop and never resurrects", () => { + setHealth({ aelia: 90, brann: 100, nia: 80 }); + expect(useGameStore.getState().castAbility("ability6")).toBe(true); + setHealth({ aelia: 30, brann: 50, nia: 0 }); + + useGameStore.getState().tick(2); + useGameStore.getState().tick(2); + useGameStore.getState().tick(2.01); + + expect(member("aelia").hp).toBe(90); + expect(member("brann").hp).toBe(100); + expect(member("nia").hp).toBe(0); + expect(useGameStore.getState().healerMechanic.timeLoop).toBeNull(); + }); +}); diff --git a/src/game/healerEffects.test.ts b/src/game/healerEffects.test.ts new file mode 100644 index 0000000..eaa306b --- /dev/null +++ b/src/game/healerEffects.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { freshParty } from "./data"; +import { advanceHealingEffects } from "./healerEffects"; + +describe("healing effect advancement", () => { + it("does not count resource-generating healing ticks on defeated members", () => { + const fallen = { + ...freshParty("druid", "Aelia")[0], + hp: 0, + healingEffects: [{ + id: "rejuvenation" as const, + expiresAt: 5, + nextTickAt: 1, + tickInterval: 1, + healingPerTick: 8, + stacks: 1, + }], + }; + + const advanced = advanceHealingEffects(fallen, 0, 3, 1); + + expect(advanced.member.hp).toBe(0); + expect(advanced.tickCount).toBe(0); + expect(advanced.tickCounts.rejuvenation).toBeUndefined(); + }); +}); diff --git a/src/game/healerEffects.ts b/src/game/healerEffects.ts new file mode 100644 index 0000000..dbf5b06 --- /dev/null +++ b/src/game/healerEffects.ts @@ -0,0 +1,195 @@ +import { distance } from "./geometry"; +import type { + HealingEffectId, + HealingOverTimeEffect, + MemberId, + PartyMember, + WorldPosition, +} from "./types"; + +const DRUID_EFFECTS = new Set(["regrowth", "rejuvenation", "lifebloom", "wild-growth"]); + +export interface HealingEffectAdvanceResult { + member: PartyMember; + tickCount: number; + tickCounts: Partial>; + bloomHealing: number; +} + +export function healingEffect(member: PartyMember, id: HealingEffectId): HealingOverTimeEffect | undefined { + return member.healingEffects.find((effect) => effect.id === id); +} + +export function setHealingEffect(member: PartyMember, effect: HealingOverTimeEffect): PartyMember { + const existingIndex = member.healingEffects.findIndex((candidate) => candidate.id === effect.id); + if (existingIndex < 0) return { ...member, healingEffects: [...member.healingEffects, effect] }; + const healingEffects = [...member.healingEffects]; + healingEffects[existingIndex] = effect; + return { ...member, healingEffects }; +} + +export function removeHealingEffect(member: PartyMember, id: HealingEffectId): PartyMember { + const healingEffects = member.healingEffects.filter((effect) => effect.id !== id); + return healingEffects.length === member.healingEffects.length ? member : { ...member, healingEffects }; +} + +export function triggerHealingEffects( + member: PartyMember, + healingMultiplier: number, + predicate: (effect: HealingOverTimeEffect) => boolean = () => true, +): { member: PartyMember; healing: number; triggered: number } { + if (member.hp <= 0) return { member, healing: 0, triggered: 0 }; + let healing = 0; + let triggered = 0; + for (const effect of member.healingEffects) { + if (!predicate(effect)) continue; + healing += effect.healingPerTick * effect.stacks * healingMultiplier; + triggered += 1; + } + return { + member: healing > 0 ? { ...member, hp: Math.min(member.maxHp, member.hp + healing) } : member, + healing, + triggered, + }; +} + +export function advanceHealingEffects( + member: PartyMember, + oldTime: number, + time: number, + healingMultiplier: number, + flourishExpiresAt = 0, +): HealingEffectAdvanceResult { + if (!member.healingEffects.length) return { member, tickCount: 0, tickCounts: {}, bloomHealing: 0 }; + let hp = member.hp; + let tickCount = 0; + const tickCounts: Partial> = {}; + let bloomHealing = 0; + const healingEffects: HealingOverTimeEffect[] = []; + + for (const current of member.healingEffects) { + if (current.expiresAt <= oldTime) continue; + let nextTickAt = current.nextTickAt; + const lastTickAt = Math.min(time, current.expiresAt); + while (nextTickAt <= lastTickAt + 0.001) { + if (hp > 0) { + hp = Math.min(member.maxHp, hp + current.healingPerTick * current.stacks * healingMultiplier); + tickCount += 1; + tickCounts[current.id] = (tickCounts[current.id] ?? 0) + 1; + } + const accelerated = DRUID_EFFECTS.has(current.id) && flourishExpiresAt > nextTickAt; + nextTickAt += accelerated ? Math.min(0.5, current.tickInterval) : current.tickInterval; + } + + if (time >= current.expiresAt) { + if (current.id === "lifebloom" && hp > 0) { + const bloom = (current.expirationHealingPerStack ?? 10) * current.stacks * healingMultiplier; + bloomHealing += bloom; + hp = Math.min(member.maxHp, hp + bloom); + } + continue; + } + + healingEffects.push({ ...current, nextTickAt }); + } + + return { + member: { ...member, hp, healingEffects }, + tickCount, + tickCounts, + bloomHealing, + }; +} + +export function flourishParty(party: readonly PartyMember[], time: number, extension = 5): PartyMember[] { + return party.map((member) => { + let changed = false; + const healingEffects = member.healingEffects.map((effect) => { + if (!DRUID_EFFECTS.has(effect.id) || effect.expiresAt <= time) return effect; + changed = true; + return { + ...effect, + expiresAt: effect.expiresAt + extension, + nextTickAt: Math.min(effect.nextTickAt, time + 0.5), + }; + }); + return changed ? { ...member, healingEffects } : member; + }); +} + +export function chainHealIndexes( + party: readonly PartyMember[], + positions: Readonly>, + startIndex: number, + maxTargets: number, + maxJumpDistance = 7, +): number[] { + if (startIndex < 0 || party[startIndex]?.hp <= 0 || maxTargets <= 0) return []; + const result = [startIndex]; + const visited = new Set(result); + while (result.length < maxTargets) { + const previousIndex = result[result.length - 1]; + const previousPosition = positions[party[previousIndex].id]; + const candidate = party + .map((member, index) => ({ + index, + member, + jumpDistance: distance(previousPosition, positions[member.id]), + })) + .filter(({ index, member, jumpDistance }) => !visited.has(index) + && member.hp > 0 + && member.hp < member.maxHp + && jumpDistance <= maxJumpDistance) + .sort((left, right) => left.jumpDistance - right.jumpDistance + || (left.member.hp / left.member.maxHp) - (right.member.hp / right.member.maxHp) + || left.index - right.index)[0]; + if (!candidate) break; + visited.add(candidate.index); + result.push(candidate.index); + } + return result; +} + +export function equalizeHealthPercentages( + party: readonly PartyMember[], + positions: Readonly>, + center: WorldPosition, + radius: number, +): PartyMember[] { + const linkedIndexes = party + .map((member, index) => ({ member, index })) + .filter(({ member }) => member.hp > 0 && distance(positions[member.id], center) <= radius) + .map(({ index }) => index); + if (linkedIndexes.length < 2) return [...party]; + const averagePercent = linkedIndexes.reduce((sum, index) => sum + party[index].hp / party[index].maxHp, 0) / linkedIndexes.length; + const linked = new Set(linkedIndexes); + return party.map((member, index) => linked.has(index) + ? { ...member, hp: Math.min(member.maxHp, member.maxHp * averagePercent) } + : member); +} + +export function resolveReactiveHealAfterDamage( + before: PartyMember, + after: PartyMember, + time: number, + healingMultiplier: number, +): { member: PartyMember; triggered: boolean } { + const effect = before.reactiveHeal; + const tookDamage = after.hp < before.hp || after.absorb < before.absorb; + if (!effect || effect.id !== "earth-shield" || effect.charges <= 0 || effect.expiresAt <= time + || effect.nextTriggerAt > time || after.hp <= 0 || !tookDamage) { + return { + member: effect && effect.expiresAt <= time ? { ...after, reactiveHeal: null } : after, + triggered: false, + }; + } + const charges = effect.charges - 1; + return { + member: { + ...after, + hp: Math.min(after.maxHp, after.hp + effect.healingPerTrigger * healingMultiplier), + reactiveHeal: charges > 0 ? { ...effect, charges, nextTriggerAt: time + 0.75 } : null, + }, + triggered: true, + }; +} diff --git a/src/game/healerMechanics.test.ts b/src/game/healerMechanics.test.ts new file mode 100644 index 0000000..4b61708 --- /dev/null +++ b/src/game/healerMechanics.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { freshParty } from "./data"; +import { + createHealerMechanicState, + healTargetAndBeacon, + placeOrRewindTimeAnchor, + resolveTimeLoop, + startTimeLoop, +} from "./healerMechanics"; + +describe("shared healer mechanics", () => { + it("echoes a direct heal without healing dead or duplicate beacon targets", () => { + const party = freshParty("paladin").map((member) => member.id === "brann" + ? { ...member, hp: 50 } + : member.id === "nia" + ? { ...member, hp: 20 } + : member); + const mechanic = { + ...createHealerMechanicState("paladin"), + beaconTargetId: "brann" as const, + beaconExpiresAt: 10, + }; + + const result = healTargetAndBeacon(party, 2, 30, mechanic, 1); + expect(result.party[2].hp).toBe(50); + expect(result.party[1].hp).toBe(62); + expect(result.directHealing).toBe(30); + expect(result.beaconHealing).toBe(12); + expect(party[1].hp).toBe(50); + }); + + it("replaces an anchor on a new target and rewinds only its recorded target", () => { + const party = freshParty("chronomancer").map((member) => member.id === "brann" ? { ...member, hp: 90 } : member); + const mechanic = createHealerMechanicState("chronomancer"); + const anchored = placeOrRewindTimeAnchor(party, 1, mechanic, 1); + const damaged = anchored.party.map((member) => member.id === "brann" ? { ...member, hp: 35 } : member); + const rewound = placeOrRewindTimeAnchor(damaged, 1, anchored.mechanic, 2); + + expect(rewound.party[1].hp).toBe(90); + expect(rewound.restored).toBe(55); + expect(rewound.mechanic.temporalAnchor).toBeNull(); + }); + + it("resolves a party snapshot once without resurrecting defeated allies", () => { + const party = freshParty("chronomancer"); + const mechanic = startTimeLoop(party, createHealerMechanicState("chronomancer"), 5); + const damaged = party.map((member) => member.id === "brann" + ? { ...member, hp: 40 } + : member.id === "nia" + ? { ...member, hp: 0 } + : member); + const early = resolveTimeLoop(damaged, mechanic, 4.9); + const resolved = resolveTimeLoop(early.party, early.mechanic, 5); + + expect(early.party[1].hp).toBe(40); + expect(resolved.party[1].hp).toBe(150); + expect(resolved.party[2].hp).toBe(0); + expect(resolved.mechanic.timeLoop).toBeNull(); + }); +}); diff --git a/src/game/healerMechanics.ts b/src/game/healerMechanics.ts new file mode 100644 index 0000000..60c1e56 --- /dev/null +++ b/src/game/healerMechanics.ts @@ -0,0 +1,163 @@ +import type { HealerClassId, HealerMechanicState, MemberId, PartyMember } from "./types"; + +export interface BeaconHealResult { + party: PartyMember[]; + directHealing: number; + beaconHealing: number; +} + +export interface TimeAnchorResult { + party: PartyMember[]; + mechanic: HealerMechanicState; + restored: number; + anchored: boolean; +} + +export interface TimeLoopResult { + party: PartyMember[]; + mechanic: HealerMechanicState; + restored: number; +} + +export function healerMaxResource(classId: HealerClassId): number { + if (classId === "druid") return 5; + if (classId === "shaman") return 2; + if (classId === "paladin" || classId === "chronomancer") return 3; + return 0; +} + +export function createHealerMechanicState(classId: HealerClassId): HealerMechanicState { + return { + resource: 0, + maxResource: healerMaxResource(classId), + flourishExpiresAt: 0, + nextPulseAt: 0, + beaconTargetId: null, + beaconExpiresAt: 0, + avengingCrusaderExpiresAt: 0, + temporalAnchor: null, + timeLoop: null, + }; +} + +function healLiving(member: PartyMember, amount: number): PartyMember { + if (member.hp <= 0 || amount <= 0) return member; + return { ...member, hp: Math.min(member.maxHp, member.hp + amount) }; +} + +export function healTargetAndBeacon( + party: readonly PartyMember[], + targetIndex: number, + amount: number, + mechanic: HealerMechanicState, + time: number, + echoFraction = 0.4, +): BeaconHealResult { + const target = party[targetIndex]; + if (!target || target.hp <= 0) return { party: [...party], directHealing: 0, beaconHealing: 0 }; + const next = [...party]; + const healedTarget = healLiving(target, amount); + const directHealing = healedTarget.hp - target.hp; + next[targetIndex] = healedTarget; + + let beaconHealing = 0; + if (mechanic.beaconTargetId && mechanic.beaconExpiresAt > time && mechanic.beaconTargetId !== target.id) { + const beaconIndex = next.findIndex((member) => member.id === mechanic.beaconTargetId); + const beacon = next[beaconIndex]; + if (beacon?.hp > 0) { + const healedBeacon = healLiving(beacon, directHealing * echoFraction); + beaconHealing = healedBeacon.hp - beacon.hp; + next[beaconIndex] = healedBeacon; + } + } + return { party: next, directHealing, beaconHealing }; +} + +export function placeOrRewindTimeAnchor( + party: readonly PartyMember[], + targetIndex: number, + mechanic: HealerMechanicState, + time: number, + duration = 6, +): TimeAnchorResult { + const target = party[targetIndex]; + if (!target || target.hp <= 0) return { party: [...party], mechanic, restored: 0, anchored: false }; + const anchor = mechanic.temporalAnchor; + if (anchor?.targetId === target.id && anchor.expiresAt > time) { + const restoredHp = Math.max(target.hp, Math.min(target.maxHp, anchor.hp)); + const next = [...party]; + next[targetIndex] = { ...target, hp: restoredHp }; + return { + party: next, + mechanic: { ...mechanic, temporalAnchor: null }, + restored: restoredHp - target.hp, + anchored: false, + }; + } + return { + party: [...party], + mechanic: { + ...mechanic, + temporalAnchor: { targetId: target.id, hp: target.hp, expiresAt: time + duration }, + }, + restored: 0, + anchored: true, + }; +} + +export function startTimeLoop(party: readonly PartyMember[], mechanic: HealerMechanicState, restoresAt: number): HealerMechanicState { + return { + ...mechanic, + timeLoop: { + restoresAt, + partyHp: Object.fromEntries(party.map((member) => [member.id, member.hp])) as Record, + }, + }; +} + +export function resolveTimeLoop( + party: PartyMember[], + mechanic: HealerMechanicState, + time: number, +): TimeLoopResult { + const loop = mechanic.timeLoop; + if (!loop || loop.restoresAt > time) return { party, mechanic, restored: 0 }; + let restored = 0; + const next = party.map((member) => { + if (member.hp <= 0) return member; + const hp = Math.max(member.hp, Math.min(member.maxHp, loop.partyHp[member.id])); + restored += hp - member.hp; + return hp === member.hp ? member : { ...member, hp }; + }); + return { party: next, mechanic: { ...mechanic, timeLoop: null }, restored }; +} + +export function healMostInjured(party: readonly PartyMember[], amount: number): { party: PartyMember[]; targetId: MemberId | null; healing: number } { + let targetIndex = -1; + let lowestRatio = Number.POSITIVE_INFINITY; + for (let index = 0; index < party.length; index += 1) { + const member = party[index]; + if (member.hp <= 0 || member.hp >= member.maxHp) continue; + const ratio = member.hp / member.maxHp; + if (ratio < lowestRatio) { + lowestRatio = ratio; + targetIndex = index; + } + } + if (targetIndex < 0) return { party: [...party], targetId: null, healing: 0 }; + const next = [...party]; + const before = next[targetIndex]; + next[targetIndex] = healLiving(before, amount); + return { party: next, targetId: before.id, healing: next[targetIndex].hp - before.hp }; +} + +export function expireHealerMechanics(mechanic: HealerMechanicState, time: number): HealerMechanicState { + let next = mechanic; + if (next.beaconTargetId && next.beaconExpiresAt <= time) { + next = { ...next, beaconTargetId: null, beaconExpiresAt: 0 }; + } + if (next.temporalAnchor && next.temporalAnchor.expiresAt <= time) { + next = { ...next, temporalAnchor: null }; + } + return next; +} diff --git a/src/game/healerRigAssets.test.ts b/src/game/healerRigAssets.test.ts new file mode 100644 index 0000000..e1ddade --- /dev/null +++ b/src/game/healerRigAssets.test.ts @@ -0,0 +1,93 @@ +import { fileURLToPath } from "node:url"; +import { NodeIO, type Document } from "@gltf-transform/core"; +import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; +import { MeshoptDecoder } from "meshoptimizer"; +import { beforeAll, describe, expect, it } from "vitest"; +import { CHARACTER_PART_CATALOG } from "./characterAppearance"; +import type { MemberId } from "./types"; + +const BODY_ASSETS = ["druid", "mage", "ranger", "knight", "rogue"] as const; +const REQUIRED_SHARED_CLIPS = ["Death_A", "Hit_A", "Idle", "Running_A", "Walking_A", "Spellcasting"]; +const REQUIRED_RIG_NODES = ["root", "hips", "spine", "chest", "head", "handslot.r", "handslot.l"]; +const ASSET_BY_MEMBER: Record = { + aelia: "druid", + brann: "knight", + nia: "ranger", + orin: "mage", + vale: "rogue", +}; + +const io = new NodeIO() + .registerExtensions(ALL_EXTENSIONS) + .registerDependencies({ "meshopt.decoder": MeshoptDecoder }); + +function assetPath(name: typeof BODY_ASSETS[number]) { + return fileURLToPath(new URL(`../assets/game/models/claudecraft/chars/players/${name}-uastc.glb`, import.meta.url)); +} + +describe("shared healer animation rig assets", () => { + const documents = new Map(); + + beforeAll(async () => { + await MeshoptDecoder.ready; + await Promise.all(BODY_ASSETS.map(async (name) => { + documents.set(name, await io.read(assetPath(name))); + })); + }); + + it("keeps the same named joint and socket contract on every healer body", () => { + const druidSkin = documents.get("druid")!.getRoot().listSkins()[0]; + const druidJoints = druidSkin.listJoints().map((joint) => joint.getName()).sort(); + const druidRestPose = new Map(druidSkin.listJoints().map((joint) => [joint.getName(), { + parent: joint.getParentNode()?.getName() ?? null, + translation: joint.getTranslation(), + rotation: joint.getRotation(), + scale: joint.getScale(), + }])); + + for (const name of BODY_ASSETS) { + const root = documents.get(name)!.getRoot(); + const nodeNames = new Set(root.listNodes().map((node) => node.getName())); + for (const requiredNode of REQUIRED_RIG_NODES) expect(nodeNames.has(requiredNode), `${name}:${requiredNode}`).toBe(true); + for (const skin of root.listSkins()) { + expect(skin.listJoints().map((joint) => joint.getName()).sort(), name).toEqual(druidJoints); + for (const joint of skin.listJoints()) { + expect({ + parent: joint.getParentNode()?.getName() ?? null, + translation: joint.getTranslation(), + rotation: joint.getRotation(), + scale: joint.getScale(), + }, `${name}:${joint.getName()}`).toEqual(druidRestPose.get(joint.getName())); + } + } + } + }); + + it("lets every body resolve all targets from Aelia's shared animation set", () => { + const druidRoot = documents.get("druid")!.getRoot(); + const clipNames = new Set(druidRoot.listAnimations().map((animation) => animation.getName())); + for (const clipName of REQUIRED_SHARED_CLIPS) expect(clipNames.has(clipName), clipName).toBe(true); + + const animationTargets = new Set(druidRoot.listAnimations().flatMap((animation) => + animation.listChannels().map((channel) => channel.getTargetNode()?.getName()).filter((name): name is string => Boolean(name)), + )); + for (const name of BODY_ASSETS) { + const nodeNames = new Set(documents.get(name)!.getRoot().listNodes().map((node) => node.getName())); + const missingTargets = [...animationTargets].filter((target) => !nodeNames.has(target)); + expect(missingTargets, name).toEqual([]); + } + }); + + it("keeps every modular catalog node present in its source GLB", () => { + for (const [partId, definition] of Object.entries(CHARACTER_PART_CATALOG)) { + const assetName = ASSET_BY_MEMBER[definition.sourceMemberId]; + const nodes = documents.get(assetName)!.getRoot().listNodes(); + for (const nodeName of definition.nodeNames) { + const node = nodes.find((candidate) => candidate.getName() === nodeName); + expect(node, `${partId}:${nodeName}`).toBeDefined(); + expect(node?.getMesh(), `${partId}:${nodeName}:mesh`).not.toBeNull(); + expect(node?.getSkin(), `${partId}:${nodeName}:skin`).not.toBeNull(); + } + } + }); +}); diff --git a/src/game/healerVisuals.test.ts b/src/game/healerVisuals.test.ts new file mode 100644 index 0000000..46cad56 --- /dev/null +++ b/src/game/healerVisuals.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { HEALER_CLASS_ORDER } from "./healers"; +import { + createDefaultHealerAppearance, + HEALER_VISUAL_PROFILES, + healerVisualSignature, + normalizeHealerAppearance, +} from "./healerVisuals"; + +describe("healer visual profiles", () => { + it("covers every healer class", () => { + expect(Object.keys(HEALER_VISUAL_PROFILES)).toEqual(HEALER_CLASS_ORDER); + }); + + it("reuses Aelia's animation rig for every healer body", () => { + for (const profile of Object.values(HEALER_VISUAL_PROFILES)) { + expect(profile.animationMemberId).toBe("aelia"); + } + }); + + it("gives every healer a distinct silhouette and loadout signature", () => { + const signatures = Object.values(HEALER_VISUAL_PROFILES).map(healerVisualSignature); + expect(new Set(signatures).size).toBe(HEALER_CLASS_ORDER.length); + }); + + it("returns owned defaults and repairs unknown saved appearances per class", () => { + const first = createDefaultHealerAppearance("priest"); + const second = createDefaultHealerAppearance("priest"); + expect(first).toEqual(HEALER_VISUAL_PROFILES.priest.appearance); + expect(first).not.toBe(second); + expect(first.mainHand).not.toBe(second.mainHand); + expect(normalizeHealerAppearance("druid", { version: 99 })).toEqual( + HEALER_VISUAL_PROFILES.druid.appearance, + ); + }); +}); diff --git a/src/game/healerVisuals.ts b/src/game/healerVisuals.ts new file mode 100644 index 0000000..6f553e8 --- /dev/null +++ b/src/game/healerVisuals.ts @@ -0,0 +1,148 @@ +import type { HealerClassId, MemberId } from "./types"; +import { + cloneCharacterAppearance, + normalizeCharacterAppearance, + type CharacterAppearanceV1, + type CharacterHeldItemVisual, + type CharacterWeaponGrip, + type CharacterWeaponModelId, +} from "./characterAppearance"; + +export type HealerWeaponModelId = CharacterWeaponModelId; +export type HealerWeaponGrip = CharacterWeaponGrip; +export type HealerAccessoryId = "sun-halo" | "grove-antlers" | "storm-totem" | "sun-crest" | "clockwork-rings"; + +export type HealerHeldItemVisual = CharacterHeldItemVisual; + +export interface HealerVisualProfile { + /** Geometry source. Every source uses the shared Rig_Medium bone contract. */ + bodyMemberId: MemberId; + /** Aelia's Druid file remains the single animation source for every healer. */ + animationMemberId: "aelia"; + appearance: CharacterAppearanceV1; + hiddenNodes: readonly string[]; + accessory: HealerAccessoryId; + accentColor: string; + secondaryColor: string; +} + +export const HEALER_VISUAL_PROFILES: Record = { + priest: { + bodyMemberId: "orin", + animationMemberId: "aelia", + appearance: { + version: 1, + rigId: "medium", + scaleSourceMemberId: "orin", + headPartId: "mage-head", + upperBodyPartId: "mage-upper", + lowerBodyPartId: "mage-lower", + headwearPartId: null, + backPartId: "mage-cape", + mainHand: { modelId: "cc/adv_druid_staff", grip: "staff" }, + }, + hiddenNodes: ["Mage_Hat"], + accessory: "sun-halo", + accentColor: "#ffe7a3", + secondaryColor: "#9a76ff", + }, + druid: { + bodyMemberId: "aelia", + animationMemberId: "aelia", + appearance: { + version: 1, + rigId: "medium", + scaleSourceMemberId: "aelia", + headPartId: "druid-head", + upperBodyPartId: "druid-upper", + lowerBodyPartId: "druid-lower", + headwearPartId: null, + backPartId: "druid-backpack", + mainHand: { modelId: "cc/adv_druid_staff", grip: "staff" }, + }, + hiddenNodes: [], + accessory: "grove-antlers", + accentColor: "#79d66f", + secondaryColor: "#e8b86a", + }, + shaman: { + bodyMemberId: "nia", + animationMemberId: "aelia", + appearance: { + version: 1, + rigId: "medium", + scaleSourceMemberId: "nia", + headPartId: "ranger-head", + upperBodyPartId: "ranger-upper", + lowerBodyPartId: "ranger-lower", + headwearPartId: null, + backPartId: "ranger-cape", + mainHand: { modelId: "cc/adv_druid_staff", grip: "staff" }, + }, + hiddenNodes: ["Ranger_Quiver"], + accessory: "storm-totem", + accentColor: "#62bdff", + secondaryColor: "#7de0bf", + }, + paladin: { + bodyMemberId: "brann", + animationMemberId: "aelia", + appearance: { + version: 1, + rigId: "medium", + scaleSourceMemberId: "brann", + headPartId: "knight-head", + upperBodyPartId: "knight-upper", + lowerBodyPartId: "knight-lower", + headwearPartId: "knight-helmet", + backPartId: "knight-cape", + mainHand: { modelId: "cc/adv_sword_1handed", grip: "upright" }, + offHand: { modelId: "cc/shield_badge", grip: "prop" }, + }, + hiddenNodes: [], + accessory: "sun-crest", + accentColor: "#ffc45c", + secondaryColor: "#fff3bd", + }, + chronomancer: { + bodyMemberId: "vale", + animationMemberId: "aelia", + appearance: { + version: 1, + rigId: "medium", + scaleSourceMemberId: "vale", + headPartId: "rogue-head", + upperBodyPartId: "rogue-upper", + lowerBodyPartId: "rogue-lower", + headwearPartId: null, + backPartId: "rogue-cape", + mainHand: { modelId: "cc/adv_wand", grip: "wand" }, + offHand: { modelId: "cc/spellbook_open", grip: "prop" }, + }, + hiddenNodes: [], + accessory: "clockwork-rings", + accentColor: "#62d4d9", + secondaryColor: "#8877ff", + }, +}; + +export function createDefaultHealerAppearance(classId: HealerClassId): CharacterAppearanceV1 { + return cloneCharacterAppearance(HEALER_VISUAL_PROFILES[classId].appearance); +} + +export function normalizeHealerAppearance(classId: HealerClassId, value: unknown): CharacterAppearanceV1 { + return normalizeCharacterAppearance(value, HEALER_VISUAL_PROFILES[classId].appearance); +} + +export function healerVisualSignature(profile: HealerVisualProfile) { + return [ + profile.appearance.headPartId, + profile.appearance.upperBodyPartId, + profile.appearance.lowerBodyPartId, + profile.appearance.headwearPartId ?? "bare", + profile.appearance.backPartId ?? "no-back", + profile.accessory, + profile.appearance.mainHand.modelId, + profile.appearance.offHand?.modelId ?? "empty", + ].join(":"); +} diff --git a/src/game/healers.ts b/src/game/healers.ts index 4a4a3cb..188fdd7 100644 --- a/src/game/healers.ts +++ b/src/game/healers.ts @@ -1,20 +1,39 @@ -import type { AbilityDefinition, AbilityId, HealerClassDefinition, HealerClassId, InventoryItem } from "./types"; +import type { + AbilityDefinition, + AbilityLoadout, + AbilitySlotId, + HealerAbilityId, + HealerClassDefinition, + HealerClassId, + HealerPulseKind, + InventoryItem, +} from "./types"; import { ABILITY_CONTROLLER_BINDINGS } from "./controllerBindings"; -const bindings: Record> = { - mend: { id: "mend", key: "1", gamepad: ABILITY_CONTROLLER_BINDINGS.mend.glyph, targeting: "ally" }, - renew: { id: "renew", key: "2", gamepad: ABILITY_CONTROLLER_BINDINGS.renew.glyph, targeting: "ally" }, - shield: { id: "shield", key: "3", gamepad: ABILITY_CONTROLLER_BINDINGS.shield.glyph, targeting: "ally" }, - purify: { id: "purify", key: "4", gamepad: ABILITY_CONTROLLER_BINDINGS.purify.glyph, targeting: "ally" }, - radiance: { id: "radiance", key: "5", gamepad: ABILITY_CONTROLLER_BINDINGS.radiance.glyph, targeting: "party" }, - barrier: { id: "barrier", key: "6", gamepad: ABILITY_CONTROLLER_BINDINGS.barrier.glyph, targeting: "party" }, +const slotBindings: Record> = { + ability1: { slot: "ability1", key: "1", gamepad: ABILITY_CONTROLLER_BINDINGS.ability1.glyph }, + ability2: { slot: "ability2", key: "2", gamepad: ABILITY_CONTROLLER_BINDINGS.ability2.glyph }, + ability3: { slot: "ability3", key: "3", gamepad: ABILITY_CONTROLLER_BINDINGS.ability3.glyph }, + ability4: { slot: "ability4", key: "4", gamepad: ABILITY_CONTROLLER_BINDINGS.ability4.glyph }, + ability5: { slot: "ability5", key: "5", gamepad: ABILITY_CONTROLLER_BINDINGS.ability5.glyph }, + ability6: { slot: "ability6", key: "6", gamepad: ABILITY_CONTROLLER_BINDINGS.ability6.glyph }, }; -function ability(id: AbilityId, definition: Omit): AbilityDefinition { - return { ...bindings[id], ...definition }; +function ability( + slot: AbilitySlotId, + id: HealerAbilityId, + pulseKind: HealerPulseKind, + targeting: AbilityDefinition["targeting"], + definition: Omit, +): AbilityDefinition { + return { ...slotBindings[slot], id, pulseKind, targeting, ...definition }; } -export const HEALER_CLASS_ORDER: HealerClassId[] = ["priest", "druid", "shaman"]; +export const HEALER_CLASS_ORDER: HealerClassId[] = ["priest", "druid", "shaman", "paladin", "chronomancer"]; + +export function isHealerClassId(value: string): value is HealerClassId { + return HEALER_CLASS_ORDER.includes(value as HealerClassId); +} export const HEALER_CLASSES: Record = { priest: { @@ -26,12 +45,12 @@ export const HEALER_CLASSES: Record = { resourceName: "Grace", description: "Direct healing, protective shields, cleansing, and a damage-reducing sanctuary.", abilities: { - mend: ability("mend", { name: "Mend", shortName: "Mend", cooldown: 0, castTime: 0.5, mana: 5, icon: "+", description: "Cast for 0.5 seconds to heal the selected ally for 38 health. No cooldown.", color: "#fff0bd" }), - renew: ability("renew", { name: "Renew", shortName: "Renew", cooldown: 0, mana: 7, icon: "✣", description: "Heal selected ally for 7 health every second for 8 seconds. No cooldown.", color: "#71df9c" }), - shield: ability("shield", { name: "Aegis Shield", shortName: "Shield", cooldown: 10, mana: 8, icon: "◇", description: "Give selected ally a 36-point damage shield.", color: "#6fc6ff" }), - purify: ability("purify", { name: "Purify", shortName: "Purify", cooldown: 3, mana: 5, icon: "✧", description: "Dispel all harmful magic from the selected ally.", color: "#b58cff" }), - radiance: ability("radiance", { name: "Radiance", shortName: "Radiance", cooldown: 14, mana: 12, icon: "☀", description: "Heal every party member for 22 health.", color: "#ffd66b" }), - barrier: ability("barrier", { name: "Barrier", shortName: "Barrier", cooldown: 60, mana: 10, icon: "◉", description: "Place a 3m field at your feet for 8 seconds. Allies inside take 30% less damage.", color: "#f2cf55" }), + ability1: ability("ability1", "priest-mend", "direct-heal", "ally", { name: "Mend", shortName: "Mend", cooldown: 0, castTime: 0.5, mana: 5, icon: "+", description: "Cast for 0.5 seconds to heal the selected ally for 38 health. No cooldown.", color: "#fff0bd" }), + ability2: ability("ability2", "priest-renew", "periodic-heal", "ally", { name: "Renew", shortName: "Renew", cooldown: 0, mana: 7, icon: "✣", description: "Heal selected ally for 7 health every second for 8 seconds. No cooldown.", color: "#71df9c" }), + ability3: ability("ability3", "priest-aegis-shield", "protective", "ally", { name: "Aegis Shield", shortName: "Shield", cooldown: 10, mana: 8, icon: "◇", description: "Give selected ally a 36-point damage shield.", color: "#6fc6ff" }), + ability4: ability("ability4", "priest-purify", "cleanse", "ally", { name: "Purify", shortName: "Purify", cooldown: 3, mana: 5, icon: "✧", description: "Dispel all harmful magic from the selected ally.", color: "#b58cff" }), + ability5: ability("ability5", "priest-radiance", "group-heal", "party", { name: "Radiance", shortName: "Radiance", cooldown: 14, mana: 12, icon: "☀", description: "Heal every party member for 22 health.", color: "#ffd66b" }), + ability6: ability("ability6", "priest-barrier", "field", "party", { name: "Barrier", shortName: "Barrier", cooldown: 60, mana: 10, icon: "◉", description: "Place a 3m field at your feet for 8 seconds. Allies inside take 30% less damage.", color: "#f2cf55" }), }, }, druid: { @@ -41,14 +60,15 @@ export const HEALER_CLASSES: Record = { icon: "❧", color: "#79d36f", resourceName: "Mana", - description: "Placeholder nature kit built around regeneration, bark wards, and restorative growth.", + secondaryResourceName: "Verdancy", + description: "Proactive regeneration, layered growth effects, and well-timed blooms.", abilities: { - mend: ability("mend", { name: "Healing Touch", shortName: "Heal Touch", cooldown: 0, castTime: 0.5, mana: 5, icon: "❦", description: "Placeholder: cast a focused nature heal for 38 health.", color: "#b8ef8b" }), - renew: ability("renew", { name: "Rejuvenation", shortName: "Rejuvenate", cooldown: 0, mana: 7, icon: "☘", description: "Placeholder: restore 7 health each second for 8 seconds.", color: "#63d77e" }), - shield: ability("shield", { name: "Ironbark", shortName: "Ironbark", cooldown: 10, mana: 8, icon: "♧", description: "Placeholder: grant the selected ally 36 absorption.", color: "#a6c76b" }), - purify: ability("purify", { name: "Nature's Cure", shortName: "Nature Cure", cooldown: 3, mana: 5, icon: "✤", description: "Placeholder: dispel all harmful magic from the selected ally.", color: "#8de2b2" }), - radiance: ability("radiance", { name: "Wild Growth", shortName: "Wild Growth", cooldown: 14, mana: 12, icon: "✾", description: "Placeholder: heal every party member for 22 health.", color: "#d1ed73" }), - barrier: ability("barrier", { name: "Grove Ward", shortName: "Grove Ward", cooldown: 60, mana: 10, icon: "◌", description: "Placeholder: grow an 8-second protective grove that reduces damage by 30%.", color: "#70bc72" }), + ability1: ability("ability1", "druid-regrowth", "direct-heal", "ally", { name: "Regrowth", shortName: "Regrowth", cooldown: 0, castTime: 0.5, mana: 5, icon: "❦", description: "Heal for 24, then 4 each second for 6 seconds. Consumes up to 3 Verdancy for 7 more healing each.", color: "#b8ef8b" }), + ability2: ability("ability2", "druid-rejuvenation", "periodic-heal", "ally", { name: "Rejuvenation", shortName: "Rejuvenate", cooldown: 0, mana: 6, icon: "☘", description: "Restore 6 health each second for 8 seconds. Each tick generates Verdancy.", color: "#63d77e" }), + ability3: ability("ability3", "druid-lifebloom", "periodic-heal", "ally", { name: "Lifebloom", shortName: "Lifebloom", cooldown: 0, mana: 5, icon: "♧", description: "Stack up to 3 times for 7 seconds. Heals over time, then blooms for 10 per stack. At 3 stacks, spend 2 Verdancy to bloom now.", color: "#a6c76b" }), + ability4: ability("ability4", "druid-natures-cure", "cleanse", "ally", { name: "Nature's Cure", shortName: "Nature Cure", cooldown: 3, mana: 5, icon: "✤", description: "Dispel all harmful magic and immediately trigger every healing-over-time effect on the target.", color: "#8de2b2" }), + ability5: ability("ability5", "druid-wild-growth", "group-heal", "party", { name: "Wild Growth", shortName: "Wild Growth", cooldown: 14, mana: 12, icon: "✾", description: "Heal the three most injured allies for 5 health each second for 6 seconds.", color: "#d1ed73" }), + ability6: ability("ability6", "druid-flourish", "field", "party", { name: "Flourish", shortName: "Flourish", cooldown: 60, mana: 10, icon: "◌", description: "Extend active Druid healing effects by 5 seconds and make them tick twice as fast for 6 seconds.", color: "#70bc72" }), }, }, shaman: { @@ -58,18 +78,86 @@ export const HEALER_CLASSES: Record = { icon: "ϟ", color: "#65b9ed", resourceName: "Mana", - description: "Placeholder elemental kit using tides, earth wards, cleansing, and spirit protection.", + secondaryResourceName: "Tidal Surge", + description: "Reactive burst healing through chain positioning, earth wards, and spirit links.", abilities: { - mend: ability("mend", { name: "Healing Wave", shortName: "Heal Wave", cooldown: 0, castTime: 0.5, mana: 5, icon: "≈", description: "Placeholder: cast a focused water heal for 38 health.", color: "#8fdcf2" }), - renew: ability("renew", { name: "Riptide", shortName: "Riptide", cooldown: 0, mana: 7, icon: "≋", description: "Placeholder: restore 7 health each second for 8 seconds.", color: "#54c9c5" }), - shield: ability("shield", { name: "Earth Shield", shortName: "Earth Shield", cooldown: 10, mana: 8, icon: "⬡", description: "Placeholder: grant the selected ally 36 absorption.", color: "#d2b66c" }), - purify: ability("purify", { name: "Cleanse Spirit", shortName: "Cleanse", cooldown: 3, mana: 5, icon: "✧", description: "Placeholder: dispel all harmful magic from the selected ally.", color: "#9aaef5" }), - radiance: ability("radiance", { name: "Chain Heal", shortName: "Chain Heal", cooldown: 14, mana: 12, icon: "⌁", description: "Placeholder: heal every party member for 22 health.", color: "#6ee2db" }), - barrier: ability("barrier", { name: "Spirit Link", shortName: "Spirit Link", cooldown: 60, mana: 10, icon: "◎", description: "Placeholder: place an 8-second spirit field that reduces damage by 30%.", color: "#9d8cf2" }), + ability1: ability("ability1", "shaman-healing-wave", "direct-heal", "ally", { name: "Healing Wave", shortName: "Heal Wave", cooldown: 0, castTime: 0.65, mana: 5, icon: "≈", description: "Heal for 34, increased by 35% below half health. Tidal Surge makes the cast faster and adds 10 healing.", color: "#8fdcf2" }), + ability2: ability("ability2", "shaman-riptide", "periodic-heal", "ally", { name: "Riptide", shortName: "Riptide", cooldown: 6, mana: 6, icon: "≋", description: "Heal for 12, then 5 each second for 6 seconds. Marks a Chain Heal anchor and grants Tidal Surge.", color: "#54c9c5" }), + ability3: ability("ability3", "shaman-earth-shield", "protective", "ally", { name: "Earth Shield", shortName: "Earth Shield", cooldown: 10, mana: 8, icon: "⬡", description: "Give an ally 6 charges for 30 seconds. Taking damage consumes a charge to heal for 9 and grants Tidal Surge.", color: "#d2b66c" }), + ability4: ability("ability4", "shaman-cleanse-spirit", "cleanse", "ally", { name: "Cleanse Spirit", shortName: "Cleanse", cooldown: 3, mana: 5, icon: "✧", description: "Dispel all harmful magic from an ally. A successful cleanse grants Tidal Surge.", color: "#9aaef5" }), + ability5: ability("ability5", "shaman-chain-heal", "group-heal", "ally", { name: "Chain Heal", shortName: "Chain Heal", cooldown: 12, mana: 12, icon: "⌁", description: "Heal the target, then jump through nearby injured allies with diminishing power. Tidal Surge adds jumps; Riptide strengthens the first heal.", color: "#6ee2db" }), + ability6: ability("ability6", "shaman-spirit-link", "field", "party", { name: "Spirit Link Totem", shortName: "Spirit Link", cooldown: 60, mana: 10, icon: "◎", description: "Place an 8-second, 3m spirit field that equalizes nearby allies' health percentages each second.", color: "#9d8cf2" }), + }, + }, + paladin: { + id: "paladin", + name: "Paladin", + specialization: "Dawnforged Paladin", + icon: "☼", + color: "#f2a65a", + resourceName: "Mana", + secondaryResourceName: "Conviction", + description: "Fight beside the party, turn offense into healing, and funnel light through a chosen beacon.", + abilities: { + ability1: ability("ability1", "paladin-holy-light", "direct-heal", "ally", { name: "Holy Light", shortName: "Holy Light", cooldown: 0, castTime: 0.55, mana: 5, icon: "✚", description: "Cast for 0.55 seconds to heal an ally for 34. Beacon of Light echoes 40% to its marked ally.", color: "#ffe29a" }), + ability2: ability("ability2", "paladin-crusader-strike", "direct-heal", "enemy", { name: "Crusader Strike", shortName: "Crusader", cooldown: 4, mana: 3, icon: "⚔", description: "Strike the boss for 18 damage, heal the most injured ally for 18, and gain 1 Conviction.", color: "#f6b35f" }), + ability3: ability("ability3", "paladin-beacon-of-light", "protective", "ally", { name: "Beacon of Light", shortName: "Beacon", cooldown: 8, mana: 7, icon: "♢", description: "Mark an ally for 30 seconds. Your single-target Paladin heals on others echo to the beacon for 40%.", color: "#ffd86b" }), + ability4: ability("ability4", "paladin-cleanse-light", "cleanse", "ally", { name: "Cleanse Light", shortName: "Cleanse", cooldown: 3, mana: 5, icon: "✧", description: "Dispel all harmful magic from the selected ally and heal them for 10.", color: "#fff0bd" }), + ability5: ability("ability5", "paladin-word-of-glory", "group-heal", "ally", { name: "Word of Glory", shortName: "Word Glory", cooldown: 0, mana: 6, icon: "✹", description: "Spend all Conviction to heal an ally for 18 plus 14 per point. At 3 Conviction, also heal the party for 8.", color: "#ffc857" }), + ability6: ability("ability6", "paladin-avenging-crusader", "field", "party", { name: "Avenging Crusader", shortName: "Avenging", cooldown: 60, mana: 10, icon: "⚜", description: "For 10 seconds, 20% of party attack damage heals the most injured living ally.", color: "#ff9f43" }), + }, + }, + chronomancer: { + id: "chronomancer", + name: "Chronomancer", + specialization: "Continuum Chronomancer", + icon: "◷", + color: "#62d4d9", + resourceName: "Mana", + secondaryResourceName: "Chronoshards", + description: "Prepare health snapshots, schedule healing echoes, and reverse damage after it happens.", + abilities: { + ability1: ability("ability1", "chronomancer-mend-timeline", "direct-heal", "ally", { name: "Mend Timeline", shortName: "Mend Time", cooldown: 0, castTime: 0.5, mana: 5, icon: "⌛", description: "Cast for 0.5 seconds to heal the selected ally for 30.", color: "#b5f4ef" }), + ability2: ability("ability2", "chronomancer-time-anchor", "protective", "ally", { name: "Time Anchor", shortName: "Anchor", cooldown: 0, mana: 4, icon: "⌖", description: "Record an ally's health for 6 seconds. Recast on that ally to restore damage taken since and gain 1 Chronoshard.", color: "#6ce5df" }), + ability3: ability("ability3", "chronomancer-echo-of-tomorrow", "periodic-heal", "ally", { name: "Echo of Tomorrow", shortName: "Tomorrow", cooldown: 6, mana: 7, icon: "◌", description: "Heal an ally for 10 now, then echo 28 healing after 3 seconds.", color: "#84cfff" }), + ability4: ability("ability4", "chronomancer-erase-affliction", "cleanse", "ally", { name: "Erase Affliction", shortName: "Erase", cooldown: 3, mana: 5, icon: "⌫", description: "Dispel all harmful magic and gain 1 Chronoshard.", color: "#a99cff" }), + ability5: ability("ability5", "chronomancer-accelerate", "group-heal", "party", { name: "Accelerate", shortName: "Accelerate", cooldown: 14, mana: 11, icon: "≫", description: "Spend all Chronoshards. Heal the party for 10 plus 8 per shard and reduce active cooldowns by 1 second per shard.", color: "#52c7e8" }), + ability6: ability("ability6", "chronomancer-time-loop", "field", "party", { name: "Time Loop", shortName: "Time Loop", cooldown: 60, mana: 10, icon: "∞", description: "Record every living ally's health. After 6 seconds, restore any health lost since the loop began.", color: "#7d8cff" }), }, }, }; +/** + * Flat spell registry used by run modes that compose abilities across healer + * classes. Class screens remain projections of the same canonical definitions. + */ +const HEALER_ABILITY_ENTRIES = HEALER_CLASS_ORDER.flatMap((classId) => + Object.values(HEALER_CLASSES[classId].abilities).map((entry) => [entry.id, entry] as const), +); + +/** Canonical deterministic order for every registered healer ability. */ +export const HEALER_ABILITY_IDS: readonly HealerAbilityId[] = Object.freeze( + HEALER_ABILITY_ENTRIES.map(([abilityId]) => abilityId), +); + +export const HEALER_ABILITIES = Object.fromEntries(HEALER_ABILITY_ENTRIES) as Record; + +export const DEFAULT_ABILITY_LOADOUTS = Object.fromEntries( + HEALER_CLASS_ORDER.map((classId) => [ + classId, + Object.fromEntries(Object.entries(HEALER_CLASSES[classId].abilities).map(([slotId, entry]) => [slotId, entry.id])), + ]), +) as Record>; + +export function createClassAbilityLoadout(classId: HealerClassId): Record { + return { ...DEFAULT_ABILITY_LOADOUTS[classId] }; +} + +export function resolveSlottedAbility(loadout: AbilityLoadout, slotId: AbilitySlotId): AbilityDefinition | undefined { + const abilityId = loadout[slotId]; + return abilityId ? HEALER_ABILITIES[abilityId] : undefined; +} + const CLASS_INVENTORIES: Record = { priest: [ { id: "priest-censer", name: "Censer of First Light", slot: "Main Hand", rarity: "Rare", icon: "♰", stats: ["+12 Grace", "+8% Mend healing"], effect: "Mend restores 2 mana when it lands on an ally below 50% health.", equipped: true }, @@ -78,14 +166,24 @@ const CLASS_INVENTORIES: Record = { { id: "priest-sigil", name: "Sigil of Quiet Resolve", slot: "Trinket", rarity: "Rare", icon: "◈", stats: ["+10% Purify range", "+5 Haste"], effect: "Purify grants its target 8 absorption when it removes Ember Brand.", equipped: false }, ], druid: [ - { id: "druid-branch", name: "Verdant Branch", slot: "Main Hand", rarity: "Common", icon: "❧", stats: ["+5 Spirit"], effect: "Placeholder Druid starter weapon.", equipped: true }, - { id: "druid-hide", name: "Mossbound Hide", slot: "Chest", rarity: "Common", icon: "♧", stats: ["+10 Armor"], effect: "Placeholder Druid starter armor.", equipped: true }, - { id: "druid-seed", name: "Dreamseed", slot: "Trinket", rarity: "Uncommon", icon: "•", stats: ["+3 Haste"], effect: "Placeholder Druid trinket.", equipped: false }, + { id: "druid-branch", name: "Verdant Branch", slot: "Main Hand", rarity: "Common", icon: "❧", stats: ["+5 Spirit"], effect: "A living focus shaped for layered restoration magic.", equipped: true }, + { id: "druid-hide", name: "Mossbound Hide", slot: "Chest", rarity: "Common", icon: "♧", stats: ["+10 Armor"], effect: "Flexible living armor that protects without slowing spellwork.", equipped: true }, + { id: "druid-seed", name: "Dreamseed", slot: "Trinket", rarity: "Uncommon", icon: "•", stats: ["+3 Haste"], effect: "A dormant seed that stirs near active growth magic.", equipped: false }, ], shaman: [ - { id: "shaman-totem", name: "Raincall Totem", slot: "Main Hand", rarity: "Common", icon: "ϟ", stats: ["+5 Spirit"], effect: "Placeholder Shaman starter focus.", equipped: true }, - { id: "shaman-mail", name: "Tideworn Mail", slot: "Chest", rarity: "Common", icon: "▧", stats: ["+12 Armor"], effect: "Placeholder Shaman starter armor.", equipped: true }, - { id: "shaman-stone", name: "Whispering Stone", slot: "Trinket", rarity: "Uncommon", icon: "◇", stats: ["+3 Haste"], effect: "Placeholder Shaman trinket.", equipped: false }, + { id: "shaman-totem", name: "Raincall Totem", slot: "Main Hand", rarity: "Common", icon: "ϟ", stats: ["+5 Spirit"], effect: "A compact focus carrying water and earth sigils.", equipped: true }, + { id: "shaman-mail", name: "Tideworn Mail", slot: "Chest", rarity: "Common", icon: "▧", stats: ["+12 Armor"], effect: "Salt-tempered mail built for close formation healing.", equipped: true }, + { id: "shaman-stone", name: "Whispering Stone", slot: "Trinket", rarity: "Uncommon", icon: "◇", stats: ["+3 Haste"], effect: "An ancestral stone that hums beside active totems.", equipped: false }, + ], + paladin: [ + { id: "paladin-mace", name: "Sunward Mace", slot: "Main Hand", rarity: "Common", icon: "⚒", stats: ["+5 Spirit"], effect: "A balanced war-mace that channels each impact into restorative light.", equipped: true }, + { id: "paladin-plate", name: "Dawnforged Plate", slot: "Chest", rarity: "Common", icon: "▣", stats: ["+14 Armor"], effect: "Warm plate built for fighting close to the party.", equipped: true }, + { id: "paladin-reliquary", name: "Reliquary of Oaths", slot: "Trinket", rarity: "Uncommon", icon: "♢", stats: ["+3 Haste"], effect: "A tiny reliquary that brightens as Conviction grows.", equipped: false }, + ], + chronomancer: [ + { id: "chronomancer-focus", name: "Continuum Focus", slot: "Main Hand", rarity: "Common", icon: "◷", stats: ["+5 Spirit"], effect: "A clockwork focus calibrated to preserve living timelines.", equipped: true }, + { id: "chronomancer-mantle", name: "Secondhand Mantle", slot: "Chest", rarity: "Common", icon: "⌛", stats: ["+10 Armor"], effect: "Threaded with moments recovered from failed futures.", equipped: true }, + { id: "chronomancer-hourglass", name: "Unspent Hourglass", slot: "Trinket", rarity: "Uncommon", icon: "∞", stats: ["+3 Haste"], effect: "Its sand falls upward near a damaged timeline.", equipped: false }, ], }; diff --git a/src/game/hockeyHealing.test.ts b/src/game/hockeyHealing.test.ts new file mode 100644 index 0000000..b283ed0 --- /dev/null +++ b/src/game/hockeyHealing.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + HOCKEY_GOAL_HALF_WIDTH, + HOCKEY_HEALER_GOAL_Z, + HOCKEY_NPC_PADDLE_Z, + advanceHockeyHealing, + createHockeyHealingState, + hockeyAimPreviewVisible, + hockeyPaddleReturnDirection, + hockeyPuckSpeed, + hockeyReturnDirection, +} from "./hockeyHealing"; + +describe("Hockey Healing", () => { + it("starts rallies at the faster default puck speed", () => { + expect(hockeyPuckSpeed(0)).toBe(6.2); + expect(Math.hypot(...createHockeyHealingState(true).puckVelocity)).toBeCloseTo(6.2); + }); + + it("uses a wide goal and keeps return aim facing the NPC half", () => { + expect(HOCKEY_GOAL_HALF_WIDTH).toBe(8); + expect(hockeyReturnDirection([0, 0])).toEqual([0, -1]); + expect(hockeyReturnDirection([-1, 0])[0]).toBeLessThan(0); + expect(hockeyReturnDirection([1, 0])[0]).toBeGreaterThan(0); + expect(hockeyReturnDirection([0, 1])[1]).toBeLessThan(0); + }); + + it("moves the enemy Pong paddle toward the puck with bounded speed", () => { + const state = createHockeyHealingState(true); + state.puckPosition = [7, 0]; + state.puckVelocity = [0, -5.2]; + + const next = advanceHockeyHealing(state, { delta: 0.1, time: 1, playerPosition: [0, 8.5] }); + + expect(next.paddleX).toBeGreaterThan(0); + expect(next.paddleX).toBeCloseTo(1.2, 8); + }); + + it("returns an incoming puck using current left-stick aim", () => { + const state = createHockeyHealingState(true); + state.puckPosition = [0, 7.8]; + state.puckVelocity = [0, 5.2]; + state.aimDirection = [1, -0.5]; + + const next = advanceHockeyHealing(state, { delta: 0.2, time: 3, playerPosition: [0, 8.5] }); + + expect(next.returns).toBe(1); + expect(next.puckVelocity[0]).toBeGreaterThan(0); + expect(next.puckVelocity[1]).toBeLessThan(0); + expect(next.lastReturnAt).toBe(3); + }); + + it("ends run when puck enters healer goal", () => { + const state = createHockeyHealingState(true); + state.puckPosition = [0, HOCKEY_HEALER_GOAL_Z - 0.2]; + state.puckVelocity = [0, 5.2]; + + const next = advanceHockeyHealing(state, { delta: 0.1, time: 9, playerPosition: [7, 8] }); + + expect(next.status).toBe("lost"); + expect(next.lostAt).toBe(9); + expect(next.puckVelocity).toEqual([0, 0]); + }); + + it("lets the moving enemy paddle visibly return the puck", () => { + const state = createHockeyHealingState(true); + state.puckPosition = [0, HOCKEY_NPC_PADDLE_Z + 1]; + state.puckVelocity = [0, -5.2]; + + const next = advanceHockeyHealing(state, { delta: 0.2, time: 4, playerPosition: [0, 8.5] }); + + expect(next.status).toBe("live"); + expect(next.npcGoals).toBe(0); + expect(next.paddleReturns).toBe(1); + expect(next.paddleHitAt).toBe(4); + expect(next.serveIndex).toBe(1); + expect(next.puckVelocity[1]).toBeGreaterThan(0); + }); + + it("uses Pong contact position to set rebound angle", () => { + expect(hockeyPaddleReturnDirection(-2)[0]).toBeLessThan(0); + expect(hockeyPaddleReturnDirection(0)).toEqual([0, 1]); + expect(hockeyPaddleReturnDirection(2)[0]).toBeGreaterThan(0); + }); + + it("shows aim preview only for nearby incoming puck", () => { + const state = createHockeyHealingState(true); + state.puckPosition = [0, 2]; + state.puckVelocity = [0, 5.2]; + expect(hockeyAimPreviewVisible(state, [0, 8.5])).toBe(true); + state.puckPosition = [0, -10]; + expect(hockeyAimPreviewVisible(state, [0, 8.5])).toBe(false); + state.puckVelocity = [0, -5.2]; + expect(hockeyAimPreviewVisible(state, [0, 8.5])).toBe(false); + }); +}); diff --git a/src/game/hockeyHealing.ts b/src/game/hockeyHealing.ts new file mode 100644 index 0000000..6d5f0ed --- /dev/null +++ b/src/game/hockeyHealing.ts @@ -0,0 +1,252 @@ +import type { WorldPosition } from "./types"; + +export type HockeyHealingStatus = "inactive" | "live" | "lost"; + +export interface HockeyHealingState { + status: HockeyHealingStatus; + puckPosition: WorldPosition; + puckVelocity: WorldPosition; + aimDirection: WorldPosition; + returns: number; + paddleReturns: number; + paddleX: number; + paddleHitAt: number; + npcGoals: number; + serveIndex: number; + lastReturnAt: number; + lostAt: number | null; +} + +export interface HockeyHealingStep { + delta: number; + time: number; + playerPosition: WorldPosition; +} + +export const HOCKEY_ARENA_MIN_X = -10; +export const HOCKEY_ARENA_MAX_X = 10; +export const HOCKEY_ARENA_MIN_Z = -15; +export const HOCKEY_ARENA_MAX_Z = 13; +export const HOCKEY_ARENA_CENTER_Z = (HOCKEY_ARENA_MIN_Z + HOCKEY_ARENA_MAX_Z) * 0.5; +export const HOCKEY_ARENA_WIDTH = HOCKEY_ARENA_MAX_X - HOCKEY_ARENA_MIN_X; +export const HOCKEY_ARENA_LENGTH = HOCKEY_ARENA_MAX_Z - HOCKEY_ARENA_MIN_Z; +export const HOCKEY_MIDLINE_Z = -1; +export const HOCKEY_GOAL_HALF_WIDTH = 8; +export const HOCKEY_HEALER_GOAL_Z = 12.5; +export const HOCKEY_NPC_GOAL_Z = -14.5; +export const HOCKEY_NPC_PADDLE_Z = -13.35; +export const HOCKEY_NPC_PADDLE_WIDTH = 4.5; +export const HOCKEY_NPC_PADDLE_HALF_WIDTH = HOCKEY_NPC_PADDLE_WIDTH * 0.5; +export const HOCKEY_PUCK_RADIUS = 0.42; +export const HOCKEY_PLAYER_INTERCEPT_RADIUS = 1.05; +export const HOCKEY_AIM_PREVIEW_SECONDS = 1.5; + +const STARTING_SPEED = 6.2; +const MAX_SPEED = 10.7; +const MAX_SUBSTEPS = 8; +const MAX_SUBSTEP_DISTANCE = 0.34; +const AIM_DEAD_ZONE = 0.12; +const MIN_FORWARD_COMPONENT = 0.3; +const NPC_PADDLE_SPEED = 12; +const NPC_PADDLE_MAX_REBOUND_X = 0.76; +const INCOMING_LANES = [0, -0.62, 0.7, -0.28, 0.38, -0.82, 0.86, -0.48, 0.18] as const; +const PADDLE_CONTACT_OFFSETS = [0, -0.48, 0.56, -0.26, 0.34, -0.62, 0.64, -0.38, 0.2] as const; + +const HOCKEY_NPC_PADDLE_HIT_Z = HOCKEY_NPC_PADDLE_Z + HOCKEY_PUCK_RADIUS + 0.28; + +export const HOCKEY_DEFAULT_PLAYER_POSITION: WorldPosition = [0, 8.5]; + +export function hockeyPuckSpeed(returns: number) { + return Math.min(MAX_SPEED, STARTING_SPEED + Math.max(0, returns) * 0.18); +} + +function velocityToward(start: WorldPosition, target: WorldPosition, speed: number): WorldPosition { + const dx = target[0] - start[0]; + const dz = target[1] - start[1]; + const length = Math.max(0.0001, Math.hypot(dx, dz)); + return [dx / length * speed, dz / length * speed]; +} + +function incomingVelocity(position: WorldPosition, serveIndex: number, returns: number) { + const lane = INCOMING_LANES[serveIndex % INCOMING_LANES.length]; + return velocityToward( + position, + [lane * HOCKEY_GOAL_HALF_WIDTH, HOCKEY_HEALER_GOAL_Z], + hockeyPuckSpeed(returns), + ); +} + +export function createHockeyHealingState(active = false): HockeyHealingState { + const puckPosition: WorldPosition = [0, HOCKEY_NPC_PADDLE_HIT_Z + 0.08]; + return { + status: active ? "live" : "inactive", + puckPosition, + puckVelocity: active ? incomingVelocity(puckPosition, 0, 0) : [0, 0], + aimDirection: [0, -1], + returns: 0, + paddleReturns: 0, + paddleX: 0, + paddleHitAt: Number.NEGATIVE_INFINITY, + npcGoals: 0, + serveIndex: 0, + lastReturnAt: 0, + lostAt: null, + }; +} + +export function setHockeyAim(state: HockeyHealingState, aim: WorldPosition): HockeyHealingState { + const x = Number.isFinite(aim[0]) ? aim[0] : 0; + const z = Number.isFinite(aim[1]) ? aim[1] : 0; + if (state.aimDirection[0] === x && state.aimDirection[1] === z) return state; + return { ...state, aimDirection: [x, z] }; +} + +/** Maps world-space left-stick movement into opponent-facing puck travel. */ +export function hockeyReturnDirection(aim: WorldPosition): WorldPosition { + const magnitude = Math.hypot(aim[0], aim[1]); + if (magnitude < AIM_DEAD_ZONE) return [0, -1]; + const x = aim[0] / magnitude; + const forward = Math.max(MIN_FORWARD_COMPONENT, -aim[1] / magnitude); + const length = Math.hypot(x, forward); + return [x / length, -forward / length]; +} + +/** Pong-style rebound: edge hits travel wider while every return faces the healer goal. */ +export function hockeyPaddleReturnDirection(contactOffset: number): WorldPosition { + const normalizedOffset = Math.max(-1, Math.min(1, contactOffset / HOCKEY_NPC_PADDLE_HALF_WIDTH)); + const x = normalizedOffset * NPC_PADDLE_MAX_REBOUND_X; + return [x, Math.sqrt(Math.max(0.001, 1 - x * x))]; +} + +function moveToward(current: number, target: number, distance: number) { + if (Math.abs(target - current) <= distance) return target; + return current + Math.sign(target - current) * distance; +} + +function paddleTargetX(puckX: number, serveIndex: number, returningToPaddle: boolean) { + const contactOffset = returningToPaddle + ? PADDLE_CONTACT_OFFSETS[serveIndex % PADDLE_CONTACT_OFFSETS.length] * HOCKEY_NPC_PADDLE_HALF_WIDTH + : 0; + return Math.max( + HOCKEY_ARENA_MIN_X + HOCKEY_NPC_PADDLE_HALF_WIDTH, + Math.min(HOCKEY_ARENA_MAX_X - HOCKEY_NPC_PADDLE_HALF_WIDTH, puckX - contactOffset), + ); +} + +function segmentDistanceSquared( + startX: number, + startZ: number, + endX: number, + endZ: number, + point: WorldPosition, +) { + const dx = endX - startX; + const dz = endZ - startZ; + const lengthSquared = dx * dx + dz * dz; + const projection = lengthSquared < 0.000001 + ? 0 + : Math.max(0, Math.min(1, ((point[0] - startX) * dx + (point[1] - startZ) * dz) / lengthSquared)); + const nearestX = startX + dx * projection; + const nearestZ = startZ + dz * projection; + return (point[0] - nearestX) ** 2 + (point[1] - nearestZ) ** 2; +} + +export function hockeyAimPreviewVisible(state: HockeyHealingState, playerPosition: WorldPosition) { + if (state.status !== "live" || state.puckVelocity[1] <= 0) return false; + const distance = Math.hypot( + playerPosition[0] - state.puckPosition[0], + playerPosition[1] - state.puckPosition[1], + ); + return distance / Math.max(0.001, Math.hypot(state.puckVelocity[0], state.puckVelocity[1])) <= HOCKEY_AIM_PREVIEW_SECONDS; +} + +export function advanceHockeyHealing(source: HockeyHealingState, step: HockeyHealingStep): HockeyHealingState { + if (source.status !== "live" || step.delta <= 0) return source; + + const state: HockeyHealingState = { + ...source, + puckPosition: [...source.puckPosition], + puckVelocity: [...source.puckVelocity], + aimDirection: [...source.aimDirection], + }; + const speed = Math.hypot(state.puckVelocity[0], state.puckVelocity[1]); + const substeps = Math.max(1, Math.min(MAX_SUBSTEPS, Math.ceil(speed * step.delta / MAX_SUBSTEP_DISTANCE))); + const subDelta = step.delta / substeps; + const interceptRadiusSquared = (HOCKEY_PLAYER_INTERCEPT_RADIUS + HOCKEY_PUCK_RADIUS) ** 2; + + for (let substep = 0; substep < substeps && state.status === "live"; substep += 1) { + const startX = state.puckPosition[0]; + const startZ = state.puckPosition[1]; + let endX = startX + state.puckVelocity[0] * subDelta; + let endZ = startZ + state.puckVelocity[1] * subDelta; + + const minPuckX = HOCKEY_ARENA_MIN_X + HOCKEY_PUCK_RADIUS; + const maxPuckX = HOCKEY_ARENA_MAX_X - HOCKEY_PUCK_RADIUS; + if (endX < minPuckX || endX > maxPuckX) { + endX = Math.max(minPuckX, Math.min(maxPuckX, endX)); + state.puckVelocity[0] *= -1; + } + + const returningToPaddle = state.puckVelocity[1] < 0; + const paddleTarget = paddleTargetX(endX, state.serveIndex, returningToPaddle); + state.paddleX = moveToward(state.paddleX, paddleTarget, NPC_PADDLE_SPEED * subDelta); + + if (state.puckVelocity[1] > 0 && segmentDistanceSquared(startX, startZ, endX, endZ, step.playerPosition) <= interceptRadiusSquared) { + const direction = hockeyReturnDirection(state.aimDirection); + const nextReturns = state.returns + 1; + const returnSpeed = hockeyPuckSpeed(nextReturns); + state.puckPosition[0] = endX; + state.puckPosition[1] = Math.min(endZ, step.playerPosition[1]); + state.puckVelocity[0] = direction[0] * returnSpeed; + state.puckVelocity[1] = direction[1] * returnSpeed; + state.returns = nextReturns; + state.lastReturnAt = step.time; + continue; + } + + if (state.puckVelocity[1] > 0 && endZ >= HOCKEY_HEALER_GOAL_Z) { + state.puckPosition[0] = endX; + state.puckPosition[1] = HOCKEY_HEALER_GOAL_Z; + if (Math.abs(endX) <= HOCKEY_GOAL_HALF_WIDTH) { + state.status = "lost"; + state.puckVelocity = [0, 0]; + state.lostAt = step.time; + } else { + state.puckVelocity[1] = -Math.abs(state.puckVelocity[1]); + } + continue; + } + + if (state.puckVelocity[1] < 0 && startZ >= HOCKEY_NPC_PADDLE_HIT_Z && endZ <= HOCKEY_NPC_PADDLE_HIT_Z) { + const travelRatio = Math.max(0, Math.min(1, (HOCKEY_NPC_PADDLE_HIT_Z - startZ) / Math.min(-0.0001, endZ - startZ))); + const hitX = startX + (endX - startX) * travelRatio; + const contactOffset = hitX - state.paddleX; + if (Math.abs(contactOffset) <= HOCKEY_NPC_PADDLE_HALF_WIDTH + HOCKEY_PUCK_RADIUS) { + const direction = hockeyPaddleReturnDirection(contactOffset); + const returnSpeed = hockeyPuckSpeed(state.returns); + state.puckPosition = [hitX, HOCKEY_NPC_PADDLE_HIT_Z]; + state.puckVelocity = [direction[0] * returnSpeed, direction[1] * returnSpeed]; + state.paddleReturns += 1; + state.paddleHitAt = step.time; + state.serveIndex += 1; + continue; + } + } + + if (state.puckVelocity[1] < 0 && endZ <= HOCKEY_NPC_GOAL_Z) { + state.npcGoals += 1; + state.serveIndex += 1; + state.paddleX = paddleTargetX(endX, state.serveIndex, false); + state.puckPosition = [state.paddleX, HOCKEY_NPC_PADDLE_HIT_Z]; + state.puckVelocity = incomingVelocity(state.puckPosition, state.serveIndex, state.returns); + state.paddleReturns += 1; + state.paddleHitAt = step.time; + continue; + } + + state.puckPosition[0] = endX; + state.puckPosition[1] = endZ; + } + + return state; +} diff --git a/src/game/hockeyHealingPvp.test.ts b/src/game/hockeyHealingPvp.test.ts new file mode 100644 index 0000000..0823116 --- /dev/null +++ b/src/game/hockeyHealingPvp.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + HOCKEY_PVP_GOAL_DAMAGE, + HOCKEY_PVP_GOAL_Z, + advanceHockeyPvpPuck, + createHockeyPvpState, + hockeyPvpBossAt, + hockeyPvpDampeningPercent, + hockeyPvpHealingEffectiveness, + hockeyPvpPuckSpeed, + mirrorHockeyPvpPuck, +} from "./hockeyHealingPvp"; + +describe("Healing Hockey PVP", () => { + it("starts rallies at the faster default puck speed", () => { + const state = createHockeyPvpState({ matchId: null, seed: 7, opponentName: "CPU", role: "cpu" }); + + expect(hockeyPvpPuckSpeed(0)).toBe(7.2); + expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.2); + }); + + it("adds five percent global dampening for every boss killed by either party", () => { + expect(hockeyPvpDampeningPercent(0, 0)).toBe(0); + expect(hockeyPvpDampeningPercent(1, 0)).toBe(5); + expect(hockeyPvpDampeningPercent(2, 3)).toBe(25); + expect(hockeyPvpHealingEffectiveness(2, 3)).toBe(0.75); + expect(hockeyPvpDampeningPercent(14, 9)).toBe(100); + expect(hockeyPvpHealingEffectiveness(14, 9)).toBe(0); + }); + + it("uses same deterministic boss order for both parties", () => { + const first = Array.from({ length: 20 }, (_, index) => hockeyPvpBossAt(4242, index)); + const second = Array.from({ length: 20 }, (_, index) => hockeyPvpBossAt(4242, index)); + expect(first).toEqual(second); + expect(first.every(Boolean)).toBe(true); + expect(new Set(first).size).toBeGreaterThan(1); + }); + + it("records a 45-damage goal signal and continues rally", () => { + const source = createHockeyPvpState({ matchId: null, seed: 7, opponentName: "CPU", role: "cpu" }); + source.puckPosition = [0, HOCKEY_PVP_GOAL_Z - 0.1]; + source.puckVelocity = [0, 8]; + const result = advanceHockeyPvpPuck(source, { + delta: 0.1, + localPlayerPosition: [8.8, 8.5], + localAimDirection: [0, -1], + opponentPlayerPosition: [0, 8.5], + opponentAimDirection: [0, -1], + }); + + expect(HOCKEY_PVP_GOAL_DAMAGE).toBe(45); + expect(result.localGoalsConceded).toBe(1); + expect(result.goalSequence).toBe(1); + expect(result.status).toBe("live"); + expect(result.puckVelocity[1]).toBeGreaterThan(0); + }); + + it("mirrors authoritative puck state for guest perspective", () => { + const source = createHockeyPvpState({ matchId: "match", seed: 9, opponentName: "Rival", role: "host" }); + source.puckPosition = [3, -8]; + source.puckVelocity = [-2, -6]; + source.localReturns = 4; + source.opponentReturns = 7; + source.localGoalsConceded = 2; + source.opponentGoalsConceded = 1; + source.goalSequence = 3; + source.lastGoalSide = "opponent"; + + expect(mirrorHockeyPvpPuck(source)).toMatchObject({ + puckPosition: [-3, 8], + puckVelocity: [2, 6], + localReturns: 7, + opponentReturns: 4, + localGoalsConceded: 1, + opponentGoalsConceded: 2, + lastGoalSide: "local", + }); + }); +}); diff --git a/src/game/hockeyHealingPvp.ts b/src/game/hockeyHealingPvp.ts new file mode 100644 index 0000000..7dab627 --- /dev/null +++ b/src/game/hockeyHealingPvp.ts @@ -0,0 +1,281 @@ +import { AVAILABLE_BOSS_IDS } from "./bossCatalog"; +import { hockeyReturnDirection } from "./hockeyHealing"; +import type { BossId, BossMotionMode, PartyMember, WorldPosition } from "./types"; + +export type HockeyPvpRole = "cpu" | "host" | "guest"; +export type HockeyPvpGoalSide = "local" | "opponent"; + +export interface HockeyPvpMatchConfig { + matchId: string | null; + seed: number; + opponentName: string; + role: HockeyPvpRole; +} + +export interface HockeyPvpPuckState { + puckPosition: WorldPosition; + puckVelocity: WorldPosition; + localReturns: number; + opponentReturns: number; + localGoalsConceded: number; + opponentGoalsConceded: number; + goalSequence: number; + lastGoalSide: HockeyPvpGoalSide | null; + serveIndex: number; +} + +export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState { + status: "inactive" | "live" | "won" | "lost"; + aimDirection: WorldPosition; + opponentBossKills: number; + opponentPlayerPosition: WorldPosition; + opponentAimDirection: WorldPosition; + nextCpuHealAt: number; + networkSequence: number; + appliedGoalSequence: number; +} + +export interface HockeyPvpRemoteSnapshot { + sequence: number; + time: number; + party: PartyMember[]; + partyPositions: Record; + boss: { id: BossId; name: string; hp: number; maxHp: number }; + bossPosition: WorldPosition; + bossMode: BossMotionMode; + bossKills: number; + playerPosition: WorldPosition; + aimDirection: WorldPosition; + puck?: HockeyPvpPuckState; +} + +export const HOCKEY_PVP_SIDE_OFFSET_Z = 11; +export const HOCKEY_PVP_ARENA_MIN_X = -10; +export const HOCKEY_PVP_ARENA_MAX_X = 10; +export const HOCKEY_PVP_ARENA_MIN_Z = -25; +export const HOCKEY_PVP_ARENA_MAX_Z = 25; +export const HOCKEY_PVP_GOAL_Z = 23.5; +export const HOCKEY_PVP_GOAL_HALF_WIDTH = 8; +export const HOCKEY_PVP_PUCK_RADIUS = 0.42; +export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05; +export const HOCKEY_PVP_GOAL_DAMAGE = 45; +export const HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT = 5; +export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000; + +const STARTING_SPEED = 7.2; +const MAX_SPEED = 11.5; +const MAX_SUBSTEPS = 10; +const MAX_SUBSTEP_DISTANCE = 0.32; +const SERVE_LANES = [0, -0.46, 0.58, -0.25, 0.34, -0.7, 0.74] as const; + +export function hockeyPvpDampeningPercent(localBossKills: number, opponentBossKills: number): number { + const totalBossKills = Math.max(0, Math.floor(localBossKills)) + Math.max(0, Math.floor(opponentBossKills)); + return Math.min(100, totalBossKills * HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT); +} + +export function hockeyPvpHealingEffectiveness(localBossKills: number, opponentBossKills: number): number { + return 1 - hockeyPvpDampeningPercent(localBossKills, opponentBossKills) / 100; +} + +function normalizedSeed(seed: number) { + return Math.max(1, Math.floor(Math.abs(Number(seed) || 1))) >>> 0; +} + +export function hockeyPvpBossAt(seed: number, index: number): BossId { + let value = (normalizedSeed(seed) + Math.max(0, Math.floor(index)) * 0x9e3779b9) >>> 0; + value ^= value >>> 16; + value = Math.imul(value, 0x21f0aaad) >>> 0; + value ^= value >>> 15; + value = Math.imul(value, 0x735a2d97) >>> 0; + value ^= value >>> 15; + return AVAILABLE_BOSS_IDS[(value >>> 0) % AVAILABLE_BOSS_IDS.length]; +} + +export function hockeyPvpPuckSpeed(totalReturns: number) { + return Math.min(MAX_SPEED, STARTING_SPEED + Math.max(0, totalReturns) * 0.16); +} + +function serveVelocity(side: HockeyPvpGoalSide, serveIndex: number, totalReturns: number): WorldPosition { + const x = SERVE_LANES[serveIndex % SERVE_LANES.length] * HOCKEY_PVP_GOAL_HALF_WIDTH; + const z = side === "local" ? HOCKEY_PVP_GOAL_Z : -HOCKEY_PVP_GOAL_Z; + const length = Math.max(0.001, Math.hypot(x, z)); + const speed = hockeyPvpPuckSpeed(totalReturns); + return [x / length * speed, z / length * speed]; +} + +export function createHockeyPvpState(config?: HockeyPvpMatchConfig): HockeyPvpState { + const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const }; + return { + ...match, + status: config ? "live" : "inactive", + puckPosition: [0, 0], + puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0], + localReturns: 0, + opponentReturns: 0, + localGoalsConceded: 0, + opponentGoalsConceded: 0, + goalSequence: 0, + lastGoalSide: null, + serveIndex: 0, + aimDirection: [0, -1], + opponentBossKills: 0, + opponentPlayerPosition: [0, 8.5], + opponentAimDirection: [0, -1], + nextCpuHealAt: 1, + networkSequence: 0, + appliedGoalSequence: 0, + }; +} + +export function hockeyPvpLocalToWorld(position: WorldPosition): WorldPosition { + return [position[0], position[1] + HOCKEY_PVP_SIDE_OFFSET_Z]; +} + +export function hockeyPvpOpponentToWorld(position: WorldPosition): WorldPosition { + return [-position[0], -position[1] - HOCKEY_PVP_SIDE_OFFSET_Z]; +} + +function segmentDistanceSquared(start: WorldPosition, end: WorldPosition, point: WorldPosition) { + const dx = end[0] - start[0]; + const dz = end[1] - start[1]; + const lengthSquared = dx * dx + dz * dz; + const projection = lengthSquared < 0.000001 + ? 0 + : Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared)); + const nearestX = start[0] + dx * projection; + const nearestZ = start[1] + dz * projection; + return (point[0] - nearestX) ** 2 + (point[1] - nearestZ) ** 2; +} + +function mirrorDirection(direction: WorldPosition): WorldPosition { + return [-direction[0], -direction[1]]; +} + +function resetAfterGoal(state: HockeyPvpPuckState, side: HockeyPvpGoalSide) { + state.goalSequence += 1; + state.lastGoalSide = side; + if (side === "local") state.localGoalsConceded += 1; + else state.opponentGoalsConceded += 1; + state.serveIndex += 1; + state.puckPosition = [0, 0]; + state.puckVelocity = serveVelocity(side, state.serveIndex, state.localReturns + state.opponentReturns); +} + +export function advanceHockeyPvpPuck( + source: HockeyPvpState, + step: { + delta: number; + localPlayerPosition: WorldPosition; + localAimDirection: WorldPosition; + opponentPlayerPosition: WorldPosition; + opponentAimDirection: WorldPosition; + }, +): HockeyPvpState { + if (source.status !== "live" || source.role === "guest" || step.delta <= 0) return source; + const state: HockeyPvpState = { + ...source, + puckPosition: [...source.puckPosition], + puckVelocity: [...source.puckVelocity], + }; + const localPlayer = hockeyPvpLocalToWorld(step.localPlayerPosition); + const opponentPlayer = hockeyPvpOpponentToWorld(step.opponentPlayerPosition); + const speed = Math.hypot(state.puckVelocity[0], state.puckVelocity[1]); + const substeps = Math.max(1, Math.min(MAX_SUBSTEPS, Math.ceil(speed * step.delta / MAX_SUBSTEP_DISTANCE))); + const subDelta = step.delta / substeps; + const interceptRadiusSquared = (HOCKEY_PVP_INTERCEPT_RADIUS + HOCKEY_PVP_PUCK_RADIUS) ** 2; + + for (let index = 0; index < substeps; index += 1) { + const start: WorldPosition = [...state.puckPosition]; + const end: WorldPosition = [ + start[0] + state.puckVelocity[0] * subDelta, + start[1] + state.puckVelocity[1] * subDelta, + ]; + const minX = HOCKEY_PVP_ARENA_MIN_X + HOCKEY_PVP_PUCK_RADIUS; + const maxX = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_PUCK_RADIUS; + if (end[0] < minX || end[0] > maxX) { + end[0] = Math.max(minX, Math.min(maxX, end[0])); + state.puckVelocity[0] *= -1; + } + + if (state.puckVelocity[1] > 0 && segmentDistanceSquared(start, end, localPlayer) <= interceptRadiusSquared) { + const direction = hockeyReturnDirection(step.localAimDirection); + state.localReturns += 1; + const returnSpeed = hockeyPvpPuckSpeed(state.localReturns + state.opponentReturns); + state.puckPosition = [end[0], Math.min(end[1], localPlayer[1])]; + state.puckVelocity = [direction[0] * returnSpeed, direction[1] * returnSpeed]; + continue; + } + + if (state.puckVelocity[1] < 0 && segmentDistanceSquared(start, end, opponentPlayer) <= interceptRadiusSquared) { + const direction = mirrorDirection(hockeyReturnDirection(step.opponentAimDirection)); + state.opponentReturns += 1; + const returnSpeed = hockeyPvpPuckSpeed(state.localReturns + state.opponentReturns); + state.puckPosition = [end[0], Math.max(end[1], opponentPlayer[1])]; + state.puckVelocity = [direction[0] * returnSpeed, direction[1] * returnSpeed]; + continue; + } + + if (state.puckVelocity[1] > 0 && end[1] >= HOCKEY_PVP_GOAL_Z) { + if (Math.abs(end[0]) <= HOCKEY_PVP_GOAL_HALF_WIDTH) resetAfterGoal(state, "local"); + else { + state.puckPosition = [end[0], HOCKEY_PVP_GOAL_Z]; + state.puckVelocity[1] = -Math.abs(state.puckVelocity[1]); + } + continue; + } + + if (state.puckVelocity[1] < 0 && end[1] <= -HOCKEY_PVP_GOAL_Z) { + if (Math.abs(end[0]) <= HOCKEY_PVP_GOAL_HALF_WIDTH) resetAfterGoal(state, "opponent"); + else { + state.puckPosition = [end[0], -HOCKEY_PVP_GOAL_Z]; + state.puckVelocity[1] = Math.abs(state.puckVelocity[1]); + } + continue; + } + + state.puckPosition = end; + } + return state; +} + +export function advanceHockeyPvpCpuGoalie( + position: WorldPosition, + puckPosition: WorldPosition, + delta: number, +): { position: WorldPosition; aimDirection: WorldPosition } { + const opponentLocalPuckX = -puckPosition[0]; + const targetX = Math.max(-8.6, Math.min(8.6, opponentLocalPuckX)); + const maxMovement = 7.2 * Math.max(0, delta); + const nextX = Math.abs(targetX - position[0]) <= maxMovement + ? targetX + : position[0] + Math.sign(targetX - position[0]) * maxMovement; + const aimX = Math.max(-0.85, Math.min(0.85, -opponentLocalPuckX / HOCKEY_PVP_GOAL_HALF_WIDTH)); + return { position: [nextX, 8.5], aimDirection: [aimX, -1] }; +} + +export function mirrorHockeyPvpPuck(source: HockeyPvpPuckState): HockeyPvpPuckState { + return { + puckPosition: [-source.puckPosition[0], -source.puckPosition[1]], + puckVelocity: [-source.puckVelocity[0], -source.puckVelocity[1]], + localReturns: source.opponentReturns, + opponentReturns: source.localReturns, + localGoalsConceded: source.opponentGoalsConceded, + opponentGoalsConceded: source.localGoalsConceded, + goalSequence: source.goalSequence, + lastGoalSide: source.lastGoalSide === "local" ? "opponent" : source.lastGoalSide === "opponent" ? "local" : null, + serveIndex: source.serveIndex, + }; +} + +export const HOCKEY_PVP_CPU_NAMES = [ + "Willow Warden", + "Mender Nova", + "Pulsekeeper", + "Sage Rook", + "Mercy Vale", + "Aster Ward", +] as const; + +export function randomHockeyPvpCpuName(random: () => number = Math.random) { + return HOCKEY_PVP_CPU_NAMES[Math.floor(random() * HOCKEY_PVP_CPU_NAMES.length)] ?? HOCKEY_PVP_CPU_NAMES[0]; +} diff --git a/src/game/hockeyHealingPvpStore.test.ts b/src/game/hockeyHealingPvpStore.test.ts new file mode 100644 index 0000000..bc99c9c --- /dev/null +++ b/src/game/hockeyHealingPvpStore.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createClassInventory } from "./healers"; +import { HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt } from "./hockeyHealingPvp"; +import { upcomingEncounterMechanic, useGameStore } from "./store"; +import { freshParty } from "./data"; +import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL } from "./progression/gear"; +import { EMPTY_MEMBER_GEAR_MODIFIERS } from "./progression/gearEffects"; + +const MATCH = { + matchId: null, + seed: 4242, + opponentName: "CPU Aster", + role: "cpu" as const, +}; + +function createMaxedGear() { + const gear = createDefaultGearProgress(); + for (const ownerId of GEAR_OWNER_ORDER) { + for (const slotId of GEAR_SLOT_ORDER) gear[ownerId].slots[slotId].level = MAX_GEAR_LEVEL; + } + gear.priest.passiveInfusionId = "mend-echo"; + return gear; +} + +describe("Healing Hockey PVP encounter integration", () => { + beforeEach(() => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + [hockeyPvpBossAt(MATCH.seed, 0)], + "hockey-healing-pvp", + undefined, + "initiate", + MATCH, + ); + }); + + it("starts exactly two copies of same boss across mirrored parties", () => { + const state = useGameStore.getState(); + expect(state.additionalBosses).toHaveLength(0); + expect(state.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0)); + expect(state.hockeyPvpOpponent.boss.id).toBe(state.boss.id); + expect(state.hockeyPvp.opponentName).toBe("CPU Aster"); + }); + + it("normalizes both parties to default base gear without changing saved upgrades", () => { + const maxedGear = createMaxedGear(); + const baseHealth = freshParty("priest", "Aelia").map((member) => member.maxHp); + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + [hockeyPvpBossAt(MATCH.seed, 0)], + "hockey-healing-pvp", + maxedGear, + "initiate", + MATCH, + ); + + const briefing = useGameStore.getState(); + expect(briefing.party.map((member) => member.maxHp)).toEqual(baseHealth); + expect(briefing.hockeyPvpOpponent.party.map((member) => member.maxHp)).toEqual(baseHealth); + expect(briefing.healingMultiplier).toBe(1); + expect(briefing.passiveRunBuffId).toBeNull(); + expect(briefing.gearProgress).toBe(maxedGear); + for (const modifiers of Object.values(briefing.gearModifiers)) { + expect(modifiers).toEqual(EMPTY_MEMBER_GEAR_MODIFIERS); + } + + briefing.startEncounter(); + const combat = useGameStore.getState(); + expect(combat.party.map((member) => member.maxHp)).toEqual(baseHealth); + expect(combat.hockeyPvpOpponent.party.map((member) => member.maxHp)).toEqual(baseHealth); + expect(combat.healingMultiplier).toBe(1); + expect(combat.passiveRunBuffId).toBeNull(); + expect(combat.gearProgress).toBe(maxedGear); + }); + + it("continues applying saved gear outside PVP", () => { + const maxedGear = createMaxedGear(); + const baseHealth = freshParty("priest", "Aelia").map((member) => member.maxHp); + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + "bulldrome", + "encounter", + maxedGear, + ); + + const state = useGameStore.getState(); + expect(state.party.map((member) => member.maxHp)).not.toEqual(baseHealth); + expect(state.healingMultiplier).toBeGreaterThan(1); + expect(state.passiveRunBuffId).toBe("mend-echo"); + }); + + it("deals exactly 45 partywide damage when local net is breached", () => { + useGameStore.getState().startEncounter(); + const startingHp = useGameStore.getState().party.map((member) => member.hp); + useGameStore.setState((state) => ({ + hockeyPvp: { + ...state.hockeyPvp, + puckPosition: [0, HOCKEY_PVP_GOAL_Z - 0.1], + puckVelocity: [0, 8], + }, + playerPosition: [8.8, 8.5], + partyPositions: { ...state.partyPositions, aelia: [8.8, 8.5] }, + boss: { ...state.boss, nextMeleeAt: 999 }, + bossMotion: { ...state.bossMotion, nextMechanicAt: 999 }, + hockeyPvpOpponent: { + ...state.hockeyPvpOpponent, + boss: { ...state.hockeyPvpOpponent.boss, nextMeleeAt: 999 }, + bossMotion: { ...state.hockeyPvpOpponent.bossMotion, nextMechanicAt: 999 }, + }, + })); + + useGameStore.getState().tick(0.1); + + useGameStore.getState().party.forEach((member, index) => { + expect(member.hp).toBe(startingHp[index] - HOCKEY_PVP_GOAL_DAMAGE); + }); + expect(useGameStore.getState().phase).toBe("combat"); + }); + + it("reduces both local and CPU healing by combined boss-kill dampening", () => { + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + endlessBossKills: 1, + party: state.party.map((member) => ({ ...member, hp: Math.max(1, member.hp - 40) })), + boss: { ...state.boss, nextMeleeAt: 999 }, + bossMotion: { ...state.bossMotion, nextMechanicAt: 999 }, + hockeyPvp: { ...state.hockeyPvp, opponentBossKills: 1, nextCpuHealAt: 0 }, + hockeyPvpOpponent: { + ...state.hockeyPvpOpponent, + party: state.hockeyPvpOpponent.party.map((member, index) => ({ ...member, hp: index === 0 ? 50 : member.hp })), + boss: { ...state.hockeyPvpOpponent.boss, nextMeleeAt: 999 }, + bossMotion: { ...state.hockeyPvpOpponent.bossMotion, nextMechanicAt: 999 }, + }, + })); + + const localBefore = useGameStore.getState().party[0].hp; + expect(useGameStore.getState().castAbility("ability5")).toBe(true); + expect(useGameStore.getState().party[0].hp - localBefore).toBeCloseTo(22 * 0.9, 5); + + useGameStore.getState().tick(0.01); + expect(useGameStore.getState().hockeyPvpOpponent.party[0].hp).toBeCloseTo(50 + 32 * 0.9, 5); + }); + + it("keeps faster and slower parties on same deterministic boss order", () => { + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, hp: 0, defeatedAt: state.time }, + endlessBossKills: 1, + })); + useGameStore.getState().tick(0.01); + expect(useGameStore.getState().boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 1)); + expect(useGameStore.getState().hockeyPvpOpponent.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0)); + + useGameStore.setState((state) => ({ + hockeyPvp: { ...state.hockeyPvp, opponentBossKills: 1 }, + hockeyPvpOpponent: { + ...state.hockeyPvpOpponent, + boss: { ...state.hockeyPvpOpponent.boss, hp: 0, defeatedAt: state.time }, + }, + })); + useGameStore.getState().tick(0.01); + + expect(useGameStore.getState().hockeyPvpOpponent.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 1)); + expect(useGameStore.getState().boss.id).toBe(useGameStore.getState().hockeyPvpOpponent.boss.id); + }); + + it("wins when opponent party falls", () => { + useGameStore.getState().startEncounter(); + useGameStore.getState().setActiveTab("map"); + useGameStore.setState((state) => ({ + hockeyPvpOpponent: { + ...state.hockeyPvpOpponent, + party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, hp: 0 })), + }, + })); + useGameStore.getState().tick(0.01); + expect(useGameStore.getState().phase).toBe("victory"); + expect(useGameStore.getState().hockeyPvp.status).toBe("won"); + expect(useGameStore.getState().activeTab).toBe("combat"); + }); + + it("keeps HUD mechanic data defined during instant boss replacement", () => { + useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } })); + expect(upcomingEncounterMechanic(useGameStore.getState())).toEqual({ + name: "Replacement incoming", + remaining: 0, + cycle: 1, + urgent: false, + }); + }); +}); diff --git a/src/game/hockeyHealingStore.test.ts b/src/game/hockeyHealingStore.test.ts new file mode 100644 index 0000000..0d831d1 --- /dev/null +++ b/src/game/hockeyHealingStore.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath"; +import { createClassInventory } from "./healers"; +import { HOCKEY_HEALER_GOAL_Z, HOCKEY_MIDLINE_Z, HOCKEY_NPC_PADDLE_Z } from "./hockeyHealing"; +import { useGameStore } from "./store"; + +describe("Hockey Healing encounter integration", () => { + beforeEach(() => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + ["bulldrome", "broodfang-spider"], + "hockey-healing", + ); + }); + + it("starts two bosses on enemy half with healer defending own half", () => { + const state = useGameStore.getState(); + expect(state.endlessMode).toBe(true); + expect(state.additionalBosses).toHaveLength(1); + expect(state.draftBuffIds).toEqual([]); + expect(state.bossMotion.position[1]).toBeLessThan(HOCKEY_MIDLINE_Z); + expect(state.additionalBosses[0].motion.position[1]).toBeLessThan(HOCKEY_MIDLINE_Z); + expect(state.partyPositions.aelia[1]).toBeGreaterThan(HOCKEY_MIDLINE_Z); + for (const memberId of ["brann", "nia", "orin", "vale"] as const) { + expect(state.partyPositions[memberId][1]).toBeLessThan(HOCKEY_MIDLINE_Z); + } + }); + + it("uses world-space left-stick direction when healer returns puck", () => { + useGameStore.getState().startEncounter(); + useGameStore.getState().setHockeyAimDirection([1, -0.5]); + useGameStore.setState((state) => ({ + hockey: { ...state.hockey, puckPosition: [0, 7.8], puckVelocity: [0, 5.2] }, + boss: { ...state.boss, nextMeleeAt: 999 }, + bossMotion: { ...state.bossMotion, nextMechanicAt: 999 }, + additionalBosses: state.additionalBosses.map((entry) => ({ + ...entry, + boss: { ...entry.boss, nextMeleeAt: 999 }, + motion: { ...entry.motion, nextMechanicAt: 999 }, + })), + })); + + useGameStore.getState().tick(0.2); + + expect(useGameStore.getState().hockey.returns).toBe(1); + expect(useGameStore.getState().hockey.puckVelocity[0]).toBeGreaterThan(0); + expect(useGameStore.getState().hockey.puckVelocity[1]).toBeLessThan(0); + }); + + it("ends run on wide-goal breach", () => { + useGameStore.getState().startEncounter(); + useGameStore.getState().setPlayerPosition([7, 8]); + useGameStore.setState((state) => ({ + hockey: { ...state.hockey, puckPosition: [0, HOCKEY_HEALER_GOAL_Z - 0.2], puckVelocity: [0, 5.2] }, + })); + + useGameStore.getState().tick(0.1); + + expect(useGameStore.getState().hockey.status).toBe("lost"); + expect(useGameStore.getState().phase).toBe("defeat"); + }); + + it("keeps the rally going through the enemy Pong paddle", () => { + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + hockey: { ...state.hockey, puckPosition: [0, HOCKEY_NPC_PADDLE_Z + 1], puckVelocity: [0, -5.2] }, + })); + + useGameStore.getState().tick(0.2); + + const hockey = useGameStore.getState().hockey; + expect(hockey.paddleReturns).toBe(1); + expect(hockey.puckVelocity[1]).toBeGreaterThan(0); + expect(useGameStore.getState().phase).toBe("combat"); + }); + + it("spawns Soul Siphon only on the healer side", () => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + ["mournveil-ghost", "bulldrome"], + "hockey-healing", + ); + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, nextMeleeAt: 999 }, + bossMotion: { ...state.bossMotion, mechanicCount: 2, nextMechanicAt: 0 }, + additionalBosses: state.additionalBosses.map((entry) => ({ + ...entry, + boss: { ...entry.boss, nextMeleeAt: 999 }, + motion: { ...entry.motion, nextMechanicAt: 999 }, + })), + })); + + useGameStore.getState().tick(0.1); + + const siphon = useGameStore.getState().bossMotion.poolTelegraphs[0].soulSiphon!; + expect(siphon.wardPosition[1] - siphon.wardRadius).toBeGreaterThan(HOCKEY_MIDLINE_Z); + expect(siphon.ghostPosition[1]).toBeGreaterThan(HOCKEY_MIDLINE_Z); + }); + + it("never marks the healer for Crushing Pounce", () => { + useGameStore.getState().startEncounter(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, nextMeleeAt: 999 }, + bossMotion: { ...state.bossMotion, mechanicCount: 9, nextMechanicAt: 0 }, + additionalBosses: state.additionalBosses.map((entry) => ({ + ...entry, + boss: { ...entry.boss, nextMeleeAt: 999 }, + motion: { ...entry.motion, nextMechanicAt: 999 }, + })), + })); + + useGameStore.getState().tick(0.05); + + const motion = useGameStore.getState().bossMotion; + expect(motion.activeMechanicId).toBe("crushing-pounce"); + expect(motion.pounceTargetId).not.toBe("aelia"); + }); + + it("replaces a fallen boss in same slot after death visual finishes", () => { + useGameStore.getState().startEncounter(); + const defeatedInstanceId = useGameStore.getState().bossInstanceId; + useGameStore.setState((state) => ({ + boss: { ...state.boss, hp: 0, defeatedAt: state.time }, + hockey: { ...state.hockey, puckVelocity: [0, 0] }, + })); + + let elapsed = 0; + while (elapsed <= BOSS_DEATH_DESPAWN_SECONDS + 0.2) { + useGameStore.getState().tick(0.1); + elapsed += 0.1; + } + + const replaced = useGameStore.getState(); + expect(replaced.phase).toBe("combat"); + expect(replaced.bossInstanceId).not.toBe(defeatedInstanceId); + expect(replaced.bossInstanceId.startsWith("hockey-")).toBe(true); + expect(replaced.boss.hp).toBe(replaced.boss.maxHp); + expect(replaced.bossMotion.position[1]).toBeLessThan(HOCKEY_MIDLINE_Z); + expect(replaced.additionalBosses).toHaveLength(1); + }); +}); diff --git a/src/game/partyBehaviors.test.ts b/src/game/partyBehaviors.test.ts index e6b8cfc..9ea75b2 100644 --- a/src/game/partyBehaviors.test.ts +++ b/src/game/partyBehaviors.test.ts @@ -22,6 +22,18 @@ describe("party boss positioning", () => { expect(formation.vale).toEqual([2, -2.7]); }); + it("places drafted companions by combat kit instead of stable slot id", () => { + const party = freshParty().map((member) => member.id === "brann" + ? { ...member, runProfile: { instanceId: "ranged", combatProfileId: "warlock-damage", combatKitId: "ranged" as const, tier: "white", visualArchetype: "mage" as const } } + : member.id === "nia" + ? { ...member, runProfile: { instanceId: "tank", combatProfileId: "warrior-tank", combatKitId: "tank" as const, tier: "white", visualArchetype: "knight" as const } } + : member); + const formation = combatFormation([0, 0], party); + + expect(formation.brann[1]).toBe(7.2); + expect(formation.nia[1]).toBe(4.25); + }); + it("moves the tank toward the front and the rogue toward the rear during uptime", () => { const sharedStart: WorldPosition = [0, 0]; const positions: Record = { diff --git a/src/game/partyBehaviors.ts b/src/game/partyBehaviors.ts index 48f6297..1b02162 100644 --- a/src/game/partyBehaviors.ts +++ b/src/game/partyBehaviors.ts @@ -1,12 +1,13 @@ import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "./arena"; import { BULL_CHARGE, BULL_POUNCE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool"; import { angularDistance, moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry"; -import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types"; +import type { BossMotionState, MemberId, PartyCombatKitId, PartyMember, WorldPosition } from "./types"; export type AiMemberId = Exclude; export interface PartyBehaviorContext { memberId: AiMemberId; + combatKitId: PartyCombatKitId; current: WorldPosition; formationTarget: WorldPosition; bossMotion: BossMotionState; @@ -26,7 +27,13 @@ export interface PartyBehavior { } const AI_MEMBER_IDS: readonly AiMemberId[] = ["brann", "nia", "orin", "vale"]; -const MOVE_SPEEDS: Record = { brann: 1.45, nia: 1.2, orin: 1.1, vale: 2.2 }; +const LEGACY_COMBAT_KITS: Record = { + brann: "tank", + nia: "ranged", + orin: "caster", + vale: "melee", +}; +const MOVE_SPEEDS: Record = { tank: 1.45, ranged: 1.2, caster: 1.1, melee: 2.2 }; const EVADE_SIDES: Record = { brann: -1, nia: -1, orin: 1, vale: 1 }; const HAZARD_LOOKAHEAD = 2.2; const CIRCLE_HAZARD_CLEARANCE = 0.55; @@ -50,21 +57,28 @@ const FORMATION_MODES: readonly BossMotionState["mode"][] = [ ]; const DASH_MODES: readonly BossMotionState["mode"][] = ["telegraph", "charging"]; -const FORMATION_SLOTS: Record = { - // Bosses face Brann during normal uptime, making positive Z their front. - brann: [0, 4.25], - nia: [-3.3, 7.2], - orin: [3.3, 7.2], - vale: [0, -1.7], +const FORMATION_LATERAL_OFFSETS: Record = { brann: 0, nia: -3.3, orin: 3.3, vale: 0 }; +const FORMATION_DEPTHS: Record = { + // Bosses face tanks during normal uptime, making positive Z their front. + tank: 4.25, + ranged: 7.2, + caster: 7.2, + melee: -1.7, }; -export function combatFormation(boss: WorldPosition): Record { - return { - brann: [boss[0] + FORMATION_SLOTS.brann[0], boss[1] + FORMATION_SLOTS.brann[1]], - nia: [boss[0] + FORMATION_SLOTS.nia[0], boss[1] + FORMATION_SLOTS.nia[1]], - orin: [boss[0] + FORMATION_SLOTS.orin[0], boss[1] + FORMATION_SLOTS.orin[1]], - vale: [boss[0] + FORMATION_SLOTS.vale[0], boss[1] + FORMATION_SLOTS.vale[1]], - }; +function combatKit(memberId: AiMemberId, party?: readonly PartyMember[]): PartyCombatKitId { + return party?.find((member) => member.id === memberId)?.runProfile?.combatKitId ?? LEGACY_COMBAT_KITS[memberId]; +} + +export function combatFormation(boss: WorldPosition, party?: readonly PartyMember[]): Record { + const formation = {} as Record; + for (const memberId of AI_MEMBER_IDS) { + formation[memberId] = [ + boss[0] + FORMATION_LATERAL_OFFSETS[memberId], + boss[1] + FORMATION_DEPTHS[combatKit(memberId, party)], + ]; + } + return formation; } function circleDanger( @@ -252,11 +266,11 @@ export const evadeChargeBehavior: PartyBehavior = { export const avoidBreathBehavior: PartyBehavior = { id: "avoid-breath", - decide: ({ memberId, bossMotion }) => { + decide: ({ memberId, combatKitId, bossMotion }) => { if (bossMotion.mode !== "breath_telegraph" && bossMotion.mode !== "breath_sweeping") return null; const side = EVADE_SIDES[memberId]; const safeAngle = bossMotion.breathAngle + side * (SKY_SWEEPER_BREATH.halfAngle + Math.PI * 0.42); - const radius = memberId === "brann" ? 4.1 : memberId === "vale" ? 3.5 : 5.4; + const radius = combatKitId === "tank" ? 4.1 : combatKitId === "melee" ? 3.5 : 5.4; return { target: clampToArena([ bossMotion.position[0] + Math.sin(safeAngle) * radius, @@ -375,9 +389,9 @@ export const reactToPooledTelegraphsBehavior: PartyBehavior = { export const maintainFormationBehavior: PartyBehavior = { id: "maintain-formation", - decide: ({ formationTarget, bossMotion, memberId }) => { + decide: ({ formationTarget, bossMotion, combatKitId }) => { if (!FORMATION_MODES.includes(bossMotion.mode)) return null; - return { target: formationTarget, speed: MOVE_SPEEDS[memberId] }; + return { target: formationTarget, speed: MOVE_SPEEDS[combatKitId] }; }, }; @@ -420,17 +434,18 @@ export function updatePartyPositions( formationZ += origin[1]; } const formationOrigin: WorldPosition = [formationX / activeMotions.length, formationZ / activeMotions.length]; - const formation = combatFormation(formationOrigin); + const formation = combatFormation(formationOrigin, party); - for (let index = 0; index < AI_MEMBER_IDS.length; index += 1) { - const memberId = AI_MEMBER_IDS[index]; - const member = party[index + 1]; + for (const memberId of AI_MEMBER_IDS) { + const member = party.find((candidate) => candidate.id === memberId); if (!member || member.hp <= 0 || member.knockedUntil > time) continue; + const combatKitId = combatKit(memberId, party); for (const behavior of behaviors) { let handled = false; for (const bossMotion of activeMotions) { const decision = behavior.decide({ memberId, + combatKitId, current: next[memberId], formationTarget: formation[memberId], bossMotion, diff --git a/src/game/partyCombat.test.ts b/src/game/partyCombat.test.ts index 66622af..93ee8e3 100644 --- a/src/game/partyCombat.test.ts +++ b/src/game/partyCombat.test.ts @@ -28,6 +28,7 @@ interface SimulationOptions { targetPositions?: readonly WorldPosition[]; stopOnVictory?: boolean; upcomingMechanicRemaining?: number; + damageProfiles?: Partial>; } function createTargets(bossIds: readonly BossId[], positions?: readonly WorldPosition[]): PartyCombatTarget[] { @@ -68,6 +69,7 @@ function simulate(bossIds: readonly BossId[], options: SimulationOptions = {}) { positions, targets, upcomingMechanicRemaining: options.upcomingMechanicRemaining ?? Number.POSITIVE_INFINITY, + damageProfiles: options.damageProfiles as Record | undefined, }); combat = result.state; for (const actor of Object.values(combat.combatants)) { @@ -122,6 +124,43 @@ describe("party ability combat", () => { expect(clustered.damageBySource.vale).toBeGreaterThan(separated.damageBySource.vale ?? 0); }); + it("accepts injected single-target and area profiles without class checks in combat core", () => { + const baseline = simulate(["bulldrome"], { duration: 12, activeIds: ["vale"], stopOnVictory: false }); + const focused = simulate(["bulldrome"], { + duration: 12, + activeIds: ["vale"], + stopOnVictory: false, + damageProfiles: { vale: { singleTarget: 1.5, areaDamage: 0.5 } }, + }); + expect(focused.damageBySource.vale).toBeGreaterThan((baseline.damageBySource.vale ?? 0) * 1.4); + }); + + it("uses injected AoE stats for ranged volleys and caster splash", () => { + const legacy = simulate(["broodfang-spider", "tempestscale-dragon"], { + duration: 18, + activeIds: ["nia"], + stopOnVictory: false, + }); + expect(legacy.secondaryEvents).toHaveLength(0); + + for (const memberId of ["nia", "orin"] as const) { + const lowArea = simulate(["broodfang-spider", "tempestscale-dragon"], { + duration: 18, + activeIds: [memberId], + stopOnVictory: false, + damageProfiles: { [memberId]: { singleTarget: 1, areaDamage: 0.6 } }, + }); + const highArea = simulate(["broodfang-spider", "tempestscale-dragon"], { + duration: 18, + activeIds: [memberId], + stopOnVictory: false, + damageProfiles: { [memberId]: { singleTarget: 1, areaDamage: 1.4 } }, + }); + expect(lowArea.secondaryEvents.length).toBeGreaterThan(0); + expect(highArea.damageBySource[memberId]).toBeGreaterThan(lowArea.damageBySource[memberId] ?? 0); + } + }); + it("activates Brann's moving six-second Bulwark aura before incoming damage", () => { const result = simulate(["bulldrome"], { duration: 0.1, activeIds: ["brann"], stopOnVictory: false, upcomingMechanicRemaining: 1 }); expect(result.combat.combatants.brann.visualAction?.abilityId).toBe("bulwark_march"); diff --git a/src/game/partyCombat.ts b/src/game/partyCombat.ts index 9f3d6f4..f525b5b 100644 --- a/src/game/partyCombat.ts +++ b/src/game/partyCombat.ts @@ -1,5 +1,5 @@ import { distance } from "./geometry"; -import type { BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "./types"; +import type { BossMotionState, BossState, MemberId, PartyCombatKitId, PartyMember, WorldPosition } from "./types"; export type AiCombatantId = Exclude; @@ -94,6 +94,7 @@ export interface TankAuraState { expiresAt: number; radius: number; damageReduction: number; + sourceId: AiCombatantId; } export interface PartyCombatState { @@ -111,6 +112,7 @@ export interface PartyCombatContext { targets: PartyCombatTarget[]; upcomingMechanicRemaining: number; gearModifiers?: Record; + damageProfiles?: Record; } interface AbilitySpec { @@ -123,19 +125,40 @@ interface AbilitySpec { requiresStationary?: boolean; } -const EMPTY_AURA: TankAuraState = { expiresAt: 0, radius: 3, damageReduction: 0.3 }; -const RANGED_IDS: readonly AiCombatantId[] = ["nia", "orin"]; +interface AreaDamageShape { + readonly radius: number; + readonly secondaryDamageRatio: number; +} + +/** Ability geometry stays data-driven; injected class stats tune actual AoE power. */ +const AREA_DAMAGE_SHAPES: Partial> = { + rapid_fire: { radius: 6, secondaryDamageRatio: 0.35 }, + arcane_burst: { radius: 6, secondaryDamageRatio: 0.8 }, + comet: { radius: 7, secondaryDamageRatio: 0.65 }, +}; + +const EMPTY_AURA: TankAuraState = { expiresAt: 0, radius: 3, damageReduction: 0.3, sourceId: "brann" }; +const LEGACY_COMBAT_KITS: Record = { + brann: "tank", + nia: "ranged", + orin: "caster", + vale: "melee", +}; const VALE_CLEAVE_RADIUS = 3.6; const VALE_MELEE_RANGE = 3.65; const BRANN_MELEE_RANGE = 4.8; // Calibrated against full two-boss rotations: intended 50–90 seconds, never over 100. const PARTY_DAMAGE_SCALE = 2; -function combatant(id: AiCombatantId, hp: number): PartyCombatantState { +function combatKit(id: AiCombatantId, party: readonly PartyMember[]): PartyCombatKitId { + return party.find((member) => member.id === id)?.runProfile?.combatKitId ?? LEGACY_COMBAT_KITS[id]; +} + +function combatant(id: AiCombatantId, hp: number, kit: PartyCombatKitId): PartyCombatantState { return { id, readyAt: 0, - resource: id === "vale" ? 100 : 0, + resource: kit === "melee" ? 100 : 0, points: 0, cooldowns: {}, activeAction: null, @@ -152,10 +175,10 @@ export function createPartyCombatState(party: PartyMember[]): PartyCombatState { const hp = (id: AiCombatantId) => party.find((member) => member.id === id)?.hp ?? 0; return { combatants: { - brann: combatant("brann", hp("brann")), - nia: combatant("nia", hp("nia")), - orin: combatant("orin", hp("orin")), - vale: combatant("vale", hp("vale")), + brann: combatant("brann", hp("brann"), combatKit("brann", party)), + nia: combatant("nia", hp("nia"), combatKit("nia", party)), + orin: combatant("orin", hp("orin"), combatKit("orin", party)), + vale: combatant("vale", hp("vale"), combatKit("vale", party)), }, tankAura: { ...EMPTY_AURA }, nextEventId: 1, @@ -191,8 +214,9 @@ function targetsInRange(source: WorldPosition, targets: PartyCombatTarget[], ran function targetFor(id: AiCombatantId, context: PartyCombatContext, range = Number.POSITIVE_INFINITY) { const eligible = targetsInRange(context.positions[id], context.targets, range); if (!eligible.length) return undefined; - if (id === "orin" && eligible.length > 1) return eligible[1]; - if (RANGED_IDS.includes(id)) return eligible[0]; + const kit = combatKit(id, context.party); + if (kit === "caster" && eligible.length > 1) return eligible[1]; + if (kit === "ranged" || kit === "caster") return eligible[0]; let nearest = eligible[0]; for (let index = 1; index < eligible.length; index += 1) { if (distance(context.positions[id], eligible[index].motion.position) < distance(context.positions[id], nearest.motion.position)) nearest = eligible[index]; @@ -200,16 +224,17 @@ function targetFor(id: AiCombatantId, context: PartyCombatContext, range = Numbe return nearest; } -function partyNeedsBulwark(context: PartyCombatContext) { - const brann = context.party.find((member) => member.id === "brann"); +function partyNeedsBulwark(context: PartyCombatContext, tankId: AiCombatantId) { + const tank = context.party.find((member) => member.id === tankId); const lowMembers = context.party.filter((member) => member.hp > 0 && member.hp / member.maxHp < 0.6).length; - return context.upcomingMechanicRemaining <= 2 || (brann?.hp ?? 0) / Math.max(1, brann?.maxHp ?? 1) < 0.7 || lowMembers >= 2; + return context.upcomingMechanicRemaining <= 2 || (tank?.hp ?? 0) / Math.max(1, tank?.maxHp ?? 1) < 0.7 || lowMembers >= 2; } function chooseAbility(actor: PartyCombatantState, at: number, isMoving: boolean, context: PartyCombatContext): AbilitySpec | null { const source = context.positions[actor.id]; const rangedTarget = targetFor(actor.id, context); - if (actor.id === "nia") { + const kit = combatKit(actor.id, context.party); + if (kit === "ranged") { if (!rangedTarget) return null; if (rangedTarget.boss.hp / rangedTarget.boss.maxHp <= 0.25 && isReady(actor, "kill_shot", at)) return { id: "kill_shot", duration: 0.45, impactOffsets: [0.24], damage: 7, gcd: 1.1, cooldown: 10 }; if (!isMoving && isReady(actor, "rapid_fire", at)) return { id: "rapid_fire", duration: 2, impactOffsets: [0.4, 0.8, 1.2, 1.6], damage: 2, gcd: 2, cooldown: 9, requiresStationary: true }; @@ -218,7 +243,7 @@ function chooseAbility(actor: PartyCombatantState, at: number, isMoving: boolean return { id: "quick_shot", duration: 0.45, impactOffsets: [0.24], damage: 2, gcd: 1.15 }; } - if (actor.id === "orin") { + if (kit === "caster") { if (!rangedTarget) return null; if (!isMoving && actor.overchargeStacks === 0 && isReady(actor, "overcharge", at)) return { id: "overcharge", duration: 0.35, impactOffsets: [], damage: 0, gcd: 0.7, cooldown: 20 }; if (!isMoving && isReady(actor, "comet", at)) return { id: "comet", duration: 2, impactOffsets: [2], damage: 9, gcd: 2, cooldown: 12, requiresStationary: true }; @@ -228,7 +253,7 @@ function chooseAbility(actor: PartyCombatantState, at: number, isMoving: boolean return { id: "arcane_bolt", duration: 1.4, impactOffsets: [1.4], damage: 3, gcd: 1.4, requiresStationary: true }; } - if (actor.id === "vale") { + if (kit === "melee") { const nearby = targetsInRange(source, context.targets, VALE_CLEAVE_RADIUS); const target = targetFor(actor.id, context, VALE_MELEE_RANGE); if (!target) return null; @@ -241,7 +266,7 @@ function chooseAbility(actor: PartyCombatantState, at: number, isMoving: boolean const target = targetFor(actor.id, context, BRANN_MELEE_RANGE); if (!target) return null; - if (partyNeedsBulwark(context) && isReady(actor, "bulwark_march", at)) return { id: "bulwark_march", duration: 0.6, impactOffsets: [0.35], damage: 2, gcd: 1.1, cooldown: 30 }; + if (partyNeedsBulwark(context, actor.id) && isReady(actor, "bulwark_march", at)) return { id: "bulwark_march", duration: 0.6, impactOffsets: [0.35], damage: 2, gcd: 1.1, cooldown: 30 }; if (actor.revengeReadyUntil > at && isReady(actor, "revenge", at)) return { id: "revenge", duration: 0.6, impactOffsets: [0.35], damage: 3, gcd: 1.1, cooldown: 5 }; if (isReady(actor, "shield_slam", at)) return { id: "shield_slam", duration: 0.6, impactOffsets: [0.35], damage: 2, gcd: 1.1, cooldown: 6 }; if (targetsInRange(source, context.targets, BRANN_MELEE_RANGE).length > 1 && isReady(actor, "sweeping_guard", at)) return { id: "sweeping_guard", duration: 0.65, impactOffsets: [0.4], damage: 2, gcd: 1.1, cooldown: 4 }; @@ -261,13 +286,23 @@ function applyStartCosts(actor: PartyCombatantState, spec: AbilitySpec, at: numb if (spec.id === "backstab") { actor.resource -= 25; actor.points = Math.max(0, actor.points - 3); } if (spec.id === "fan_of_blades") actor.resource -= 35; if (spec.id === "blade_flurry") { actor.resource -= 20; actor.bladeFlurryUntil = at + 8; } - if (spec.id === "bulwark_march") state.tankAura.expiresAt = at + 6; + if (spec.id === "bulwark_march") { + state.tankAura.expiresAt = at + 6; + state.tankAura.sourceId = actor.id; + } } -function startAction(actor: PartyCombatantState, spec: AbilitySpec, target: PartyCombatTarget, at: number, state: PartyCombatState) { +function startAction( + actor: PartyCombatantState, + spec: AbilitySpec, + target: PartyCombatTarget, + at: number, + state: PartyCombatState, + kit: PartyCombatKitId, +) { applyStartCosts(actor, spec, at, state); let multiplier = 1; - if (actor.id === "orin" && spec.damage > 0 && actor.overchargeStacks > 0) { + if (kit === "caster" && spec.damage > 0 && actor.overchargeStacks > 0) { multiplier = 1.25; actor.overchargeStacks -= 1; } @@ -301,9 +336,10 @@ function damageTarget( at: number, secondary: boolean, events: PartyDamageEvent[], + profileMultiplier = 1, ) { if (target.boss.hp <= 0 || amount <= 0) return; - const dealt = Math.min(target.boss.hp, amount * action.multiplier * PARTY_DAMAGE_SCALE); + const dealt = Math.min(target.boss.hp, amount * action.multiplier * profileMultiplier * PARTY_DAMAGE_SCALE); target.boss.hp -= dealt; actor.damageDone += dealt; events.push({ id: state.nextEventId++, at, sourceId: actor.id, abilityId: action.abilityId, targetInstanceId: target.instanceId, amount: dealt, secondary }); @@ -311,23 +347,47 @@ function damageTarget( function resolveImpact(state: PartyCombatState, actor: PartyCombatantState, action: PartyCombatAction, at: number, context: PartyCombatContext, targets: PartyCombatTarget[], events: PartyDamageEvent[]) { const source = context.positions[actor.id]; - const range = actor.id === "vale" ? VALE_MELEE_RANGE : actor.id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY; + const kit = combatKit(actor.id, context.party); + const range = kit === "melee" ? VALE_MELEE_RANGE : kit === "tank" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY; let target = targets.find((entry) => entry.instanceId === action.targetInstanceId && entry.boss.hp > 0 && distance(source, entry.motion.position) <= range); target ??= targetFor(actor.id, context, range); if (!target) return; + const damageProfile = context.damageProfiles?.[actor.id] ?? { singleTarget: 1, areaDamage: 1 }; if (action.abilityId === "fan_of_blades" || action.abilityId === "sweeping_guard") { const radius = action.abilityId === "fan_of_blades" ? VALE_CLEAVE_RADIUS : BRANN_MELEE_RANGE; - for (const nearby of targetsInRange(source, targets, radius)) damageTarget(state, actor, action, nearby, action.baseDamage, at, nearby.instanceId !== target.instanceId, events); + for (const nearby of targetsInRange(source, targets, radius)) { + const secondary = nearby.instanceId !== target.instanceId; + damageTarget(state, actor, action, nearby, action.baseDamage, at, secondary, events, secondary ? damageProfile.areaDamage : damageProfile.singleTarget); + } return; } - damageTarget(state, actor, action, target, action.baseDamage, at, false, events); - const secondaryTargets = targetsInRange(source, targets, actor.id === "vale" ? VALE_CLEAVE_RADIUS : BRANN_MELEE_RANGE).filter((entry) => entry.instanceId !== target!.instanceId); + damageTarget(state, actor, action, target, action.baseDamage, at, false, events, damageProfile.singleTarget); + // Legacy encounters keep their established pacing. Run-provided profiles opt + // ranged/caster kits into the configurable splash geometry used by RPG mode. + const areaShape = context.damageProfiles ? AREA_DAMAGE_SHAPES[action.abilityId] : undefined; + if (areaShape) { + for (const secondary of livingTargets(targets)) { + if (secondary.instanceId === target.instanceId || distance(secondary.motion.position, target.motion.position) > areaShape.radius) continue; + damageTarget( + state, + actor, + action, + secondary, + action.baseDamage * areaShape.secondaryDamageRatio, + at, + true, + events, + damageProfile.areaDamage, + ); + } + } + const secondaryTargets = targetsInRange(source, targets, kit === "melee" ? VALE_CLEAVE_RADIUS : BRANN_MELEE_RANGE).filter((entry) => entry.instanceId !== target!.instanceId); if (action.abilityId === "twin_fang") { - for (const secondary of secondaryTargets) damageTarget(state, actor, action, secondary, 2, at, true, events); - } else if (actor.id === "vale" && actor.bladeFlurryUntil > at && !["fan_of_blades", "blade_flurry"].includes(action.abilityId)) { - for (const secondary of secondaryTargets) damageTarget(state, actor, action, secondary, action.baseDamage * 0.5, at, true, events); + for (const secondary of secondaryTargets) damageTarget(state, actor, action, secondary, 2, at, true, events, damageProfile.areaDamage); + } else if (kit === "melee" && actor.bladeFlurryUntil > at && !["fan_of_blades", "blade_flurry"].includes(action.abilityId)) { + for (const secondary of secondaryTargets) damageTarget(state, actor, action, secondary, action.baseDamage * 0.5, at, true, events, damageProfile.areaDamage); } } @@ -351,13 +411,14 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba const actor = state.combatants[id]; const member = context.party.find((entry) => entry.id === id); if (!member || member.hp <= 0) continue; + const kit = combatKit(id, combatContext.party); if (member.knockedUntil > context.time) { actor.activeAction = null; actor.readyAt = Math.max(actor.readyAt, member.knockedUntil); continue; } - if (id === "vale") actor.resource = Math.min(100, actor.resource + 12 * elapsed); - if (id === "brann" && member.hp < actor.lastHp) actor.revengeReadyUntil = context.time + 5; + if (kit === "melee") actor.resource = Math.min(100, actor.resource + 12 * elapsed); + if (kit === "tank" && member.hp < actor.lastHp) actor.revengeReadyUntil = context.time + 5; actor.lastHp = member.hp; const isMoving = moving(id, combatContext); if (isMoving && actor.activeAction?.requiresStationary) { @@ -391,10 +452,10 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba cooldown: baseSpec.cooldown === undefined ? undefined : baseSpec.cooldown * modifier.cooldown, } : baseSpec; if (!spec) { actor.readyAt = context.time + 0.1; break; } - const range = id === "vale" ? VALE_MELEE_RANGE : id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY; + const range = kit === "melee" ? VALE_MELEE_RANGE : kit === "tank" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY; const target = targetFor(id, combatContext, range); if (!target) { actor.readyAt = context.time + 0.1; break; } - startAction(actor, spec, target, startAt, state); + startAction(actor, spec, target, startAt, state, kit); } } diff --git a/src/game/partyState.ts b/src/game/partyState.ts index 256e134..3b0818b 100644 --- a/src/game/partyState.ts +++ b/src/game/partyState.ts @@ -3,3 +3,13 @@ import type { PartyMember } from "./types"; export function isPartyWiped(party: readonly PartyMember[]) { return party.length > 0 && party.every((member) => member.hp <= 0); } + +export function areAllNonHealerAlliesDefeated(party: readonly PartyMember[]) { + let hasAlly = false; + for (const member of party) { + if (member.id === "aelia") continue; + hasAlly = true; + if (member.hp > 0) return false; + } + return hasAlly; +} diff --git a/src/game/performance.test.ts b/src/game/performance.test.ts index 582bb2c..920f100 100644 --- a/src/game/performance.test.ts +++ b/src/game/performance.test.ts @@ -1,13 +1,17 @@ import { describe, expect, it } from "vitest"; +import { AETHER_MAX_ENEMY_SHOTS, AETHER_MAX_PLAYER_SHOTS, AETHER_MAX_SHIPS } from "./aetherAssault"; import { useGameStore } from "./store"; describe("runtime performance budgets", () => { it.each([ - { label: "dual-boss", bossIds: ["emberfox", "sandglass-scorpion"] as const, maxElapsedMs: 3_500 }, - { label: "Rogue Trials trio", bossIds: ["emberfox", "sandglass-scorpion", "tempestscale-dragon"] as const, maxElapsedMs: 5_000 }, - ])("keeps ten minutes of $label simulation bounded", ({ bossIds, maxElapsedMs }) => { + { label: "dual-boss", bossIds: ["emberfox", "sandglass-scorpion"] as const, runMode: "encounter" as const, maxElapsedMs: 3_500 }, + { label: "Rogue Trials trio", bossIds: ["emberfox", "sandglass-scorpion", "tempestscale-dragon"] as const, runMode: "encounter" as const, maxElapsedMs: 5_000 }, + { label: "Hockey Healing", bossIds: ["emberfox", "sandglass-scorpion"] as const, runMode: "hockey-healing" as const, maxElapsedMs: 4_000 }, + { label: "Blockbreaker", bossIds: ["emberfox", "sandglass-scorpion"] as const, runMode: "blockbreaker" as const, maxElapsedMs: 5_000 }, + { label: "Aether Assault", bossIds: ["emberfox", "sandglass-scorpion"] as const, runMode: "aether-assault" as const, maxElapsedMs: 6_000 }, + ])("keeps ten minutes of $label simulation bounded", ({ bossIds, runMode, maxElapsedMs }) => { const store = useGameStore.getState(); - store.configureHealer("priest", "Perf", [], bossIds); + store.configureHealer("priest", "Perf", [], bossIds, runMode); store.startEncounter(); useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 1_000_000_000, maxHp: 1_000_000_000 }, @@ -21,8 +25,22 @@ describe("runtime performance budgets", () => { let maxHazards = 0; let maxDamageEvents = 0; let maxCombatLog = 0; + let maxAetherShips = 0; + let maxAetherPlayerShots = 0; + let maxAetherEnemyShots = 0; const startedAt = performance.now(); for (let step = 0; step < 6_000; step += 1) { + if (runMode === "hockey-healing") { + const hockey = useGameStore.getState().hockey; + useGameStore.setState((state) => ({ + partyPositions: { ...state.partyPositions, aelia: [hockey.puckPosition[0], 8.5] }, + })); + } + if (runMode === "blockbreaker") { + useGameStore.setState((state) => ({ + blockbreaker: { ...state.blockbreaker, bricks: [] }, + })); + } useGameStore.getState().tick(0.1); const state = useGameStore.getState(); const hazards = state.bossMotion.hazards.length @@ -30,6 +48,9 @@ describe("runtime performance budgets", () => { maxHazards = Math.max(maxHazards, hazards); maxDamageEvents = Math.max(maxDamageEvents, state.partyDamageEvents.length); maxCombatLog = Math.max(maxCombatLog, state.combatLog.length); + maxAetherShips = Math.max(maxAetherShips, state.aetherAssault.ships.length); + maxAetherPlayerShots = Math.max(maxAetherPlayerShots, state.aetherAssault.playerShots.length); + maxAetherEnemyShots = Math.max(maxAetherEnemyShots, state.aetherAssault.enemyShots.length); } const elapsedMs = performance.now() - startedAt; const result = useGameStore.getState(); @@ -39,6 +60,54 @@ describe("runtime performance budgets", () => { expect(maxHazards).toBeLessThanOrEqual(64); expect(maxDamageEvents).toBeLessThanOrEqual(24); expect(maxCombatLog).toBeLessThanOrEqual(12); + expect(maxAetherShips).toBeLessThanOrEqual(AETHER_MAX_SHIPS); + expect(maxAetherPlayerShots).toBeLessThanOrEqual(AETHER_MAX_PLAYER_SHOTS); + expect(maxAetherEnemyShots).toBeLessThanOrEqual(AETHER_MAX_ENEMY_SHOTS); expect(elapsedMs).toBeLessThan(maxElapsedMs); }); + + it("keeps ten minutes of Healing Hockey PVP simulation bounded", () => { + const store = useGameStore.getState(); + store.configureHealer( + "priest", + "Perf", + [], + ["emberfox"], + "hockey-healing-pvp", + undefined, + "initiate", + { matchId: null, seed: 17, opponentName: "CPU Perf", role: "cpu" }, + ); + store.startEncounter(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, hp: 1_000_000_000, maxHp: 1_000_000_000 }, + party: state.party.map((member) => ({ ...member, absorb: 1_000_000_000 })), + hockeyPvpOpponent: { + ...state.hockeyPvpOpponent, + boss: { ...state.hockeyPvpOpponent.boss, hp: 1_000_000_000, maxHp: 1_000_000_000 }, + party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, absorb: 1_000_000_000 })), + }, + })); + + let maxHazards = 0; + let maxCombatLog = 0; + const startedAt = performance.now(); + for (let step = 0; step < 6_000; step += 1) { + useGameStore.getState().tick(0.1); + const state = useGameStore.getState(); + maxHazards = Math.max( + maxHazards, + state.bossMotion.hazards.length + state.hockeyPvpOpponent.bossMotion.hazards.length, + ); + maxCombatLog = Math.max(maxCombatLog, state.combatLog.length); + } + const elapsedMs = performance.now() - startedAt; + const result = useGameStore.getState(); + + expect(result.phase).toBe("combat"); + expect(result.time).toBeCloseTo(600, 5); + expect(maxHazards).toBeLessThanOrEqual(64); + expect(maxCombatLog).toBeLessThanOrEqual(12); + expect(elapsedMs).toBeLessThan(8_000); + }); }); diff --git a/src/game/progression/gear.ts b/src/game/progression/gear.ts index 0ea02be..4705d46 100644 --- a/src/game/progression/gear.ts +++ b/src/game/progression/gear.ts @@ -1,4 +1,5 @@ import type { BossGroupId } from "../bossCatalog"; +import { HEALER_CLASS_ORDER, isHealerClassId } from "../healers"; import type { HealerClassId, MemberId, RunBuffId } from "../types"; import { groupDrop, type DifficultySlug, type MaterialStack } from "./loot"; @@ -33,7 +34,7 @@ export interface GearUpgradeCost { quantity: number; } -export const GEAR_OWNER_ORDER: readonly GearOwnerId[] = ["priest", "druid", "shaman", "brann", "nia", "orin", "vale"]; +export const GEAR_OWNER_ORDER: readonly GearOwnerId[] = [...HEALER_CLASS_ORDER, "brann", "nia", "orin", "vale"]; export const GEAR_SLOT_ORDER: readonly GearSlotId[] = ["weapon", "helmet", "chest", "legs", "feet"]; export const MAX_GEAR_LEVEL: GearLevel = 10; @@ -41,6 +42,8 @@ export const GEAR_OWNER_LABELS: Record = { priest: "Priest", druid: "Druid", shaman: "Shaman", + paladin: "Paladin", + chronomancer: "Chronomancer", brann: "Brann · Knight", nia: "Nia · Ranger", orin: "Orin · Mage", @@ -79,6 +82,8 @@ const OWNER_RECIPE_SEEDS: Record = { priest: HEALER_RECIPES, druid: HEALER_RECIPES, shaman: HEALER_RECIPES, + paladin: HEALER_RECIPES, + chronomancer: HEALER_RECIPES, brann: { weapon: ["charge", "scuttle-burst"], helmet: ["slash-cross", "burrow-eruption"], @@ -110,7 +115,7 @@ const OWNER_RECIPE_SEEDS: Record = { }; function statFor(ownerId: GearOwnerId, slotId: GearSlotId): GearStatId { - if (slotId === "weapon") return ownerId === "priest" || ownerId === "druid" || ownerId === "shaman" ? "healingPower" : "damage"; + if (slotId === "weapon") return isHealerClassId(ownerId) ? "healingPower" : "damage"; if (slotId === "helmet") return ownerId === "brann" ? "stunResist" : "attackCooldown"; if (slotId === "chest") return "maxHealth"; if (slotId === "legs") return "moveSpeed"; diff --git a/src/game/progression/gearEffects.ts b/src/game/progression/gearEffects.ts index 641a854..8d75381 100644 --- a/src/game/progression/gearEffects.ts +++ b/src/game/progression/gearEffects.ts @@ -25,6 +25,16 @@ export const EMPTY_MEMBER_GEAR_MODIFIERS: MemberGearModifiers = { stunDuration: 1, }; +export function createBaseEncounterGearModifiers(): EncounterGearModifiers { + return { + aelia: { ...EMPTY_MEMBER_GEAR_MODIFIERS }, + brann: { ...EMPTY_MEMBER_GEAR_MODIFIERS }, + nia: { ...EMPTY_MEMBER_GEAR_MODIFIERS }, + orin: { ...EMPTY_MEMBER_GEAR_MODIFIERS }, + vale: { ...EMPTY_MEMBER_GEAR_MODIFIERS }, + }; +} + export function createEncounterGearModifiers(progress: GearProgress, healerClassId: HealerClassId): EncounterGearModifiers { return { aelia: modifiersForOwner(progress, healerClassId), diff --git a/src/game/progression/hunterStats.test.ts b/src/game/progression/hunterStats.test.ts index 01e03c9..d19c40b 100644 --- a/src/game/progression/hunterStats.test.ts +++ b/src/game/progression/hunterStats.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "./hunterStats"; +import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch } from "./hunterStats"; describe("roguelike hunter records", () => { it("records the reached defeat round without lowering a previous best", () => { @@ -26,3 +26,53 @@ describe("Rogue Trials endless hunter records", () => { expect(highestEndlessBossKillsAfterDefeat(4.9, 8.9)).toBe(8); }); }); + +describe("Hockey Healing records", () => { + it("ranks returns first and duration second", () => { + expect(bestHockeyHealingRecord(12, 90, 13, 70)).toEqual({ returns: 13, durationSeconds: 70 }); + expect(bestHockeyHealingRecord(13, 70, 13, 82)).toEqual({ returns: 13, durationSeconds: 82 }); + expect(bestHockeyHealingRecord(13, 82, 12, 120)).toEqual({ returns: 13, durationSeconds: 82 }); + }); + + it("normalizes invalid record values", () => { + expect(bestHockeyHealingRecord(Number.NaN, Number.NaN, 4.9, 12.5)).toEqual({ returns: 4, durationSeconds: 12.5 }); + }); +}); + +describe("Healing Hockey PVP records", () => { + it("increments wins and losses independently", () => { + expect(hockeyPvpRecordAfterMatch(3, 2, true)).toEqual({ wins: 4, losses: 2 }); + expect(hockeyPvpRecordAfterMatch(3, 2, false)).toEqual({ wins: 3, losses: 3 }); + }); +}); + +describe("Blockbreaker records", () => { + it("keeps bricks, duration, and score as independent lifetime highs", () => { + expect(bestBlockbreakerRecords(50, 120, 4_000, 60, 90, 3_500)).toEqual({ + bricks: 60, + durationSeconds: 120, + score: 4_000, + }); + }); + + it("normalizes invalid and fractional values", () => { + expect(bestBlockbreakerRecords(Number.NaN, Number.NaN, Number.NaN, 4.9, 12.5, 99.9)).toEqual({ + bricks: 4, + durationSeconds: 12.5, + score: 99, + }); + }); +}); + +describe("Aether Assault records", () => { + it("keeps wave and duration from the best-score record", () => { + expect(bestAetherAssaultRecord(10_000, 5, 120, 11_000, 4, 90)).toEqual({ score: 11_000, wave: 4, durationSeconds: 90 }); + expect(bestAetherAssaultRecord(11_000, 4, 90, 11_000, 6, 80)).toEqual({ score: 11_000, wave: 6, durationSeconds: 80 }); + expect(bestAetherAssaultRecord(11_000, 6, 80, 10_500, 9, 200)).toEqual({ score: 11_000, wave: 6, durationSeconds: 80 }); + }); + + it("uses duration only after score and wave ties", () => { + expect(bestAetherAssaultRecord(11_000, 6, 80, 11_000, 6, 95)).toEqual({ score: 11_000, wave: 6, durationSeconds: 95 }); + expect(bestAetherAssaultRecord(Number.NaN, Number.NaN, Number.NaN, 99.9, 3.9, 12.5)).toEqual({ score: 99, wave: 3, durationSeconds: 12.5 }); + }); +}); diff --git a/src/game/progression/hunterStats.ts b/src/game/progression/hunterStats.ts index 83e4809..486f7d5 100644 --- a/src/game/progression/hunterStats.ts +++ b/src/game/progression/hunterStats.ts @@ -9,3 +9,94 @@ export function highestEndlessBossKillsAfterDefeat(currentRecord: number, bossKi const normalizedKills = Math.max(0, Math.floor(Number(bossKills) || 0)); return Math.max(normalizedRecord, normalizedKills); } + +export interface HockeyHealingRecord { + returns: number; + durationSeconds: number; +} + +export function bestHockeyHealingRecord( + currentReturns: number, + currentDurationSeconds: number, + runReturns: number, + runDurationSeconds: number, +): HockeyHealingRecord { + const current: HockeyHealingRecord = { + returns: Math.max(0, Math.floor(Number(currentReturns) || 0)), + durationSeconds: Math.max(0, Number(currentDurationSeconds) || 0), + }; + const run: HockeyHealingRecord = { + returns: Math.max(0, Math.floor(Number(runReturns) || 0)), + durationSeconds: Math.max(0, Number(runDurationSeconds) || 0), + }; + return run.returns > current.returns || run.returns === current.returns && run.durationSeconds > current.durationSeconds + ? run + : current; +} + +export function hockeyPvpRecordAfterMatch(currentWins: number, currentLosses: number, won: boolean) { + const wins = Math.max(0, Math.floor(Number(currentWins) || 0)); + const losses = Math.max(0, Math.floor(Number(currentLosses) || 0)); + return won ? { wins: wins + 1, losses } : { wins, losses: losses + 1 }; +} + +export interface BlockbreakerRecords { + bricks: number; + durationSeconds: number; + score: number; +} + +export function bestBlockbreakerRecords( + currentBricks: number, + currentDurationSeconds: number, + currentScore: number, + runBricks: number, + runDurationSeconds: number, + runScore: number, +): BlockbreakerRecords { + return { + bricks: Math.max( + Math.max(0, Math.floor(Number(currentBricks) || 0)), + Math.max(0, Math.floor(Number(runBricks) || 0)), + ), + durationSeconds: Math.max( + Math.max(0, Number(currentDurationSeconds) || 0), + Math.max(0, Number(runDurationSeconds) || 0), + ), + score: Math.max( + Math.max(0, Math.floor(Number(currentScore) || 0)), + Math.max(0, Math.floor(Number(runScore) || 0)), + ), + }; +} + +export interface AetherAssaultRecord { + score: number; + wave: number; + durationSeconds: number; +} + +export function bestAetherAssaultRecord( + currentScore: number, + currentWave: number, + currentDurationSeconds: number, + runScore: number, + runWave: number, + runDurationSeconds: number, +): AetherAssaultRecord { + const current = { + score: Math.max(0, Math.floor(Number(currentScore) || 0)), + wave: Math.max(0, Math.floor(Number(currentWave) || 0)), + durationSeconds: Math.max(0, Number(currentDurationSeconds) || 0), + }; + const run = { + score: Math.max(0, Math.floor(Number(runScore) || 0)), + wave: Math.max(0, Math.floor(Number(runWave) || 0)), + durationSeconds: Math.max(0, Number(runDurationSeconds) || 0), + }; + return run.score > current.score + || run.score === current.score && run.wave > current.wave + || run.score === current.score && run.wave === current.wave && run.durationSeconds > current.durationSeconds + ? run + : current; +} diff --git a/src/game/progression/infusions.ts b/src/game/progression/infusions.ts index dca4261..9578478 100644 --- a/src/game/progression/infusions.ts +++ b/src/game/progression/infusions.ts @@ -1,4 +1,5 @@ import { RUN_BUFFS, RUN_BUFF_ORDER } from "../roguelike"; +import { isHealerClassId } from "../healers"; import type { BossId, RunBuffId } from "../types"; import { GEAR_RECIPES, @@ -52,6 +53,16 @@ const INFUSION_SEEDS: Record = { { id: "shaman-ancestral-surge", name: "Ancestral Surge", icon: "ϟ", description: "8% more healing power.", linkedBossId: "emberfox", effectKey: "healing-power" }, { id: "shaman-windwalk", name: "Windwalk", icon: "≋", description: "8% faster movement.", linkedBossId: "sandglass-scorpion", effectKey: "move-speed" }, ], + paladin: [ + { id: "paladin-sanctified-plate", name: "Sanctified Plate", icon: "▣", description: "8% more maximum health.", linkedBossId: "bristlequake-boar", effectKey: "max-health" }, + { id: "paladin-burning-oath", name: "Burning Oath", icon: "⚔", description: "8% more healing power.", linkedBossId: "emberfox", effectKey: "healing-power" }, + { id: "paladin-crusaders-step", name: "Crusader's Step", icon: "⌁", description: "8% faster movement.", linkedBossId: "sandglass-scorpion", effectKey: "move-speed" }, + ], + chronomancer: [ + { id: "chronomancer-stolen-second", name: "Stolen Second", icon: "◷", description: "8% faster cooldowns.", linkedBossId: "sandglass-scorpion", effectKey: "cooldown" }, + { id: "chronomancer-preserved-future", name: "Preserved Future", icon: "∞", description: "8% more healing power.", linkedBossId: "tempestscale-dragon", effectKey: "healing-power" }, + { id: "chronomancer-paradox-ward", name: "Paradox Ward", icon: "◇", description: "15% less hazard damage.", linkedBossId: "broodfang-spider", effectKey: "hazard-shield" }, + ], brann: [ { id: "brann-unbreakable", name: "Unbreakable", icon: "▣", description: "Immune to stuns.", linkedBossId: "bristlequake-boar", effectKey: "stun-immune" }, { id: "brann-bulwark", name: "Bulwark", icon: "⬡", description: "8% more maximum health.", linkedBossId: "bulldrome", effectKey: "max-health" }, @@ -130,7 +141,7 @@ export function equipActiveInfusion( } export function equipPassiveInfusion(progress: GearProgress, ownerId: GearOwnerId, passiveId: RunBuffId): GearProgress { - if (ownerId !== "priest" && ownerId !== "druid" && ownerId !== "shaman") throw new Error("Passive infusions belong to healer gear."); + if (!isHealerClassId(ownerId)) throw new Error("Passive infusions belong to healer gear."); if (!RUN_BUFF_ORDER.includes(passiveId)) throw new Error("Unknown passive infusion."); if (!passiveInfusionUnlocked(progress)) throw new Error(`Raise any gear slot to +${PASSIVE_INFUSION_MIN_GEAR_LEVEL}.`); return { ...progress, [ownerId]: { ...progress[ownerId], passiveInfusionId: passiveId } }; @@ -141,6 +152,6 @@ export function normalizeActiveInfusionId(ownerId: GearOwnerId, value: unknown): } export function normalizePassiveInfusionId(ownerId: GearOwnerId, value: unknown): RunBuffId | null { - if (ownerId !== "priest" && ownerId !== "druid" && ownerId !== "shaman") return null; + if (!isHealerClassId(ownerId)) return null; return typeof value === "string" && RUN_BUFF_ORDER.includes(value as RunBuffId) ? value as RunBuffId : null; } diff --git a/src/game/roguelike.ts b/src/game/roguelike.ts index 14a0b85..03be434 100644 --- a/src/game/roguelike.ts +++ b/src/game/roguelike.ts @@ -1,6 +1,6 @@ import { AVAILABLE_BOSS_IDS } from "./bossCatalog"; import { canAddBossToEncounter } from "./bossSelection"; -import type { AbilityId, BossId, RunBuffId, RunBuffRanks } from "./types"; +import type { AbilitySlotId, BossId, RunBuffId, RunBuffRanks } from "./types"; export type RunBuffEffectKind = | "extra-target" @@ -18,7 +18,7 @@ export type RunBuffEffectKind = export interface RunBuffDefinition { id: RunBuffId; - abilityId: AbilityId; + abilitySlotId: AbilitySlotId; effectKind: RunBuffEffectKind; name: string; icon: string; @@ -73,7 +73,7 @@ export const RUN_BUFF_ORDER: readonly RunBuffId[] = [ const buff = ( id: RunBuffId, - abilityId: AbilityId, + abilitySlotId: AbilitySlotId, effectKind: RunBuffEffectKind, name: string, icon: string, @@ -81,27 +81,27 @@ const buff = ( detail: string, accent: string, maxRank: 1 | 3 = 3, -): RunBuffDefinition => ({ id, abilityId, effectKind, name, icon, summary, detail, accent, maxRank, infusionEligible: true }); +): RunBuffDefinition => ({ id, abilitySlotId, effectKind, name, icon, summary, detail, accent, maxRank, infusionEligible: true }); export const RUN_BUFFS: Record = { - "mend-echo": buff("mend-echo", "mend", "extra-target", "Echoing", "+", "+1 secondary ally", "Mend heals another injured ally for 50% power per rank.", "#f2d690"), - "mend-efficiency": buff("mend-efficiency", "mend", "mana-cost", "Efficient", "▽", "−25% mana cost", "Mend mana cost is multiplied by 0.75 per rank, rounded up.", "#72c8ef"), - "mend-cast-speed": buff("mend-cast-speed", "mend", "cast-time", "Swift", "»", "−25% cast time", "Mend cast time is multiplied by 0.75 per rank.", "#d8b4ff"), - "renew-spread": buff("renew-spread", "renew", "extra-target", "Spreading", "✣", "+1 injured ally", "Direct Renew casts affect another injured ally per rank.", "#71df9c"), - "renew-duration": buff("renew-duration", "renew", "duration", "Enduring", "◷", "+2s duration", "Every Renew effect lasts 2 seconds longer per rank.", "#83d9aa"), - "renew-potency": buff("renew-potency", "renew", "healing", "Potent", "↑", "+20% tick healing", "Every Renew tick heals 20% more per rank.", "#a8e875"), - "shield-echo": buff("shield-echo", "shield", "extra-target", "Echoing Aegis", "◇", "+1 secondary ally", "Shield another injured ally for 50% power per rank.", "#75c9ff"), - "shield-potency": buff("shield-potency", "shield", "absorb", "Reinforced Aegis", "⬡", "+25% absorption", "All healer-created absorption is 25% stronger per rank.", "#61b9ee"), - "shield-guard": buff("shield-guard", "shield", "damage-reduction", "Guardian Aegis", "▣", "−8% shielded damage", "Targets with absorption take 8% less incoming damage per rank.", "#8baeff"), - "purify-renew": buff("purify-renew", "purify", "trigger-renew", "Cleansing Renewal", "✧", "Purify applies Renew", "Every ally cleansed by Purify also gains Renew.", "#b58cff", 1), - "purify-shield": buff("purify-shield", "purify", "trigger-shield", "Purifying Ward", "◈", "Purify grants 50% Shield", "Every ally cleansed by Purify gains half-strength absorption.", "#9f9aff", 1), - "purify-chain": buff("purify-chain", "purify", "chain-cleanse", "Mass Purification", "✦", "+1 cleansed ally", "Purify also cleanses the most injured other debuffed ally.", "#d4a7ff", 1), - "radiance-cooldown": buff("radiance-cooldown", "radiance", "cooldown", "Quickened Radiance", "☀", "−20% cooldown", "Radiance cooldown is multiplied by 0.8 per rank.", "#ffd66b"), - "radiance-renew": buff("radiance-renew", "radiance", "trigger-renew", "Radiant Renewal", "❈", "Radiance applies Renew", "Radiance applies Renew to every living party member.", "#d4e978", 1), - "radiance-shield": buff("radiance-shield", "radiance", "absorb", "Radiant Aegis", "◎", "+9 party absorption", "Radiance grants 9 base absorption to every living ally per rank.", "#ffe58c"), - "barrier-cooldown": buff("barrier-cooldown", "barrier", "cooldown", "Hallowed Ground", "◉", "−20% cooldown", "Barrier cooldown is multiplied by 0.8 per rank.", "#e7cb62"), - "barrier-duration": buff("barrier-duration", "barrier", "duration", "Lingering Barrier", "⌛", "+2s duration", "Barrier remains active 2 seconds longer per rank.", "#cdbd69"), - "barrier-regen": buff("barrier-regen", "barrier", "barrier-healing", "Restorative Ground", "✚", "+3 healing per second", "Living allies inside Barrier heal every second per rank.", "#84d69a"), + "mend-echo": buff("mend-echo", "ability1", "extra-target", "Echoing", "+", "+1 secondary ally", "Ability 1 heals another injured ally for 50% power per rank.", "#f2d690"), + "mend-efficiency": buff("mend-efficiency", "ability1", "mana-cost", "Efficient", "▽", "−25% mana cost", "Ability 1 mana cost is multiplied by 0.75 per rank, rounded up.", "#72c8ef"), + "mend-cast-speed": buff("mend-cast-speed", "ability1", "cast-time", "Swift", "»", "−25% cast time", "Ability 1 cast time is multiplied by 0.75 per rank.", "#d8b4ff"), + "renew-spread": buff("renew-spread", "ability2", "extra-target", "Spreading", "✣", "+1 injured ally", "Direct Ability 2 casts affect another injured ally per rank.", "#71df9c"), + "renew-duration": buff("renew-duration", "ability2", "duration", "Enduring", "◷", "+2s duration", "Ability 2 periodic healing lasts 2 seconds longer per rank.", "#83d9aa"), + "renew-potency": buff("renew-potency", "ability2", "healing", "Potent", "↑", "+20% tick healing", "Ability 2 periodic healing is 20% stronger per rank.", "#a8e875"), + "shield-echo": buff("shield-echo", "ability3", "extra-target", "Echoing Ward", "◇", "+1 secondary ally", "Ability 3 affects another injured ally at 50% power per rank.", "#75c9ff"), + "shield-potency": buff("shield-potency", "ability3", "absorb", "Reinforced Ward", "⬡", "+25% ward power", "Ability 3 healing or absorption is 25% stronger per rank.", "#61b9ee"), + "shield-guard": buff("shield-guard", "ability3", "damage-reduction", "Guardian Ward", "▣", "−8% warded damage", "Targets protected by Ability 3 take 8% less incoming damage per rank.", "#8baeff"), + "purify-renew": buff("purify-renew", "ability4", "trigger-renew", "Cleansing Renewal", "✧", "Cleanse applies Ability 2", "Every ally cleansed by Ability 4 also gains Ability 2's healing effect.", "#b58cff", 1), + "purify-shield": buff("purify-shield", "ability4", "trigger-shield", "Purifying Ward", "◈", "Cleanse grants Ability 3", "Every ally cleansed by Ability 4 gains a half-strength Ability 3 effect.", "#9f9aff", 1), + "purify-chain": buff("purify-chain", "ability4", "chain-cleanse", "Mass Purification", "✦", "+1 cleansed ally", "Ability 4 also cleanses the most injured other debuffed ally.", "#d4a7ff", 1), + "radiance-cooldown": buff("radiance-cooldown", "ability5", "cooldown", "Quickened", "☀", "−20% cooldown", "Ability 5 cooldown is multiplied by 0.8 per rank.", "#ffd66b"), + "radiance-renew": buff("radiance-renew", "ability5", "trigger-renew", "Restorative Wave", "❈", "Ability 5 applies Ability 2", "Ability 5 applies Ability 2's healing effect to every living party member.", "#d4e978", 1), + "radiance-shield": buff("radiance-shield", "ability5", "absorb", "Warding Wave", "◎", "+9 party ward", "Ability 5 grants 9 base healing or absorption to every living ally per rank.", "#ffe58c"), + "barrier-cooldown": buff("barrier-cooldown", "ability6", "cooldown", "Quickened Mastery", "◉", "−20% cooldown", "Ability 6 cooldown is multiplied by 0.8 per rank.", "#e7cb62"), + "barrier-duration": buff("barrier-duration", "ability6", "duration", "Lingering Mastery", "⌛", "+2s duration", "Ability 6 remains active 2 seconds longer per rank.", "#cdbd69"), + "barrier-regen": buff("barrier-regen", "ability6", "barrier-healing", "Restorative Mastery", "✚", "+3 healing per second", "Ability 6 adds 3 party healing per second per rank.", "#84d69a"), }; export function runBuffRank(ranks: RunBuffRanks, buffId: RunBuffId): number { @@ -160,44 +160,53 @@ export function compileRunModifiers(ranks: RunBuffRanks, passiveInfusionId: RunB }; } -export function runAbilityManaCost(abilityId: AbilityId, baseCost: number, modifiers: CompiledRunModifiers): number { +export function runAbilityManaCost(abilitySlotId: AbilitySlotId, baseCost: number, modifiers: CompiledRunModifiers): number { if (baseCost <= 0) return 0; - const multiplier = abilityId === "mend" ? modifiers.mendManaMultiplier : 1; + const multiplier = abilitySlotId === "ability1" ? modifiers.mendManaMultiplier : 1; return Math.max(1, Math.ceil(baseCost * multiplier)); } -export function runAbilityCastTime(abilityId: AbilityId, baseCastTime: number, modifiers: CompiledRunModifiers): number { - return abilityId === "mend" ? baseCastTime * modifiers.mendCastTimeMultiplier : baseCastTime; +export function runAbilityCastTime(abilitySlotId: AbilitySlotId, baseCastTime: number, modifiers: CompiledRunModifiers): number { + return abilitySlotId === "ability1" ? baseCastTime * modifiers.mendCastTimeMultiplier : baseCastTime; } -export function runAbilityCooldown(abilityId: AbilityId, baseCooldown: number, modifiers: CompiledRunModifiers): number { - if (abilityId === "radiance") return baseCooldown * modifiers.radianceCooldownMultiplier; - if (abilityId === "barrier") return baseCooldown * modifiers.barrierCooldownMultiplier; +export function runAbilityCooldown(abilitySlotId: AbilitySlotId, baseCooldown: number, modifiers: CompiledRunModifiers): number { + if (abilitySlotId === "ability5") return baseCooldown * modifiers.radianceCooldownMultiplier; + if (abilitySlotId === "ability6") return baseCooldown * modifiers.barrierCooldownMultiplier; return baseCooldown; } -export function formatRunBuffEffect(buffId: RunBuffId, requestedRank: number): string { +export function formatRunBuffEffect(buffId: RunBuffId, requestedRank: number, abilityName?: string): string { const rank = Math.max(1, Math.min(RUN_BUFFS[buffId].maxRank, requestedRank)); const reduced = (multiplier: number) => `${Math.round((1 - multiplier ** rank) * 100)}% less`; + const defaultAbilityNames: Record = { + ability1: "Mend", + ability2: "Renew", + ability3: "Shield", + ability4: "Purify", + ability5: "Radiance", + ability6: "Barrier", + }; + const name = abilityName ?? defaultAbilityNames[RUN_BUFFS[buffId].abilitySlotId]; switch (buffId) { case "mend-echo": return `${rank} secondary ${rank === 1 ? "ally" : "allies"} at 50% healing`; - case "mend-efficiency": return `${reduced(0.75)} Mend mana cost`; - case "mend-cast-speed": return `${reduced(0.75)} Mend cast time`; - case "renew-spread": return `${rank} additional Renew ${rank === 1 ? "target" : "targets"}`; - case "renew-duration": return `+${rank * 2}s Renew duration`; - case "renew-potency": return `+${rank * 20}% Renew tick healing`; - case "shield-echo": return `${rank} secondary Shield ${rank === 1 ? "target" : "targets"} at 50% power`; - case "shield-potency": return `+${rank * 25}% healer absorption`; - case "shield-guard": return `${rank * 8}% less damage while shielded`; - case "purify-renew": return "Purify applies Renew"; - case "purify-shield": return "Purify grants 50% Shield"; - case "purify-chain": return "Purify cleanses one additional ally"; - case "radiance-cooldown": return `${reduced(0.8)} Radiance cooldown`; - case "radiance-renew": return "Radiance applies Renew party-wide"; - case "radiance-shield": return `+${rank * 9} base party absorption`; - case "barrier-cooldown": return `${reduced(0.8)} Barrier cooldown`; - case "barrier-duration": return `+${rank * 2}s Barrier duration`; - case "barrier-regen": return `${rank * 3} Barrier healing per second`; + case "mend-efficiency": return `${reduced(0.75)} ${name} mana cost`; + case "mend-cast-speed": return `${reduced(0.75)} ${name} cast time`; + case "renew-spread": return `${rank} additional ${name} ${rank === 1 ? "target" : "targets"}`; + case "renew-duration": return `+${rank * 2}s ${name} duration`; + case "renew-potency": return `+${rank * 20}% ${name} tick healing`; + case "shield-echo": return `${rank} secondary ${name} ${rank === 1 ? "target" : "targets"} at 50% power`; + case "shield-potency": return `+${rank * 25}% ${name} power`; + case "shield-guard": return `${rank * 8}% less damage while protected by ${name}`; + case "purify-renew": return `${name} applies periodic healing`; + case "purify-shield": return `${name} grants a 50% ward`; + case "purify-chain": return `${name} cleanses one additional ally`; + case "radiance-cooldown": return `${reduced(0.8)} ${name} cooldown`; + case "radiance-renew": return `${name} spreads periodic healing party-wide`; + case "radiance-shield": return `+${rank * 9} base party healing or absorption`; + case "barrier-cooldown": return `${reduced(0.8)} ${name} cooldown`; + case "barrier-duration": return `+${rank * 2}s ${name} duration`; + case "barrier-regen": return `${rank * 3} ${name} healing per second`; } } diff --git a/src/game/rpgRoguelike/challenges.ts b/src/game/rpgRoguelike/challenges.ts new file mode 100644 index 0000000..b3bee78 --- /dev/null +++ b/src/game/rpgRoguelike/challenges.ts @@ -0,0 +1,100 @@ +import { randomInt } from "./random"; +import type { + ChallengeId, + ChallengeMetric, + ChallengeMetrics, + ChallengeObjective, + ChallengeResult, + RandomState, +} from "./types"; + +export interface ChallengeDefinition { + readonly id: ChallengeId; + readonly name: string; + readonly metric: ChallengeMetric; + readonly targets: readonly number[]; + readonly baseCurrency: number; +} + +export const CHALLENGE_REGISTRY: Record = { + blockbreaker: { + id: "blockbreaker", + name: "Brickbreaker Trial", + metric: "bricksBroken", + targets: [30, 45, 60], + baseCurrency: 24, + }, + hockey: { + id: "hockey", + name: "Hockey Shutout", + metric: "bossKills", + targets: [1, 2, 3], + baseCurrency: 28, + }, + "aether-assault": { + id: "aether-assault", + name: "Aether Assault", + metric: "kills", + targets: [20, 35, 50], + baseCurrency: 24, + }, +}; + +export const CHALLENGE_ORDER = Object.keys(CHALLENGE_REGISTRY) as ChallengeId[]; + +export function emptyChallengeMetrics(): ChallengeMetrics { + return { bricksBroken: 0, bossKills: 0, kills: 0 }; +} + +export function emptyChallengeCounts(): Record { + return { blockbreaker: 0, hockey: 0, "aether-assault": 0 }; +} + +export function challengeObjective(challengeId: ChallengeId, priorOccurrences: number): ChallengeObjective { + const definition = CHALLENGE_REGISTRY[challengeId]; + const repeatIndex = Math.max(0, Math.floor(priorOccurrences)); + const tier = Math.min(repeatIndex, definition.targets.length - 1); + return { + challengeId, + name: definition.name, + metric: definition.metric, + target: definition.targets[tier], + repeatIndex, + rewardCurrency: definition.baseCurrency + tier * 8, + chestQualityBonus: tier >= 2 ? 2 : 1, + }; +} + +export function selectChallenge( + source: RandomState, + counts: Readonly>, +): { + objective: ChallengeObjective; + counts: Record; + random: RandomState; +} { + const sampled = randomInt(source, CHALLENGE_ORDER.length); + const challengeId = CHALLENGE_ORDER[sampled.value]; + const priorOccurrences = counts[challengeId] ?? 0; + return { + objective: challengeObjective(challengeId, priorOccurrences), + counts: { ...counts, [challengeId]: priorOccurrences + 1 }, + random: sampled.random, + }; +} + +export function mergeChallengeMetrics( + current: ChallengeMetrics, + update: Partial, +): ChallengeMetrics { + return { + bricksBroken: Math.max(0, Math.floor(update.bricksBroken ?? current.bricksBroken)), + bossKills: Math.max(0, Math.floor(update.bossKills ?? current.bossKills)), + kills: Math.max(0, Math.floor(update.kills ?? current.kills)), + }; +} + +export function evaluateChallenge(objective: ChallengeObjective, metrics: ChallengeMetrics): ChallengeResult { + return { objective, metrics, succeeded: metrics[objective.metric] >= objective.target }; +} + diff --git a/src/game/rpgRoguelike/combatAdapter.test.ts b/src/game/rpgRoguelike/combatAdapter.test.ts new file mode 100644 index 0000000..6374e4f --- /dev/null +++ b/src/game/rpgRoguelike/combatAdapter.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { assignRosterToCombatSlots } from "./party"; +import { createRunGearItem } from "./rewards"; +import type { + CombatPartyAssignment, + PartyArchetypeId, + PartyRosterMember, + RunEquipment, +} from "./types"; +import { + createRpgDamageProfiles, + extractRpgPartyVitals, + normalizedSpellRank, + projectRpgCombat, + runGearMultipliers, + spellRankCooldownMultiplier, + spellRankPowerMultiplier, +} from "./combatAdapter"; + +function member( + instanceId: string, + archetypeId: PartyArchetypeId, + overrides: Partial = {}, +): PartyRosterMember { + return { + candidateId: `candidate-${instanceId}`, + instanceId, + name: instanceId, + archetypeId, + classId: "warrior", + className: "Warrior", + role: "Damage", + rarity: "green", + stats: { maxHp: 100, singleTarget: 1, areaDamage: 1, defense: 1, moveSpeed: 1 }, + traitIds: [], + color: "#fff", + hp: 100, + ...overrides, + }; +} + +function draftedRoster(): PartyRosterMember[] { + return [ + member("tank-instance", "warrior-tank", { + role: "Tank", + stats: { maxHp: 145, singleTarget: 0.8, areaDamage: 1.02, defense: 1.14, moveSpeed: 0.96 }, + hp: 72.5, + }), + member("warlock-instance", "warlock-damage", { + classId: "warlock", + className: "Warlock", + stats: { maxHp: 84, singleTarget: 1.24, areaDamage: 0.8, defense: 0.84, moveSpeed: 0.94 }, + hp: 84, + }), + member("monk-instance", "monk-damage", { + classId: "monk", + className: "Monk", + stats: { maxHp: 82, singleTarget: 1.28, areaDamage: 0.76, defense: 0.82, moveSpeed: 1.22 }, + hp: 82, + }), + member("warrior-instance", "warrior-damage", { + stats: { maxHp: 112, singleTarget: 1.02, areaDamage: 1.28, defense: 1, moveSpeed: 1 }, + hp: 112, + }), + ]; +} + +function runEquipment(): RunEquipment { + return { + player: { + weapon: createRunGearItem("player-weapon", "player", "weapon", 4), + armor: createRunGearItem("player-armor", "player", "armor", 1), + trinket: createRunGearItem("player-trinket", "player", "trinket", 1), + }, + "tank-instance": { + weapon: createRunGearItem("tank-weapon", "tank-instance", "weapon", 1), + armor: createRunGearItem("tank-armor", "tank-instance", "armor", 2), + trinket: createRunGearItem("tank-trinket", "tank-instance", "trinket", 2), + }, + }; +} + +describe("RPG roguelike combat adapter", () => { + it("projects drafted identities into stable combat slots with run-only gear", () => { + const roster = draftedRoster(); + const projection = projectRpgCombat( + { roster, equipment: runEquipment() }, + { healerClassId: "druid", playerName: "Willow" }, + ); + + expect(projection.party.map((entry) => entry.id)).toEqual(["aelia", "brann", "nia", "orin", "vale"]); + expect(projection.party[0]).toMatchObject({ name: "Willow", className: "Restoration Druid", maxHp: 108, hp: 108 }); + + const tank = projection.party[1]; + expect(tank).toMatchObject({ + id: "brann", + name: "tank-instance", + role: "Tank", + maxHp: 162, + hp: 81, + runProfile: { + instanceId: "tank-instance", + combatProfileId: "warrior-tank", + combatKitId: "tank", + tier: "green", + visualArchetype: "knight", + }, + }); + expect(projection.party[2].runProfile?.visualArchetype).toBe("mage"); + expect(projection.party[3].runProfile?.visualArchetype).toBe("rogue"); + expect(projection.gearModifiers.brann.damage).toBeCloseTo(1.08); + expect(projection.gearModifiers.brann.cooldown).toBeCloseTo(1 / 1.06); + expect(projection.gearModifiers.aelia.healingPower).toBeCloseTo(1.2); + expect(projection.gearModifiers.aelia.cooldown).toBeCloseTo(1 / 1.04); + }); + + it("keeps class damage identity and archetype kits independent from stable slots", () => { + const projection = projectRpgCombat( + { roster: draftedRoster(), equipment: runEquipment() }, + { healerClassId: "priest", playerName: "Aelia" }, + ); + expect(projection.party.slice(1).map((member) => member.runProfile?.combatKitId)).toEqual([ + "tank", "ranged", "melee", "melee", + ]); + const profiles = createRpgDamageProfiles(assignRosterToCombatSlots(draftedRoster())); + expect(profiles.brann).toEqual({ singleTarget: 0.8, areaDamage: 1.02 }); + expect(profiles.nia).toEqual({ singleTarget: 1.24, areaDamage: 0.8 }); + expect(profiles.orin.singleTarget).toBeGreaterThan(profiles.vale.singleTarget); + expect(profiles.vale.areaDamage).toBeGreaterThan(profiles.vale.singleTarget); + + const empty: CombatPartyAssignment = { brann: null, nia: null, orin: null, vale: null }; + expect(createRpgDamageProfiles(empty).brann).toEqual({ singleTarget: 0, areaDamage: 0 }); + }); + + it("extracts gear-independent persistent health by drafted instance id", () => { + const roster = draftedRoster(); + const combatParty = assignRosterToCombatSlots(roster); + const projection = projectRpgCombat( + { roster, equipment: runEquipment() }, + { healerClassId: "priest", playerName: "Aelia" }, + ); + const damaged = projection.party.map((entry) => entry.id === "brann" + ? { ...entry, hp: entry.maxHp * 0.25 } + : entry); + + const vitals = extractRpgPartyVitals(damaged, combatParty); + expect(vitals[0]).toEqual({ instanceId: "tank-instance", hp: 36.25 }); + expect(vitals.map((entry) => entry.instanceId)).toEqual([ + "tank-instance", + "warlock-instance", + "monk-instance", + "warrior-instance", + ]); + }); + + it("uses bounded reciprocal haste and bounded spell-rank scaling", () => { + const gear = runGearMultipliers(runEquipment(), "tank-instance"); + expect(gear).toEqual({ maxHealth: 1.12, damage: 1.08, cooldown: 1 / 1.06 }); + + const ranks = { "priest-mend": 3, "druid-regrowth": 99, "shaman-riptide": -4 } as const; + expect(normalizedSpellRank(ranks, "priest-mend")).toBe(3); + expect(normalizedSpellRank(ranks, "druid-regrowth")).toBe(5); + expect(normalizedSpellRank(ranks, "shaman-riptide")).toBe(0); + expect(spellRankPowerMultiplier(ranks, "priest-mend")).toBeCloseTo(1.36); + expect(spellRankCooldownMultiplier(ranks, "priest-mend")).toBeCloseTo(0.85); + expect(spellRankCooldownMultiplier(ranks, "druid-regrowth")).toBeCloseTo(0.75); + }); +}); diff --git a/src/game/rpgRoguelike/combatAdapter.ts b/src/game/rpgRoguelike/combatAdapter.ts new file mode 100644 index 0000000..9e425b9 --- /dev/null +++ b/src/game/rpgRoguelike/combatAdapter.ts @@ -0,0 +1,264 @@ +import { freshParty } from "../data"; +import type { AiCombatantId } from "../partyCombat"; +import { + EMPTY_MEMBER_GEAR_MODIFIERS, + type EncounterGearModifiers, + type MemberGearModifiers, +} from "../progression/gearEffects"; +import type { HealerAbilityId, HealerClassId, PartyCombatKitId, PartyMember } from "../types"; +import { assignRosterToCombatSlots } from "./party"; +import type { + CombatPartyAssignment, + PartyArchetypeId, + PartyRosterMember, + PartyVitalUpdate, + RpgRoguelikeRunState, + RunEquipment, + RunGearOwnerId, + RunGearStatId, +} from "./types"; +import { MAX_SPELL_RANK } from "./types"; + +export const RPG_COMBAT_SLOT_IDS: readonly AiCombatantId[] = ["brann", "nia", "orin", "vale"]; + +export const SPELL_POWER_PER_RANK = 0.12; +export const SPELL_COOLDOWN_REDUCTION_PER_RANK = 0.05; + +export type RpgPartyDamageProfiles = Record; + +export interface RpgCombatProjection { + /** Existing combat systems keep their stable ids; runProfile carries drafted identity. */ + readonly party: PartyMember[]; + readonly gearModifiers: EncounterGearModifiers; + readonly damageProfiles: RpgPartyDamageProfiles; +} + +export interface ProjectRpgCombatOptions { + readonly healerClassId: HealerClassId; + readonly playerName: string; +} + +type CombatProjectionSource = Pick; + +const PERCENT_SCALE = 100; + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} + +function normalizedPercent(value: number): number { + return Math.max(0, Number.isFinite(value) ? value : 0) / PERCENT_SCALE; +} + +function ownerGearStats(equipment: RunEquipment, ownerId: RunGearOwnerId): Record { + const totals: Record = { damage: 0, maxHealth: 0, haste: 0 }; + for (const item of Object.values(equipment[ownerId] ?? {})) { + if (item) totals[item.statId] += normalizedPercent(item.statValue); + } + return totals; +} + +/** + * Converts additive percentage-point run gear into simulation multipliers. + * Haste uses reciprocal scaling so future gear tiers cannot create zero cooldowns. + */ +export function runGearMultipliers( + equipment: RunEquipment, + ownerId: RunGearOwnerId, +): { readonly maxHealth: number; readonly damage: number; readonly cooldown: number } { + const stats = ownerGearStats(equipment, ownerId); + return { + maxHealth: 1 + stats.maxHealth, + damage: 1 + stats.damage, + cooldown: 1 / (1 + stats.haste), + }; +} + +function visualArchetype(archetypeId: PartyArchetypeId): NonNullable["visualArchetype"] { + if (archetypeId === "knight-tank" || archetypeId.startsWith("warrior-")) return "knight"; + if (archetypeId === "ranger-damage") return "ranger"; + if (archetypeId === "mage-damage" || archetypeId === "warlock-damage") return "mage"; + return "rogue"; +} + +export function combatKitForRosterMember(member: PartyRosterMember): PartyCombatKitId { + if (member.role === "Tank") return "tank"; + if (member.archetypeId === "mage-damage") return "caster"; + if (member.archetypeId === "ranger-damage" || member.archetypeId === "warlock-damage") return "ranged"; + return "melee"; +} + +function memberModifiers(member: PartyRosterMember | null, equipment: RunEquipment): MemberGearModifiers { + if (!member) return { ...EMPTY_MEMBER_GEAR_MODIFIERS }; + const gear = runGearMultipliers(equipment, member.instanceId); + return { + ...EMPTY_MEMBER_GEAR_MODIFIERS, + maxHealth: gear.maxHealth, + moveSpeed: member.stats.moveSpeed, + damage: gear.damage, + cooldown: gear.cooldown, + // Defense has no renderer dependency. Existing combat applies this field to + // hazards; other damage kinds can consume it when their shared API supports it. + hazardDamageTaken: clamp(1 / Math.max(0.01, member.stats.defense), 0.65, 1.25), + }; +} + +function healerModifiers(equipment: RunEquipment): MemberGearModifiers { + const gear = runGearMultipliers(equipment, "player"); + return { + ...EMPTY_MEMBER_GEAR_MODIFIERS, + maxHealth: gear.maxHealth, + healingPower: gear.damage, + cooldown: gear.cooldown, + }; +} + +/** Run-only player gear treats weapon damage as healing power. */ +export function createRpgHealerModifiers(equipment: RunEquipment): MemberGearModifiers { + return healerModifiers(equipment); +} + +function encounterGearModifiers( + combatParty: CombatPartyAssignment, + equipment: RunEquipment, +): EncounterGearModifiers { + return { + aelia: healerModifiers(equipment), + brann: memberModifiers(combatParty.brann, equipment), + nia: memberModifiers(combatParty.nia, equipment), + orin: memberModifiers(combatParty.orin, equipment), + vale: memberModifiers(combatParty.vale, equipment), + }; +} + +export function createRpgEncounterGearModifiers(source: CombatProjectionSource): EncounterGearModifiers { + return encounterGearModifiers(assignRosterToCombatSlots(source.roster), source.equipment); +} + +export function createRpgDamageProfiles(combatParty: CombatPartyAssignment): RpgPartyDamageProfiles { + return { + brann: combatParty.brann + ? { singleTarget: combatParty.brann.stats.singleTarget, areaDamage: combatParty.brann.stats.areaDamage } + : { singleTarget: 0, areaDamage: 0 }, + nia: combatParty.nia + ? { singleTarget: combatParty.nia.stats.singleTarget, areaDamage: combatParty.nia.stats.areaDamage } + : { singleTarget: 0, areaDamage: 0 }, + orin: combatParty.orin + ? { singleTarget: combatParty.orin.stats.singleTarget, areaDamage: combatParty.orin.stats.areaDamage } + : { singleTarget: 0, areaDamage: 0 }, + vale: combatParty.vale + ? { singleTarget: combatParty.vale.stats.singleTarget, areaDamage: combatParty.vale.stats.areaDamage } + : { singleTarget: 0, areaDamage: 0 }, + }; +} + +function projectRosterMember( + slotId: AiCombatantId, + member: PartyRosterMember, + modifier: MemberGearModifiers, +): PartyMember { + const maxHp = Math.max(1, Math.round(member.stats.maxHp * modifier.maxHealth)); + const healthRatio = member.stats.maxHp > 0 ? clamp(member.hp / member.stats.maxHp, 0, 1) : 0; + return { + id: slotId, + name: member.name, + className: member.className, + role: member.role, + color: member.color, + maxHp, + hp: member.hp <= 0 ? 0 : maxHp * healthRatio, + absorb: 0, + healingEffects: [], + reactiveHeal: null, + knockedUntil: 0, + debuffs: [], + runProfile: { + instanceId: member.instanceId, + combatProfileId: member.archetypeId, + combatKitId: combatKitForRosterMember(member), + tier: member.rarity, + visualArchetype: visualArchetype(member.archetypeId), + }, + }; +} + +/** + * Projects immutable run state into the legacy five-slot combat shape. Missing + * draft slots stay absent instead of creating targetable placeholder allies. + */ +export function projectRpgCombat( + source: CombatProjectionSource, + options: ProjectRpgCombatOptions, +): RpgCombatProjection { + const combatParty = assignRosterToCombatSlots(source.roster); + const gearModifiers = encounterGearModifiers(combatParty, source.equipment); + const healerBase = freshParty(options.healerClassId, options.playerName)[0]; + const healerMaxHp = Math.max(1, Math.round(healerBase.maxHp * gearModifiers.aelia.maxHealth)); + const healer: PartyMember = { ...healerBase, maxHp: healerMaxHp, hp: healerMaxHp }; + const party: PartyMember[] = [healer]; + + for (const slotId of RPG_COMBAT_SLOT_IDS) { + const member = combatParty[slotId]; + if (member) party.push(projectRosterMember(slotId, member, gearModifiers[slotId])); + } + + return { + party, + gearModifiers, + damageProfiles: createRpgDamageProfiles(combatParty), + }; +} + +/** + * Converts combat health back to gear-independent run health. This prevents an + * armor swap from permanently inflating or reducing a companion's saved HP. + */ +export function extractRpgPartyVitals( + party: readonly PartyMember[], + combatParty: CombatPartyAssignment, +): PartyVitalUpdate[] { + const vitals: PartyVitalUpdate[] = []; + for (const slotId of RPG_COMBAT_SLOT_IDS) { + const rosterMember = combatParty[slotId]; + if (!rosterMember) continue; + const combatMember = party.find((member) => member.id === slotId); + if (!combatMember) continue; + const ratio = combatMember.maxHp > 0 ? clamp(combatMember.hp / combatMember.maxHp, 0, 1) : 0; + vitals.push({ instanceId: rosterMember.instanceId, hp: rosterMember.stats.maxHp * ratio }); + } + return vitals; +} + +/** Converts gear-adjusted healer health back to the run's 100 HP base. */ +export function extractRpgPlayerVital(party: readonly PartyMember[]): number { + const healer = party.find((member) => member.id === "aelia"); + if (!healer || healer.maxHp <= 0) return 0; + return 100 * clamp(healer.hp / healer.maxHp, 0, 1); +} + +export function normalizedSpellRank( + ranks: Partial>, + spellId: HealerAbilityId, +): number { + const value = ranks[spellId] ?? 0; + return Number.isFinite(value) ? clamp(Math.floor(value), 0, MAX_SPELL_RANK) : 0; +} + +/** Applies to direct healing, absorbs, and periodic-heal magnitudes. */ +export function spellRankPowerMultiplier( + ranks: Partial>, + spellId: HealerAbilityId, +): number { + return 1 + normalizedSpellRank(ranks, spellId) * SPELL_POWER_PER_RANK; +} + +/** Applies only to the upgraded spell, before the player's run-gear haste. */ +export function spellRankCooldownMultiplier( + ranks: Partial>, + spellId: HealerAbilityId, +): number { + return 1 - normalizedSpellRank(ranks, spellId) * SPELL_COOLDOWN_REDUCTION_PER_RANK; +} diff --git a/src/game/rpgRoguelike/index.ts b/src/game/rpgRoguelike/index.ts new file mode 100644 index 0000000..0651958 --- /dev/null +++ b/src/game/rpgRoguelike/index.ts @@ -0,0 +1,9 @@ +export * from "./types"; +export * from "./random"; +export * from "./party"; +export * from "./spells"; +export * from "./challenges"; +export * from "./rewards"; +export * from "./run"; +export * from "./playSpace"; +export * from "./uiModel"; diff --git a/src/game/rpgRoguelike/party.ts b/src/game/rpgRoguelike/party.ts new file mode 100644 index 0000000..a46f402 --- /dev/null +++ b/src/game/rpgRoguelike/party.ts @@ -0,0 +1,224 @@ +import { randomInt, shuffleWithRandom, weightedIndex } from "./random"; +import type { + AllyCombatSlotId, + CombatPartyAssignment, + PartyArchetypeId, + PartyClassId, + PartyCombatStats, + PartyDraftCandidate, + PartyRarity, + PartyRole, + PartyRosterMember, + PartyTraitId, + RandomState, +} from "./types"; +import { MAX_ACTIVE_ROSTER, PARTY_OFFERS_PER_WAVE, PARTY_RECRUITS_PER_WAVE } from "./types"; + +export interface PartyRarityDefinition { + readonly id: PartyRarity; + readonly rank: number; + readonly color: string; + readonly healthMultiplier: number; + readonly damageMultiplier: number; + readonly traitCount: number; +} + +export interface PartyArchetypeDefinition { + readonly id: PartyArchetypeId; + readonly classId: PartyClassId; + readonly className: string; + readonly role: PartyRole; + readonly color: string; + readonly baseStats: PartyCombatStats; + readonly traits: readonly PartyTraitId[]; +} + +export const PARTY_RARITY_ORDER: readonly PartyRarity[] = ["white", "green", "blue", "purple", "gold"]; + +export const PARTY_RARITIES: Record = { + white: { id: "white", rank: 0, color: "#e7e9ec", healthMultiplier: 1, damageMultiplier: 1, traitCount: 0 }, + green: { id: "green", rank: 1, color: "#70d886", healthMultiplier: 1.04, damageMultiplier: 1.035, traitCount: 1 }, + blue: { id: "blue", rank: 2, color: "#65aef2", healthMultiplier: 1.08, damageMultiplier: 1.07, traitCount: 1 }, + purple: { id: "purple", rank: 3, color: "#b47aec", healthMultiplier: 1.12, damageMultiplier: 1.105, traitCount: 2 }, + gold: { id: "gold", rank: 4, color: "#efc858", healthMultiplier: 1.16, damageMultiplier: 1.14, traitCount: 3 }, +}; + +const stats = ( + maxHp: number, + singleTarget: number, + areaDamage: number, + defense: number, + moveSpeed: number, +): PartyCombatStats => ({ maxHp, singleTarget, areaDamage, defense, moveSpeed }); + +export const PARTY_ARCHETYPES: Record = { + "knight-tank": { + id: "knight-tank", classId: "knight", className: "Knight", role: "Tank", color: "#69a8dd", + baseStats: stats(150, 0.72, 0.75, 1.2, 0.92), + traits: ["steadfast", "battle-hardened", "guardian-instinct", "royal-aegis"], + }, + "ranger-damage": { + id: "ranger-damage", classId: "ranger", className: "Ranger", role: "Damage", color: "#74c987", + baseStats: stats(94, 1, 0.88, 0.92, 1.05), + traits: ["sure-footed", "eagle-eye", "rapid-volley", "perfect-shot"], + }, + "mage-damage": { + id: "mage-damage", classId: "mage", className: "Mage", role: "Damage", color: "#b17ee6", + baseStats: stats(86, 1.02, 1.18, 0.86, 0.96), + traits: ["arcane-focus", "spell-echo", "volatile-power", "astral-mastery"], + }, + "rogue-damage": { + id: "rogue-damage", classId: "rogue", className: "Rogue", role: "Damage", color: "#d97171", + baseStats: stats(92, 1.18, 0.82, 0.88, 1.18), + traits: ["light-footed", "exploit-opening", "blade-dance", "shadow-master"], + }, + "warrior-tank": { + id: "warrior-tank", classId: "warrior", className: "Warrior · Vanguard", role: "Tank", color: "#d98b55", + baseStats: stats(145, 0.8, 1.02, 1.14, 0.96), + traits: ["iron-blood", "battle-hardened", "war-cry", "unstoppable"], + }, + "warrior-damage": { + id: "warrior-damage", classId: "warrior", className: "Warrior · Ravager", role: "Damage", color: "#e07a4f", + baseStats: stats(112, 1.02, 1.28, 1, 1), + traits: ["iron-blood", "whirlwind", "war-cry", "unstoppable"], + }, + "warlock-damage": { + id: "warlock-damage", classId: "warlock", className: "Warlock", role: "Damage", color: "#8d6ad4", + baseStats: stats(84, 1.24, 0.8, 0.84, 0.94), + traits: ["soul-drain", "doom-mark", "dark-pact", "soul-tyrant"], + }, + "monk-tank": { + id: "monk-tank", classId: "monk", className: "Monk · Guardian", role: "Tank", color: "#d7b35c", + baseStats: stats(122, 0.94, 0.86, 1.08, 1.12), + traits: ["centered", "counterstrike", "flow-state", "perfect-form"], + }, + "monk-damage": { + id: "monk-damage", classId: "monk", className: "Monk · Striker", role: "Damage", color: "#e0ba62", + baseStats: stats(82, 1.28, 0.76, 0.82, 1.22), + traits: ["centered", "counterstrike", "flow-state", "perfect-form"], + }, +}; + +export const PARTY_ARCHETYPE_ORDER = Object.keys(PARTY_ARCHETYPES) as PartyArchetypeId[]; +export const TANK_ARCHETYPE_IDS = PARTY_ARCHETYPE_ORDER.filter((id) => PARTY_ARCHETYPES[id].role === "Tank"); + +const PARTY_NAMES = [ + "Ada", "Bram", "Cato", "Dara", "Eris", "Fenn", "Gale", "Hana", "Ivo", "Jora", + "Kest", "Lio", "Mara", "Nox", "Oona", "Pax", "Quin", "Rhea", "Soren", "Tali", +] as const; + +const RARITY_WEIGHTS_BY_WAVE: readonly (readonly number[])[] = [ + [55, 27, 12, 5, 1], + [45, 30, 16, 7, 2], + [35, 32, 20, 10, 3], +]; + +export function statsAtRarity(archetypeId: PartyArchetypeId, rarity: PartyRarity): PartyCombatStats { + const base = PARTY_ARCHETYPES[archetypeId].baseStats; + const tier = PARTY_RARITIES[rarity]; + return { + maxHp: Math.round(base.maxHp * tier.healthMultiplier), + singleTarget: base.singleTarget * tier.damageMultiplier, + areaDamage: base.areaDamage * tier.damageMultiplier, + defense: base.defense * tier.healthMultiplier, + moveSpeed: base.moveSpeed, + }; +} + +export function traitsAtRarity(archetypeId: PartyArchetypeId, rarity: PartyRarity): PartyTraitId[] { + return [...PARTY_ARCHETYPES[archetypeId].traits.slice(0, PARTY_RARITIES[rarity].traitCount)]; +} + +function createCandidate( + random: RandomState, + waveIndex: number, + offerIndex: number, + name: string, + archetypeId: PartyArchetypeId, +): { candidate: PartyDraftCandidate; random: RandomState } { + const weights = RARITY_WEIGHTS_BY_WAVE[Math.max(0, Math.min(RARITY_WEIGHTS_BY_WAVE.length - 1, waveIndex))]; + const rarityRoll = weightedIndex(random, weights); + const rarity = PARTY_RARITY_ORDER[rarityRoll.value]; + const archetype = PARTY_ARCHETYPES[archetypeId]; + return { + candidate: { + candidateId: `party-w${waveIndex + 1}-o${offerIndex + 1}-${archetypeId}-${rarityRoll.random.state.toString(36)}`, + name, + archetypeId, + classId: archetype.classId, + className: archetype.className, + role: archetype.role, + rarity, + stats: statsAtRarity(archetypeId, rarity), + traitIds: traitsAtRarity(archetypeId, rarity), + color: archetype.color, + }, + random: rarityRoll.random, + }; +} + +export function generatePartyDraftOffers( + source: RandomState, + waveIndex: number, +): { offers: PartyDraftCandidate[]; random: RandomState } { + let random = source; + const tankRoll = randomInt(random, TANK_ARCHETYPE_IDS.length); + random = tankRoll.random; + const guaranteedTank = TANK_ARCHETYPE_IDS[tankRoll.value]; + const otherArchetypes = PARTY_ARCHETYPE_ORDER.filter((id) => id !== guaranteedTank); + const shuffledArchetypes = shuffleWithRandom(random, otherArchetypes); + random = shuffledArchetypes.random; + const offerArchetypes = [guaranteedTank, ...shuffledArchetypes.values.slice(0, PARTY_OFFERS_PER_WAVE - 1)]; + const mixedArchetypes = shuffleWithRandom(random, offerArchetypes); + random = mixedArchetypes.random; + const shuffledNames = shuffleWithRandom(random, PARTY_NAMES); + random = shuffledNames.random; + + const offers: PartyDraftCandidate[] = []; + for (let index = 0; index < PARTY_OFFERS_PER_WAVE; index += 1) { + const created = createCandidate(random, waveIndex, index, shuffledNames.values[index], mixedArchetypes.values[index]); + offers.push(created.candidate); + random = created.random; + } + return { offers, random }; +} + +export function recruitCandidate(candidate: PartyDraftCandidate): PartyRosterMember { + return { ...candidate, instanceId: candidate.candidateId, hp: candidate.stats.maxHp }; +} + +export function canRecruitCandidate(roster: readonly PartyRosterMember[], recruitsThisWave: number): boolean { + return roster.length < MAX_ACTIVE_ROSTER && recruitsThisWave < PARTY_RECRUITS_PER_WAVE; +} + +export function nextPartyRarity(rarity: PartyRarity): PartyRarity | null { + const index = PARTY_RARITY_ORDER.indexOf(rarity); + return index >= 0 && index < PARTY_RARITY_ORDER.length - 1 ? PARTY_RARITY_ORDER[index + 1] : null; +} + +export function upgradeRosterMemberRarity(member: PartyRosterMember): PartyRosterMember { + const rarity = nextPartyRarity(member.rarity); + if (!rarity) return member; + const stats = statsAtRarity(member.archetypeId, rarity); + const gainedMaxHp = stats.maxHp - member.stats.maxHp; + return { + ...member, + rarity, + stats, + hp: member.hp <= 0 ? 0 : Math.min(stats.maxHp, member.hp + gainedMaxHp), + traitIds: traitsAtRarity(member.archetypeId, rarity), + }; +} + +const ALLY_COMBAT_SLOTS: readonly AllyCombatSlotId[] = ["brann", "nia", "orin", "vale"]; + +export function assignRosterToCombatSlots(roster: readonly PartyRosterMember[]): CombatPartyAssignment { + const assignment: CombatPartyAssignment = { brann: null, nia: null, orin: null, vale: null }; + const available = roster.slice(0, MAX_ACTIVE_ROSTER); + const tank = available.find((member) => member.role === "Tank") ?? available[0]; + if (!tank) return assignment; + assignment.brann = tank; + const remaining = available.filter((member) => member.instanceId !== tank.instanceId); + for (let index = 0; index < remaining.length; index += 1) assignment[ALLY_COMBAT_SLOTS[index + 1]] = remaining[index]; + return assignment; +} diff --git a/src/game/rpgRoguelike/playSpace.test.ts b/src/game/rpgRoguelike/playSpace.test.ts new file mode 100644 index 0000000..26b77f4 --- /dev/null +++ b/src/game/rpgRoguelike/playSpace.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { ARENA_CENTER, ARENA_RADIUS, ARENA_WALL_RADIUS, clampToArena } from "../arena"; +import type { WorldPosition } from "../types"; +import { + BOSS_ARENA_PORTAL_EXIT_DEPTH, + BOSS_ARENA_PORTAL_HALF_WIDTH, + BOSS_ARENA_PORTAL_OUTER_DEPTH, + clampToBossArenaWithPortals, + detectBossArenaExit, + type BossArenaPortalOpenState, +} from "./playSpace"; + +const CLOSED: BossArenaPortalOpenState = { north: false, south: false }; +const NORTH_OPEN: BossArenaPortalOpenState = { north: true, south: false }; +const SOUTH_OPEN: BossArenaPortalOpenState = { north: false, south: true }; + +describe("RPG roguelike boss arena play space", () => { + it("preserves the existing arena clamp while both portals are closed", () => { + const samples: WorldPosition[] = [ + [0, ARENA_CENTER[1]], + [0, ARENA_CENTER[1] - ARENA_WALL_RADIUS], + [0.8, ARENA_CENTER[1] + ARENA_WALL_RADIUS + 2], + [ARENA_WALL_RADIUS, ARENA_CENTER[1]], + ]; + + for (const sample of samples) { + expect(clampToBossArenaWithPortals(sample, CLOSED, 0.35)).toEqual(clampToArena(sample, 0.35)); + } + }); + + it("allows travel through only the corresponding open rectangular throat", () => { + const northThroat: WorldPosition = [0, ARENA_CENTER[1] - ARENA_WALL_RADIUS]; + const southThroat: WorldPosition = [0, ARENA_CENTER[1] + ARENA_WALL_RADIUS]; + + expect(clampToBossArenaWithPortals(northThroat, NORTH_OPEN)).toEqual(northThroat); + expect(clampToBossArenaWithPortals(southThroat, SOUTH_OPEN)).toEqual(southThroat); + expect(clampToBossArenaWithPortals(southThroat, NORTH_OPEN)).toEqual(clampToArena(southThroat)); + expect(clampToBossArenaWithPortals(northThroat, SOUTH_OPEN)).toEqual(clampToArena(northThroat)); + }); + + it("keeps an open throat narrow and clamps its outer end", () => { + const beyondSide: WorldPosition = [BOSS_ARENA_PORTAL_HALF_WIDTH + 2, ARENA_CENTER[1] - ARENA_WALL_RADIUS]; + const beyondEnd: WorldPosition = [0, ARENA_CENTER[1] - ARENA_WALL_RADIUS - BOSS_ARENA_PORTAL_OUTER_DEPTH - 4]; + const sideResult = clampToBossArenaWithPortals(beyondSide, NORTH_OPEN); + const endResult = clampToBossArenaWithPortals(beyondEnd, NORTH_OPEN); + + expect(sideResult).not.toEqual(beyondSide); + expect(sideResult[0]).toBeLessThanOrEqual(BOSS_ARENA_PORTAL_HALF_WIDTH); + expect(endResult).toEqual([ + 0, + ARENA_CENTER[1] - ARENA_WALL_RADIUS - BOSS_ARENA_PORTAL_OUTER_DEPTH, + ]); + }); + + it("keeps ordinary in-arena movement unchanged when a portal is open", () => { + const position: WorldPosition = [ARENA_RADIUS * 0.4, ARENA_CENTER[1] + ARENA_RADIUS * 0.25]; + expect(clampToBossArenaWithPortals(position, NORTH_OPEN)).toEqual(position); + }); + + it("detects exits only after crossing an open portal's exit plane", () => { + const northExitZ = ARENA_CENTER[1] - ARENA_WALL_RADIUS - BOSS_ARENA_PORTAL_EXIT_DEPTH; + const southExitZ = ARENA_CENTER[1] + ARENA_WALL_RADIUS + BOSS_ARENA_PORTAL_EXIT_DEPTH; + + expect(detectBossArenaExit([0, northExitZ], NORTH_OPEN)).toBe("north"); + expect(detectBossArenaExit([0, southExitZ], SOUTH_OPEN)).toBe("south"); + expect(detectBossArenaExit([0, northExitZ + 0.01], NORTH_OPEN)).toBeNull(); + expect(detectBossArenaExit([0, northExitZ], CLOSED)).toBeNull(); + expect(detectBossArenaExit([BOSS_ARENA_PORTAL_HALF_WIDTH + 0.01, northExitZ], NORTH_OPEN)).toBeNull(); + }); +}); + diff --git a/src/game/rpgRoguelike/playSpace.ts b/src/game/rpgRoguelike/playSpace.ts new file mode 100644 index 0000000..e4305fa --- /dev/null +++ b/src/game/rpgRoguelike/playSpace.ts @@ -0,0 +1,143 @@ +import { + ARENA_CENTER, + ARENA_RADIUS, + ARENA_WALL_RADIUS, + clampToArena, +} from "../arena"; +import type { WorldPosition } from "../types"; + +/** North follows Three.js convention toward negative Z; south points toward positive Z. */ +export type BossArenaPortalSide = "north" | "south"; + +export type BossArenaPortalOpenState = Readonly>; + +export const CLOSED_BOSS_ARENA_PORTALS: BossArenaPortalOpenState = { + north: false, + south: false, +}; + +export const NORTH_OPEN_BOSS_ARENA_PORTALS: BossArenaPortalOpenState = { + north: true, + south: false, +}; + +/** Half-width of the walkable opening between each doorway frame. */ +export const BOSS_ARENA_PORTAL_HALF_WIDTH = 1.45; +/** Overlap keeps each rectangular throat connected to the circular arena. */ +export const BOSS_ARENA_PORTAL_THROAT_OVERLAP = 0.75; +/** Walkable space beyond the visible wall before the room transition fires. */ +export const BOSS_ARENA_PORTAL_OUTER_DEPTH = 1.5; +/** Exit plane sits just beyond the doorway instead of at the arena's combat edge. */ +export const BOSS_ARENA_PORTAL_EXIT_DEPTH = 0.55; + +interface Bounds { + minX: number; + maxX: number; + minZ: number; + maxZ: number; +} + +function normalizedPadding(padding: number) { + return Number.isFinite(padding) ? Math.max(0, padding) : 0; +} + +function portalBounds(side: BossArenaPortalSide, padding: number): Bounds | null { + const safePadding = normalizedPadding(padding); + const halfWidth = Math.max(0, BOSS_ARENA_PORTAL_HALF_WIDTH - safePadding); + if (halfWidth <= 0) return null; + + const arenaDistance = Math.max(0, ARENA_RADIUS - safePadding); + const innerDistance = Math.max(0, arenaDistance - BOSS_ARENA_PORTAL_THROAT_OVERLAP); + const outerDistance = Math.max( + innerDistance, + ARENA_WALL_RADIUS + BOSS_ARENA_PORTAL_OUTER_DEPTH - safePadding, + ); + const centerZ = ARENA_CENTER[1]; + + return side === "north" + ? { + minX: ARENA_CENTER[0] - halfWidth, + maxX: ARENA_CENTER[0] + halfWidth, + minZ: centerZ - outerDistance, + maxZ: centerZ - innerDistance, + } + : { + minX: ARENA_CENTER[0] - halfWidth, + maxX: ARENA_CENTER[0] + halfWidth, + minZ: centerZ + innerDistance, + maxZ: centerZ + outerDistance, + }; +} + +function contains(bounds: Bounds, position: WorldPosition) { + return position[0] >= bounds.minX + && position[0] <= bounds.maxX + && position[1] >= bounds.minZ + && position[1] <= bounds.maxZ; +} + +function clampToBounds(bounds: Bounds, position: WorldPosition): WorldPosition { + return [ + Math.max(bounds.minX, Math.min(bounds.maxX, position[0])), + Math.max(bounds.minZ, Math.min(bounds.maxZ, position[1])), + ]; +} + +function distanceSquared(left: WorldPosition, right: WorldPosition) { + return (left[0] - right[0]) ** 2 + (left[1] - right[1]) ** 2; +} + +/** + * Clamps movement to the circular boss arena plus any currently open doorway + * throats. With both portals closed this is exactly the existing arena clamp. + */ +export function clampToBossArenaWithPortals( + position: WorldPosition, + portals: BossArenaPortalOpenState = CLOSED_BOSS_ARENA_PORTALS, + padding = 0, +): WorldPosition { + if (!portals.north && !portals.south) return clampToArena(position, padding); + + const safePadding = normalizedPadding(padding); + const radius = Math.max(0, ARENA_RADIUS - safePadding); + const offsetX = position[0] - ARENA_CENTER[0]; + const offsetZ = position[1] - ARENA_CENTER[1]; + if (Math.hypot(offsetX, offsetZ) <= radius) return [position[0], position[1]]; + + const openBounds = (["north", "south"] as const) + .filter((side) => portals[side]) + .map((side) => portalBounds(side, safePadding)) + .filter((bounds): bounds is Bounds => bounds !== null); + + if (openBounds.some((bounds) => contains(bounds, position))) { + return [position[0], position[1]]; + } + + let nearest = clampToArena(position, safePadding); + let nearestDistance = distanceSquared(position, nearest); + for (const bounds of openBounds) { + const candidate = clampToBounds(bounds, position); + const candidateDistance = distanceSquared(position, candidate); + if (candidateDistance < nearestDistance) { + nearest = candidate; + nearestDistance = candidateDistance; + } + } + return nearest; +} + +/** Returns the open portal crossed past its visible wall plane, if any. */ +export function detectBossArenaExit( + position: WorldPosition, + portals: BossArenaPortalOpenState = CLOSED_BOSS_ARENA_PORTALS, + padding = 0, +): BossArenaPortalSide | null { + const halfWidth = Math.max(0, BOSS_ARENA_PORTAL_HALF_WIDTH - normalizedPadding(padding)); + if (Math.abs(position[0] - ARENA_CENTER[0]) > halfWidth) return null; + + const exitDistance = ARENA_WALL_RADIUS + BOSS_ARENA_PORTAL_EXIT_DEPTH; + const offsetZ = position[1] - ARENA_CENTER[1]; + if (portals.north && offsetZ <= -exitDistance) return "north"; + if (portals.south && offsetZ >= exitDistance) return "south"; + return null; +} diff --git a/src/game/rpgRoguelike/random.ts b/src/game/rpgRoguelike/random.ts new file mode 100644 index 0000000..319e2e3 --- /dev/null +++ b/src/game/rpgRoguelike/random.ts @@ -0,0 +1,73 @@ +import type { RandomState } from "./types"; + +const DEFAULT_NON_ZERO_SEED = 0x9e3779b9; + +function hashString(value: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +export function normalizeSeed(seed: number | string): number { + const normalized = typeof seed === "string" + ? hashString(seed) + : Number.isFinite(seed) + ? Math.trunc(seed) >>> 0 + : DEFAULT_NON_ZERO_SEED; + return normalized === 0 ? DEFAULT_NON_ZERO_SEED : normalized; +} + +export function createRandomState(seed: number | string): RandomState { + const normalized = normalizeSeed(seed); + return { seed: normalized, state: normalized, draws: 0 }; +} + +export function nextRandom(source: RandomState): { value: number; random: RandomState } { + const state = (source.state + 0x6d2b79f5) >>> 0; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + const result = ((value ^ (value >>> 14)) >>> 0) / 4294967296; + return { + value: result, + random: { seed: source.seed, state, draws: source.draws + 1 }, + }; +} + +export function randomInt(source: RandomState, maximumExclusive: number): { value: number; random: RandomState } { + if (!Number.isInteger(maximumExclusive) || maximumExclusive <= 0) { + throw new Error("maximumExclusive must be a positive integer."); + } + const next = nextRandom(source); + return { value: Math.floor(next.value * maximumExclusive), random: next.random }; +} + +export function shuffleWithRandom(source: RandomState, values: readonly T[]): { values: T[]; random: RandomState } { + const shuffled = [...values]; + let random = source; + for (let index = shuffled.length - 1; index > 0; index -= 1) { + const sampled = randomInt(random, index + 1); + random = sampled.random; + [shuffled[index], shuffled[sampled.value]] = [shuffled[sampled.value], shuffled[index]]; + } + return { values: shuffled, random }; +} + +export function weightedIndex(source: RandomState, weights: readonly number[]): { value: number; random: RandomState } { + if (!weights.length || weights.some((weight) => !Number.isFinite(weight) || weight < 0)) { + throw new Error("weights must contain non-negative finite values."); + } + const total = weights.reduce((sum, weight) => sum + weight, 0); + if (total <= 0) throw new Error("weights must contain at least one positive value."); + const next = nextRandom(source); + let cursor = next.value * total; + for (let index = 0; index < weights.length; index += 1) { + cursor -= weights[index]; + if (cursor < 0) return { value: index, random: next.random }; + } + return { value: weights.length - 1, random: next.random }; +} + diff --git a/src/game/rpgRoguelike/rewards.ts b/src/game/rpgRoguelike/rewards.ts new file mode 100644 index 0000000..92bbc05 --- /dev/null +++ b/src/game/rpgRoguelike/rewards.ts @@ -0,0 +1,255 @@ +import { nextPartyRarity, upgradeRosterMemberRarity } from "./party"; +import { randomInt, shuffleWithRandom } from "./random"; +import { incrementSpellRank } from "./spells"; +import type { + RandomState, + RewardChest, + RewardChoice, + RpgRoguelikeRunState, + RunEquipment, + RunGearItem, + RunGearOwnerId, + RunGearSlotId, + RunGearStatId, + RunShopState, + ShopOffer, +} from "./types"; +import { MAX_RUN_GEAR_ENHANCEMENT, MAX_SPELL_RANK } from "./types"; + +export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"]; + +const GEAR_SLOT_DATA: Record = { + weapon: { label: "Weapon", statId: "damage" }, + armor: { label: "Armor", statId: "maxHealth" }, + trinket: { label: "Trinket", statId: "haste" }, +}; + +function enhancementLevel(value: number): 0 | 1 | 2 | 3 | 4 | 5 { + return Math.max(0, Math.min(MAX_RUN_GEAR_ENHANCEMENT, Math.floor(value))) as 0 | 1 | 2 | 3 | 4 | 5; +} + +export function equippedRunGear( + equipment: RunEquipment, + ownerId: RunGearOwnerId, + slotId: RunGearSlotId, +): RunGearItem | undefined { + return equipment[ownerId]?.[slotId]; +} + +export function createRunGearItem( + id: string, + ownerId: RunGearOwnerId, + slotId: RunGearSlotId, + enhancement: number, +): RunGearItem { + const level = enhancementLevel(enhancement); + const data = GEAR_SLOT_DATA[slotId]; + const ownerLabel = ownerId === "player" ? "Healer" : "Companion"; + return { + id, + ownerId, + slotId, + enhancement: level, + name: `${ownerLabel} ${data.label} +${level}`, + statId: data.statId, + statValue: data.statId === "haste" ? 2 + level * 2 : 4 + level * 4, + sellPrice: 18 + level * 12, + }; +} + +export function autoEquipRunGear( + equipment: RunEquipment, + bag: readonly RunGearItem[], + item: RunGearItem, +): { equipment: RunEquipment; bag: RunGearItem[]; equipped: boolean } { + const current = equippedRunGear(equipment, item.ownerId, item.slotId); + if (current && current.enhancement >= item.enhancement) { + return { equipment, bag: [...bag, item], equipped: false }; + } + const ownerEquipment = { ...(equipment[item.ownerId] ?? {}) }; + ownerEquipment[item.slotId] = item; + return { + equipment: { ...equipment, [item.ownerId]: ownerEquipment }, + bag: current ? [...bag, current] : [...bag], + equipped: true, + }; +} + +function gearOwners(state: Pick): RunGearOwnerId[] { + return ["player", ...state.roster.map((member) => member.instanceId)]; +} + +function gearRewardChoices( + source: RandomState, + state: Pick, + quality: number, + prefix: string, +): { choices: RewardChoice[]; random: RandomState } { + const choices: RewardChoice[] = []; + let random = source; + for (const ownerId of gearOwners(state)) { + for (const slotId of RUN_GEAR_SLOT_ORDER) { + const current = equippedRunGear(state.equipment, ownerId, slotId)?.enhancement ?? -1; + if (current >= MAX_RUN_GEAR_ENHANCEMENT) continue; + const minimum = enhancementLevel(Math.max(current + 1, quality)); + const rolled = randomInt(random, MAX_RUN_GEAR_ENHANCEMENT - minimum + 1); + random = rolled.random; + const next = enhancementLevel(minimum + rolled.value); + const item = createRunGearItem(`${prefix}-${ownerId}-${slotId}-${next}`, ownerId, slotId, next); + choices.push({ id: `reward-${item.id}`, kind: "run-gear", item, label: item.name }); + } + } + return { choices, random }; +} + +export function generateRewardChest( + source: RandomState, + state: Pick< + RpgRoguelikeRunState, + "roster" | "selectedSpellIds" | "spellRanks" | "equipment" | "bossesDefeated" + >, + requestedQuality: number, +): { chest: RewardChest; random: RandomState } { + const quality = Math.max(0, Math.min(3, Math.floor(requestedQuality))); + const prefix = `chest-${Math.max(1, state.bossesDefeated)}-q${quality}`; + const pool: RewardChoice[] = []; + + for (const spellId of state.selectedSpellIds) { + const current = Math.max(0, Math.floor(state.spellRanks[spellId] ?? 0)); + if (current >= MAX_SPELL_RANK) continue; + pool.push({ + id: `${prefix}-spell-${spellId}`, + kind: "spell-rank", + spellId, + nextRank: current + 1, + label: `${spellId} rank ${current + 1}`, + }); + } + + for (const member of state.roster) { + const rarity = nextPartyRarity(member.rarity); + if (!rarity) continue; + pool.push({ + id: `${prefix}-member-${member.instanceId}`, + kind: "member-rarity", + memberId: member.instanceId, + nextRarity: rarity, + label: `${member.name} → ${rarity}`, + }); + } + + const gear = gearRewardChoices(source, state, quality, prefix); + pool.push(...gear.choices); + for (let index = pool.length; index < 3; index += 1) { + const amount = 35 + quality * 15 + index * 5; + pool.push({ id: `${prefix}-currency-${index}`, kind: "currency", amount, label: `${amount} gold` }); + } + + const shuffled = shuffleWithRandom(gear.random, pool); + return { + chest: { id: prefix, quality, choices: shuffled.values.slice(0, 3) }, + random: shuffled.random, + }; +} + +export function applyRewardChoice( + state: RpgRoguelikeRunState, + choice: RewardChoice, +): RpgRoguelikeRunState { + if (choice.kind === "spell-rank") { + return { ...state, spellRanks: incrementSpellRank(state.spellRanks, choice.spellId) }; + } + if (choice.kind === "member-rarity") { + const roster = state.roster.map((member) => member.instanceId === choice.memberId ? upgradeRosterMemberRarity(member) : member); + return { + ...state, + roster, + }; + } + if (choice.kind === "run-gear") { + const applied = autoEquipRunGear(state.equipment, state.bag, choice.item); + return { ...state, equipment: applied.equipment, bag: applied.bag }; + } + return { ...state, currency: state.currency + choice.amount }; +} + +export function generateRunShop( + source: RandomState, + state: Pick, + act: number, +): { shop: RunShopState; random: RandomState } { + const quality = Math.max(0, Math.min(3, act)); + const gear = gearRewardChoices(source, state, quality, `shop-a${act}`); + const rewardChoices = gear.choices + .filter((choice): choice is Extract => choice.kind === "run-gear"); + const shuffled = shuffleWithRandom(gear.random, rewardChoices); + const offers: ShopOffer[] = shuffled.values.slice(0, 4).map((choice) => ({ + id: `offer-${choice.item.id}`, + item: choice.item, + price: 55 + choice.item.enhancement * 28, + sold: false, + })); + return { + shop: { + act, + offers, + restCost: 25 + act * 10, + reviveCost: 45 + act * 15, + }, + random: shuffled.random, + }; +} + +export function buyShopOffer(state: RpgRoguelikeRunState, offerId: string): RpgRoguelikeRunState { + const offer = state.shop?.offers.find((candidate) => candidate.id === offerId); + if (!offer || offer.sold || state.currency < offer.price || !state.shop) return state; + const applied = autoEquipRunGear(state.equipment, state.bag, offer.item); + return { + ...state, + currency: state.currency - offer.price, + equipment: applied.equipment, + bag: applied.bag, + shop: { + ...state.shop, + offers: state.shop.offers.map((candidate) => candidate.id === offerId ? { ...candidate, sold: true } : candidate), + }, + }; +} + +export function sellBagItem(state: RpgRoguelikeRunState, itemId: string): RpgRoguelikeRunState { + const item = state.bag.find((candidate) => candidate.id === itemId); + if (!item) return state; + return { + ...state, + bag: state.bag.filter((candidate) => candidate.id !== itemId), + currency: state.currency + item.sellPrice, + }; +} + +export function restParty(state: RpgRoguelikeRunState): RpgRoguelikeRunState { + if (!state.shop || state.currency < state.shop.restCost) return state; + const needsRest = state.playerHp > 0 && state.playerHp < 100 + || state.roster.some((member) => member.hp > 0 && member.hp < member.stats.maxHp); + if (!needsRest) return state; + const roster = state.roster.map((member) => member.hp > 0 ? { ...member, hp: member.stats.maxHp } : member); + return { + ...state, + currency: state.currency - state.shop.restCost, + playerHp: state.playerHp > 0 ? 100 : state.playerHp, + roster, + }; +} + +export function revivePartyMember(state: RpgRoguelikeRunState, memberId: string): RpgRoguelikeRunState { + if (!state.shop || state.currency < state.shop.reviveCost) return state; + const member = state.roster.find((candidate) => candidate.instanceId === memberId); + if (!member || member.hp > 0) return state; + const roster = state.roster.map((candidate) => candidate.instanceId === memberId + ? { ...candidate, hp: Math.max(1, Math.ceil(candidate.stats.maxHp * 0.5)) } + : candidate); + return { + ...state, + currency: state.currency - state.shop.reviveCost, + roster, + }; +} diff --git a/src/game/rpgRoguelike/rpgRoguelike.test.ts b/src/game/rpgRoguelike/rpgRoguelike.test.ts new file mode 100644 index 0000000..b84fd78 --- /dev/null +++ b/src/game/rpgRoguelike/rpgRoguelike.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from "vitest"; +import type { HealerAbilityId } from "../types"; +import { + MAX_ACTIVE_ROSTER, + MAX_RUN_GEAR_ENHANCEMENT, + MAX_SPELL_RANK, + PARTY_RECRUITS_PER_WAVE, + PARTY_RARITY_ORDER, + TOTAL_BOSS_COUNT, + abilityLoadoutFromSpellIds, + applyRewardChoice, + assignRosterToCombatSlots, + autoEquipRunGear, + challengeObjective, + createRandomState, + createRpgRoguelikeRun, + createRunGearItem, + equippedRunGear, + generateRewardChest, + generateRunShop, + nextPartyRarity, + reduceRpgRoguelikeRun, + selectCurrentBossId, + type PartyRosterMember, + type RewardChoice, + type RpgRoguelikeAction, + type RpgRoguelikeRunState, + type RunGearItem, +} from "."; + +function dispatch(state: RpgRoguelikeRunState, action: RpgRoguelikeAction) { + return reduceRpgRoguelikeRun(state, action); +} + +function finishDrafts(seed: number | string = 42): RpgRoguelikeRunState { + let state = createRpgRoguelikeRun({ seed }); + while (state.phase === "party-draft") { + const draft = state.partyDraft!; + const tank = draft.offers.find((offer) => offer.role === "Tank"); + const ordered = tank ? [tank, ...draft.offers.filter((offer) => offer !== tank)] : draft.offers; + for (const offer of ordered) { + if (state.roster.length >= MAX_ACTIVE_ROSTER) break; + if ((state.partyDraft?.recruitedThisWaveIds.length ?? 0) >= PARTY_RECRUITS_PER_WAVE) break; + state = dispatch(state, { type: "party-recruit", candidateId: offer.candidateId }); + } + state = dispatch(state, { type: "party-next-wave" }); + } + while (state.phase === "spell-draft") { + for (const spellId of state.spellDraft!.offers.slice(0, 2)) state = dispatch(state, { type: "spell-pick", spellId }); + state = dispatch(state, { type: "spell-next-wave" }); + } + return state; +} + +function usableReward(state: RpgRoguelikeRunState, choice: RewardChoice) { + if (choice.kind === "spell-rank") return state.selectedSpellIds.includes(choice.spellId) + && choice.nextRank > (state.spellRanks[choice.spellId] ?? 0) + && choice.nextRank <= MAX_SPELL_RANK; + if (choice.kind === "member-rarity") { + const member = state.roster.find((candidate) => candidate.instanceId === choice.memberId); + return Boolean(member && nextPartyRarity(member.rarity) === choice.nextRarity); + } + if (choice.kind === "run-gear") { + const current = equippedRunGear(state.equipment, choice.item.ownerId, choice.item.slotId)?.enhancement ?? -1; + return choice.item.enhancement > current && choice.item.enhancement <= MAX_RUN_GEAR_ENHANCEMENT; + } + return choice.amount > 0; +} + +describe("RPG Roguelike deterministic domain", () => { + it("replays identical offers, route, state, and RNG cursor from one seed", () => { + const left = finishDrafts("deterministic-run"); + const right = finishDrafts("deterministic-run"); + expect(left).toEqual(right); + expect(left.random.draws).toBeGreaterThan(0); + expect(JSON.parse(JSON.stringify(left))).toEqual(left); + }); + + it("enforces party wave/global caps, supports replacement, and guarantees tank-capable offers", () => { + let state = createRpgRoguelikeRun({ seed: 7 }); + expect(state.partyDraft!.offers).toHaveLength(5); + expect(state.partyDraft!.offers.some((offer) => offer.role === "Tank")).toBe(true); + const firstFour = state.partyDraft!.offers.slice(0, 4); + for (const offer of firstFour) state = dispatch(state, { type: "party-recruit", candidateId: offer.candidateId }); + expect(state.roster).toHaveLength(3); + + const removed = state.roster[0]; + expect(dispatch(state, { type: "party-remove", memberId: removed.instanceId })).toBe(state); + state = dispatch(state, { type: "party-next-wave" }); + expect(state.partyDraft!.waveIndex).toBe(1); + expect(state.partyDraft!.offers.some((offer) => offer.role === "Tank")).toBe(true); + + state = dispatch(state, { type: "party-recruit", candidateId: state.partyDraft!.offers[0].candidateId }); + expect(state.roster).toHaveLength(4); + state = dispatch(state, { type: "party-remove", memberId: removed.instanceId }); + expect(state.roster).toHaveLength(3); + expect(state.partyDraft!.recruitedThisWaveIds).toHaveLength(1); + state = dispatch(state, { type: "party-recruit", candidateId: state.partyDraft!.offers[1].candidateId }); + expect(state.roster).toHaveLength(4); + const before = state; + state = dispatch(state, { type: "party-recruit", candidateId: state.partyDraft!.offers[2].candidateId }); + expect(state).toBe(before); + }); + + it("assigns a drafted tank to Brann while preserving four distinct instances", () => { + const state = finishDrafts(19); + const assignment = assignRosterToCombatSlots(state.roster); + expect(assignment.brann?.role).toBe("Tank"); + expect(Object.values(assignment).filter(Boolean)).toHaveLength(4); + expect(new Set(Object.values(assignment).map((member) => member?.instanceId))).toHaveLength(4); + }); + + it("will not finalize four companions without a tank", () => { + let state = createRpgRoguelikeRun({ seed: 91 }); + while (state.phase === "party-draft" && state.partyDraft!.waveIndex < 2) { + for (const offer of state.partyDraft!.offers) { + if (state.roster.length >= 4 || state.partyDraft!.recruitedThisWaveIds.length >= 3) break; + state = dispatch(state, { type: "party-recruit", candidateId: offer.candidateId }); + } + state = dispatch(state, { type: "party-next-wave" }); + } + const tankless = { + ...state, + roster: state.roster.map((member) => ({ ...member, role: "Damage" as const })), + }; + expect(tankless.roster).toHaveLength(4); + expect(dispatch(tankless, { type: "party-next-wave" })).toBe(tankless); + }); + + it("caps spell picks, refunds a current-wave removal, and emits mixed spells in pick order", () => { + let state = createRpgRoguelikeRun({ seed: 22 }); + while (state.phase === "party-draft") { + for (const offer of state.partyDraft!.offers) { + if (state.roster.length >= 4 || state.partyDraft!.recruitedThisWaveIds.length >= 3) break; + state = dispatch(state, { type: "party-recruit", candidateId: offer.candidateId }); + } + state = dispatch(state, { type: "party-next-wave" }); + } + const firstWave = state.spellDraft!.offers; + state = dispatch(state, { type: "spell-pick", spellId: firstWave[0] }); + state = dispatch(state, { type: "spell-pick", spellId: firstWave[1] }); + expect(dispatch(state, { type: "spell-remove", spellId: firstWave[0] })).toBe(state); + state = dispatch(state, { type: "spell-next-wave" }); + const offers = state.spellDraft!.offers; + state = dispatch(state, { type: "spell-pick", spellId: offers[0] }); + state = dispatch(state, { type: "spell-pick", spellId: offers[1] }); + const capped = dispatch(state, { type: "spell-pick", spellId: offers[2] }); + expect(capped).toBe(state); + state = dispatch(state, { type: "spell-remove", spellId: offers[0] }); + state = dispatch(state, { type: "spell-pick", spellId: offers[2] }); + expect(state.selectedSpellIds).toEqual([firstWave[0], firstWave[1], offers[1], offers[2]]); + expect(state.abilityLoadout).toEqual({ + ability1: firstWave[0], + ability2: firstWave[1], + ability3: offers[2], + ability4: offers[1], + }); + + const mixed: HealerAbilityId[] = ["priest-mend", "druid-rejuvenation", "shaman-chain-heal"]; + expect(abilityLoadoutFromSpellIds(mixed)).toEqual({ + ability1: "priest-mend", + ability2: "druid-rejuvenation", + ability3: "shaman-chain-heal", + }); + }); + + it("builds three acts of three bosses plus a finale, with nine challenges and three shops", () => { + let state = finishDrafts(9001); + expect(state.bossRoute).toHaveLength(TOTAL_BOSS_COUNT); + let challenges = 0; + let shops = 0; + let safety = 0; + while (state.phase !== "victory" && safety++ < 200) { + if (state.phase === "challenge-briefing") { + challenges += 1; + state = dispatch(state, { type: "challenge-start" }); + } else if (state.phase === "challenge-active") { + state = dispatch(state, { type: "challenge-complete", metrics: { bricksBroken: 999, bossKills: 999, kills: 999 } }); + } else if (state.phase === "boss-briefing") { + expect(selectCurrentBossId(state)).not.toBeNull(); + state = dispatch(state, { type: "boss-start" }); + } else if (state.phase === "boss-combat") { + state = dispatch(state, { type: "boss-won" }); + } else if (state.phase === "boss-cleared") { + state = dispatch(state, { type: "reward-open" }); + } else if (state.phase === "reward") { + state = dispatch(state, { type: "reward-choose", choiceId: state.pendingReward!.choices[0].id }); + } else if (state.phase === "shop") { + shops += 1; + state = dispatch(state, { type: "shop-leave" }); + } else { + throw new Error(`Unexpected phase ${state.phase}`); + } + } + expect(state.phase).toBe("victory"); + expect(state.bossesDefeated).toBe(10); + expect(challenges).toBe(9); + expect(shops).toBe(3); + }); + + it("bounds repeat challenge scaling and treats challenge failure as soft", () => { + expect(challengeObjective("blockbreaker", 0).target).toBe(30); + expect(challengeObjective("blockbreaker", 1).target).toBe(45); + expect(challengeObjective("blockbreaker", 2).target).toBe(60); + expect(challengeObjective("blockbreaker", 99).target).toBe(60); + expect(challengeObjective("hockey", 99).target).toBe(3); + + let state = finishDrafts(77); + const currency = state.currency; + state = dispatch(state, { type: "challenge-start" }); + state = dispatch(state, { type: "challenge-complete" }); + expect(state.phase).toBe("boss-briefing"); + expect(state.lastChallengeResult?.succeeded).toBe(false); + expect(state.currency).toBe(currency); + expect(state.nextChestQualityBonus).toBe(0); + + let simultaneous = finishDrafts(78); + simultaneous = dispatch(simultaneous, { type: "challenge-start" }); + const objective = simultaneous.currentChallenge!.objective; + const metrics = objective.metric === "bricksBroken" + ? { bricksBroken: objective.target } + : objective.metric === "bossKills" + ? { bossKills: objective.target } + : { kills: objective.target }; + simultaneous = dispatch(simultaneous, { type: "challenge-complete", metrics, forcedFailure: true }); + expect(simultaneous.lastChallengeResult?.succeeded).toBe(false); + expect(simultaneous.nextChestQualityBonus).toBe(0); + }); + + it("generates exactly three distinct, usable chest choices and applies the selected upgrade", () => { + const state = finishDrafts(300); + const generated = generateRewardChest(createRandomState(1), state, 2); + expect(generated.chest.choices).toHaveLength(3); + expect(new Set(generated.chest.choices.map((choice) => choice.id))).toHaveLength(3); + expect(generated.chest.choices.every((choice) => usableReward(state, choice))).toBe(true); + const choice = generated.chest.choices[0]; + const applied = applyRewardChoice(state, choice); + if (choice.kind === "spell-rank") expect(applied.spellRanks[choice.spellId]).toBe(1); + else if (choice.kind === "member-rarity") { + expect(applied.roster.find((member) => member.instanceId === choice.memberId)?.rarity).toBe(choice.nextRarity); + } else if (choice.kind === "run-gear") { + expect(equippedRunGear(applied.equipment, choice.item.ownerId, choice.item.slotId)).toEqual(choice.item); + } else expect(applied.currency).toBe(state.currency + choice.amount); + }); + + it("caps run-only gear at +5 and keeps displaced gear in the sellable bag", () => { + const lower = createRunGearItem("lower", "player", "weapon", 2); + const capped = createRunGearItem("capped", "player", "weapon", 99); + expect(capped.enhancement).toBe(5); + const first = autoEquipRunGear({}, [], lower); + const second = autoEquipRunGear(first.equipment, first.bag, capped); + expect(equippedRunGear(second.equipment, "player", "weapon")?.enhancement).toBe(5); + expect(second.bag).toEqual([lower]); + const weaker = createRunGearItem("weaker", "player", "weapon", 4); + const third = autoEquipRunGear(second.equipment, second.bag, weaker); + expect(third.equipped).toBe(false); + expect(third.bag).toContain(weaker); + }); + + it("supports shop buy, sell, rest, revive, and leave", () => { + let state = finishDrafts(501); + const generated = generateRunShop(state.random, state, 1); + const downId = state.roster[0].instanceId; + const injuredId = state.roster[1].instanceId; + const sellable = createRunGearItem("sellable", "player", "armor", 0); + state = { + ...state, + phase: "shop", + random: generated.random, + shop: generated.shop, + currency: 2_000, + bag: [sellable], + roster: state.roster.map((member) => member.instanceId === downId + ? { ...member, hp: 0 } + : member.instanceId === injuredId + ? { ...member, hp: 1 } + : member), + }; + const offer = state.shop!.offers[0]; + state = dispatch(state, { type: "shop-buy", offerId: offer.id }); + expect(state.shop!.offers[0].sold).toBe(true); + expect(equippedRunGear(state.equipment, offer.item.ownerId, offer.item.slotId)).toEqual(offer.item); + const afterBuyCurrency = state.currency; + state = dispatch(state, { type: "shop-sell", itemId: sellable.id }); + expect(state.currency).toBe(afterBuyCurrency + sellable.sellPrice); + state = dispatch(state, { type: "shop-rest" }); + expect(state.roster.find((member) => member.instanceId === injuredId)?.hp) + .toBe(state.roster.find((member) => member.instanceId === injuredId)?.stats.maxHp); + expect(Object.values(assignRosterToCombatSlots(state.roster)).find((member) => member?.instanceId === injuredId)?.hp) + .toBe(state.roster.find((member) => member.instanceId === injuredId)?.stats.maxHp); + state = dispatch(state, { type: "shop-revive", memberId: downId }); + expect(state.roster.find((member) => member.instanceId === downId)!.hp).toBeGreaterThan(0); + expect(Object.values(assignRosterToCombatSlots(state.roster)).find((member) => member?.instanceId === downId)?.hp) + .toBe(Math.ceil(state.roster.find((member) => member.instanceId === downId)!.stats.maxHp * 0.5)); + state = dispatch(state, { type: "shop-leave" }); + expect(state.phase).not.toBe("shop"); + expect(state.shop).toBeNull(); + }); + + it("persists bounded party health and ends immediately on a boss loss", () => { + let state = finishDrafts(808); + state = dispatch(state, { type: "challenge-start" }); + state = dispatch(state, { type: "challenge-complete" }); + state = dispatch(state, { type: "boss-start" }); + const member = state.roster[0]; + state = dispatch(state, { + type: "boss-lost", + vitals: [{ instanceId: member.instanceId, hp: -20 }], + }); + expect(state.phase).toBe("defeat"); + expect(state.roster[0].hp).toBe(0); + expect(dispatch(state, { type: "reward-open" })).toBe(state); + }); + + it("defines monotonic rarity order and modest archetype upgrades", () => { + expect(PARTY_RARITY_ORDER).toEqual(["white", "green", "blue", "purple", "gold"]); + const state = finishDrafts(15); + const white = state.roster.find((member) => member.rarity === "white") as PartyRosterMember | undefined; + if (!white) return; + const choice: RewardChoice = { + id: "test-rarity", + kind: "member-rarity", + memberId: white.instanceId, + nextRarity: "green", + label: "upgrade", + }; + const upgraded = applyRewardChoice(state, choice).roster.find((member) => member.instanceId === white.instanceId)!; + expect(upgraded.stats.maxHp).toBeGreaterThan(white.stats.maxHp); + expect(upgraded.stats.singleTarget).toBeGreaterThan(white.stats.singleTarget); + expect(upgraded.traitIds.length).toBeGreaterThan(white.traitIds.length); + + const defeatedState = { + ...state, + roster: state.roster.map((member) => member.instanceId === white.instanceId ? { ...member, hp: 0 } : member), + }; + const defeatedUpgrade = applyRewardChoice(defeatedState, choice).roster + .find((member) => member.instanceId === white.instanceId)!; + expect(defeatedUpgrade.hp).toBe(0); + }); +}); diff --git a/src/game/rpgRoguelike/run.ts b/src/game/rpgRoguelike/run.ts new file mode 100644 index 0000000..eaeda2d --- /dev/null +++ b/src/game/rpgRoguelike/run.ts @@ -0,0 +1,352 @@ +import { AVAILABLE_BOSS_IDS } from "../bossCatalog"; +import type { BossId, HealerAbilityId } from "../types"; +import { + emptyChallengeCounts, + emptyChallengeMetrics, + evaluateChallenge, + mergeChallengeMetrics, + selectChallenge, +} from "./challenges"; +import { + canRecruitCandidate, + generatePartyDraftOffers, + recruitCandidate, +} from "./party"; +import { createRandomState, shuffleWithRandom } from "./random"; +import { + applyRewardChoice, + buyShopOffer, + generateRewardChest, + generateRunShop, + restParty, + revivePartyMember, + sellBagItem, +} from "./rewards"; +import { + addSpellToAbilityLoadout, + canPickSpell, + generateSpellDraftOffers, + removeSpellFromAbilityLoadout, +} from "./spells"; +import type { + PartyVitalUpdate, + RandomState, + RpgRoguelikeAction, + RpgRoguelikeRunConfig, + RpgRoguelikeRunState, +} from "./types"; +import { + ACT_BOSS_COUNT, + BOSSES_PER_ACT, + MAX_ACTIVE_ROSTER, + PARTY_DRAFT_WAVE_COUNT, + PARTY_RECRUITS_PER_WAVE, + SPELL_DRAFT_WAVE_COUNT, + TOTAL_BOSS_COUNT, +} from "./types"; + +function uniqueBossPool(pool: readonly BossId[]): BossId[] { + return [...new Set(pool)]; +} + +export function generateBossRoute( + source: RandomState, + requestedPool: readonly BossId[] = AVAILABLE_BOSS_IDS, +): { route: BossId[]; random: RandomState } { + const pool = uniqueBossPool(requestedPool); + if (!pool.length) throw new Error("RPG Roguelike requires at least one boss."); + const route: BossId[] = []; + let random = source; + while (route.length < TOTAL_BOSS_COUNT) { + const shuffled = shuffleWithRandom(random, pool); + random = shuffled.random; + for (const bossId of shuffled.values) { + if (route.length >= TOTAL_BOSS_COUNT) break; + if (pool.length > 1 && route[route.length - 1] === bossId) continue; + route.push(bossId); + } + } + return { route, random }; +} + +function syncPartyVitals( + state: RpgRoguelikeRunState, + updates: readonly PartyVitalUpdate[] | undefined, + playerHp?: number, +): RpgRoguelikeRunState { + if (!updates?.length && playerHp === undefined) return state; + const byId = new Map((updates ?? []).map((update) => [update.instanceId, update.hp])); + const roster = state.roster.map((member) => { + const hp = byId.get(member.instanceId); + return hp === undefined + ? member + : { ...member, hp: Math.max(0, Math.min(member.stats.maxHp, hp)) }; + }); + return { + ...state, + playerHp: playerHp === undefined ? state.playerHp : Math.max(0, Math.min(100, playerHp)), + roster, + }; +} + +function prepareEncounter(state: RpgRoguelikeRunState): RpgRoguelikeRunState { + if (state.bossIndex >= TOTAL_BOSS_COUNT) return { ...state, phase: "victory", currentChallenge: null }; + if (state.bossIndex >= ACT_BOSS_COUNT) { + return { ...state, phase: "boss-briefing", currentChallenge: null, lastChallengeResult: null }; + } + const selected = selectChallenge(state.random, state.challengeCounts); + return { + ...state, + random: selected.random, + phase: "challenge-briefing", + challengeCounts: selected.counts, + currentChallenge: { objective: selected.objective, metrics: emptyChallengeMetrics() }, + lastChallengeResult: null, + }; +} + +export function createRpgRoguelikeRun(config: RpgRoguelikeRunConfig): RpgRoguelikeRunState { + let random = createRandomState(config.seed); + const route = generateBossRoute(random, config.bossPool); + random = route.random; + const partyOffers = generatePartyDraftOffers(random, 0); + random = partyOffers.random; + return { + version: 1, + seed: random.seed, + random, + phase: "party-draft", + partyDraft: { waveIndex: 0, offers: partyOffers.offers, recruitedThisWaveIds: [] }, + spellDraft: null, + roster: [], + selectedSpellIds: [], + abilityLoadout: {}, + spellRanks: {}, + playerHp: 100, + bossRoute: route.route, + bossIndex: 0, + bossesDefeated: 0, + challengeCounts: emptyChallengeCounts(), + currentChallenge: null, + lastChallengeResult: null, + nextChestQualityBonus: 0, + pendingReward: null, + equipment: {}, + bag: [], + currency: Math.max(0, Math.floor(config.startingCurrency ?? 80)), + shop: null, + }; +} + +/** Mirrors draft replacement capacity so controller/UI never expose inert removals. */ +export function canRemovePartyMember(state: RpgRoguelikeRunState, memberId: string): boolean { + const draft = state.partyDraft; + if (state.phase !== "party-draft" + || !draft + || draft.waveIndex === 0 + || !state.roster.some((member) => member.instanceId === memberId)) return false; + if (draft.waveIndex < PARTY_DRAFT_WAVE_COUNT - 1) return true; + + const removingCurrentPick = draft.recruitedThisWaveIds.includes(memberId); + const picksAfterRemoval = draft.recruitedThisWaveIds.length - Number(removingCurrentPick); + const maximumFinalRoster = state.roster.length - 1 + (PARTY_RECRUITS_PER_WAVE - picksAfterRemoval); + return maximumFinalRoster >= MAX_ACTIVE_ROSTER; +} + +function reducePartyDraft(state: RpgRoguelikeRunState, action: RpgRoguelikeAction): RpgRoguelikeRunState { + const draft = state.partyDraft; + if (state.phase !== "party-draft" || !draft) return state; + if (action.type === "party-recruit") { + const candidate = draft.offers.find((offer) => offer.candidateId === action.candidateId); + if (!candidate + || state.roster.some((member) => member.instanceId === candidate.candidateId) + || !canRecruitCandidate(state.roster, draft.recruitedThisWaveIds.length)) return state; + const roster = [...state.roster, recruitCandidate(candidate)]; + return { + ...state, + roster, + partyDraft: { ...draft, recruitedThisWaveIds: [...draft.recruitedThisWaveIds, candidate.candidateId] }, + }; + } + if (action.type === "party-remove") { + if (!canRemovePartyMember(state, action.memberId)) return state; + return { + ...state, + roster: state.roster.filter((member) => member.instanceId !== action.memberId), + partyDraft: { + ...draft, + recruitedThisWaveIds: draft.recruitedThisWaveIds.filter((id) => id !== action.memberId), + }, + }; + } + if (action.type !== "party-next-wave") return state; + if (draft.waveIndex < PARTY_DRAFT_WAVE_COUNT - 1) { + const remainingWaves = PARTY_DRAFT_WAVE_COUNT - draft.waveIndex - 1; + if (state.roster.length + remainingWaves * PARTY_RECRUITS_PER_WAVE < MAX_ACTIVE_ROSTER) return state; + const generated = generatePartyDraftOffers(state.random, draft.waveIndex + 1); + return { + ...state, + random: generated.random, + partyDraft: { waveIndex: draft.waveIndex + 1, offers: generated.offers, recruitedThisWaveIds: [] }, + }; + } + if (state.roster.length !== MAX_ACTIVE_ROSTER || !state.roster.some((member) => member.role === "Tank")) return state; + const generated = generateSpellDraftOffers(state.random); + return { + ...state, + random: generated.random, + phase: "spell-draft", + partyDraft: null, + spellDraft: { + waveIndex: 0, + offers: generated.offers, + pickedThisWaveIds: [], + seenOfferIds: generated.offers, + }, + }; +} + +function reduceSpellDraft(state: RpgRoguelikeRunState, action: RpgRoguelikeAction): RpgRoguelikeRunState { + const draft = state.spellDraft; + if (state.phase !== "spell-draft" || !draft) return state; + if (action.type === "spell-pick") { + if (!draft.offers.includes(action.spellId) + || !canPickSpell(state.selectedSpellIds, draft.pickedThisWaveIds.length, action.spellId)) return state; + const selectedSpellIds = [...state.selectedSpellIds, action.spellId]; + return { + ...state, + selectedSpellIds, + abilityLoadout: addSpellToAbilityLoadout(state.abilityLoadout, action.spellId), + spellDraft: { ...draft, pickedThisWaveIds: [...draft.pickedThisWaveIds, action.spellId] }, + }; + } + if (action.type === "spell-remove") { + if (draft.waveIndex === 0) return state; + if (!state.selectedSpellIds.includes(action.spellId)) return state; + const selectedSpellIds = state.selectedSpellIds.filter((spellId) => spellId !== action.spellId); + return { + ...state, + selectedSpellIds, + abilityLoadout: removeSpellFromAbilityLoadout(state.abilityLoadout, action.spellId), + spellDraft: { + ...draft, + pickedThisWaveIds: draft.pickedThisWaveIds.filter((spellId) => spellId !== action.spellId), + }, + }; + } + if (action.type !== "spell-next-wave") return state; + if (draft.waveIndex < SPELL_DRAFT_WAVE_COUNT - 1) { + const generated = generateSpellDraftOffers(state.random, draft.seenOfferIds); + return { + ...state, + random: generated.random, + spellDraft: { + waveIndex: draft.waveIndex + 1, + offers: generated.offers, + pickedThisWaveIds: [], + seenOfferIds: [...draft.seenOfferIds, ...generated.offers], + }, + }; + } + if (!state.selectedSpellIds.length) return state; + return prepareEncounter({ ...state, spellDraft: null }); +} + +function completeReward(state: RpgRoguelikeRunState, choiceId: string): RpgRoguelikeRunState { + const choice = state.pendingReward?.choices.find((candidate) => candidate.id === choiceId); + if (!choice || state.phase !== "reward") return state; + let next = applyRewardChoice(state, choice); + next = { ...next, pendingReward: null }; + if (state.bossIndex === TOTAL_BOSS_COUNT - 1) return { ...next, phase: "victory" }; + + const bossIndex = state.bossIndex + 1; + if (state.bossesDefeated <= ACT_BOSS_COUNT && state.bossesDefeated % BOSSES_PER_ACT === 0) { + const generated = generateRunShop(next.random, next, state.bossesDefeated / BOSSES_PER_ACT); + return { ...next, random: generated.random, bossIndex, phase: "shop", shop: generated.shop }; + } + return prepareEncounter({ ...next, bossIndex }); +} + +export function reduceRpgRoguelikeRun( + state: RpgRoguelikeRunState, + action: RpgRoguelikeAction, +): RpgRoguelikeRunState { + if (state.phase === "victory" || state.phase === "defeat") return state; + if (state.phase === "party-draft") return reducePartyDraft(state, action); + if (state.phase === "spell-draft") return reduceSpellDraft(state, action); + + if (action.type === "party-vitals") return syncPartyVitals(state, action.vitals, action.playerHp); + if (action.type === "challenge-start" && state.phase === "challenge-briefing" && state.currentChallenge) { + return { ...state, phase: "challenge-active" }; + } + if (action.type === "challenge-progress" && state.phase === "challenge-active" && state.currentChallenge) { + return { + ...state, + currentChallenge: { + ...state.currentChallenge, + metrics: mergeChallengeMetrics(state.currentChallenge.metrics, action.metrics), + }, + }; + } + if (action.type === "challenge-complete" && state.phase === "challenge-active" && state.currentChallenge) { + const metrics = mergeChallengeMetrics(state.currentChallenge.metrics, action.metrics ?? {}); + const evaluated = evaluateChallenge(state.currentChallenge.objective, metrics); + const result = action.forcedFailure ? { ...evaluated, succeeded: false } : evaluated; + return { + ...state, + phase: "boss-briefing", + currentChallenge: null, + lastChallengeResult: result, + currency: state.currency + (result.succeeded ? result.objective.rewardCurrency : 0), + nextChestQualityBonus: result.succeeded ? result.objective.chestQualityBonus : 0, + }; + } + if (action.type === "boss-start" && state.phase === "boss-briefing") return { ...state, phase: "boss-combat" }; + if (action.type === "boss-lost" && state.phase === "boss-combat") { + return { ...syncPartyVitals(state, action.vitals, action.playerHp), phase: "defeat", pendingReward: null }; + } + if (action.type === "boss-won" && state.phase === "boss-combat") { + const synced = syncPartyVitals(state, action.vitals, action.playerHp); + return { ...synced, phase: "boss-cleared", bossesDefeated: state.bossesDefeated + 1 }; + } + if (action.type === "reward-open" && state.phase === "boss-cleared" && !state.pendingReward) { + const baseQuality = Math.min(3, Math.floor(state.bossIndex / BOSSES_PER_ACT)); + const generated = generateRewardChest(state.random, state, baseQuality + state.nextChestQualityBonus); + return { + ...state, + random: generated.random, + phase: "reward", + pendingReward: generated.chest, + nextChestQualityBonus: 0, + }; + } + if (action.type === "reward-choose") return completeReward(state, action.choiceId); + if (state.phase === "shop") { + if (action.type === "shop-buy") return buyShopOffer(state, action.offerId); + if (action.type === "shop-sell") return sellBagItem(state, action.itemId); + if (action.type === "shop-rest") return restParty(state); + if (action.type === "shop-revive") return revivePartyMember(state, action.memberId); + if (action.type === "shop-leave") return prepareEncounter({ ...state, phase: "boss-briefing", shop: null }); + } + return state; +} + +export function selectCurrentBossId(state: RpgRoguelikeRunState): BossId | null { + return state.bossRoute[state.bossIndex] ?? null; +} + +export function selectCanRecruit(state: RpgRoguelikeRunState): boolean { + return state.phase === "party-draft" + && Boolean(state.partyDraft) + && canRecruitCandidate(state.roster, state.partyDraft?.recruitedThisWaveIds.length ?? 0); +} + +export function selectCanPickSpell(state: RpgRoguelikeRunState, spellId: HealerAbilityId): boolean { + return state.phase === "spell-draft" + && Boolean(state.spellDraft?.offers.includes(spellId)) + && canPickSpell(state.selectedSpellIds, state.spellDraft?.pickedThisWaveIds.length ?? 0, spellId); +} + +export function isRpgRoguelikeTerminal(state: RpgRoguelikeRunState): boolean { + return state.phase === "victory" || state.phase === "defeat"; +} diff --git a/src/game/rpgRoguelike/spells.test.ts b/src/game/rpgRoguelike/spells.test.ts new file mode 100644 index 0000000..45a8203 --- /dev/null +++ b/src/game/rpgRoguelike/spells.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { HEALER_ABILITY_IDS } from "../healers"; +import { + buildRpgRoguelikeSpellPool, + generateSpellDraftOffers, + HEALER_SPELL_IDS, + RPG_ROGUELIKE_SPELL_POLICY, +} from "./spells"; +import { createRandomState } from "./random"; +import { SPELL_OFFERS_PER_WAVE } from "./types"; + +describe("RPG Roguelike healer spell discovery", () => { + it("automatically mirrors the canonical healer registry minus explicit blocks", () => { + const blocked = new Set(RPG_ROGUELIKE_SPELL_POLICY.blockedSpellIds); + expect(HEALER_SPELL_IDS).toEqual(HEALER_ABILITY_IDS.filter((spellId) => !blocked.has(spellId))); + }); + + it("supports a typed mode-specific deny-list without changing the healer registry", () => { + const blockedSpellId = HEALER_ABILITY_IDS[0]; + const pool = buildRpgRoguelikeSpellPool(HEALER_ABILITY_IDS, { blockedSpellIds: [blockedSpellId] }); + + expect(pool).not.toContain(blockedSpellId); + expect(pool).toEqual(HEALER_ABILITY_IDS.slice(1)); + expect(HEALER_ABILITY_IDS).toContain(blockedSpellId); + }); + + it("never reintroduces blocked spells when unseen offers run low", () => { + const blockedSpellId = HEALER_ABILITY_IDS[0]; + const eligible = buildRpgRoguelikeSpellPool(HEALER_ABILITY_IDS, { blockedSpellIds: [blockedSpellId] }); + const seen = eligible.slice(0, eligible.length - 1); + const left = generateSpellDraftOffers(createRandomState("blocked-fallback"), seen, eligible); + const right = generateSpellDraftOffers(createRandomState("blocked-fallback"), seen, eligible); + + expect(left).toEqual(right); + expect(left.offers).toHaveLength(SPELL_OFFERS_PER_WAVE); + expect(left.offers).not.toContain(blockedSpellId); + expect(left.offers.every((spellId) => eligible.includes(spellId))).toBe(true); + }); + + it("fails clearly when policy leaves too few draftable abilities", () => { + const minimal = HEALER_ABILITY_IDS.slice(0, SPELL_OFFERS_PER_WAVE); + expect(() => buildRpgRoguelikeSpellPool(minimal, { blockedSpellIds: [minimal[0]] })) + .toThrow(`RPG Roguelike needs at least ${SPELL_OFFERS_PER_WAVE} eligible healer abilities.`); + }); +}); diff --git a/src/game/rpgRoguelike/spells.ts b/src/game/rpgRoguelike/spells.ts new file mode 100644 index 0000000..ccff488 --- /dev/null +++ b/src/game/rpgRoguelike/spells.ts @@ -0,0 +1,107 @@ +import { HEALER_ABILITY_IDS } from "../healers"; +import type { AbilityLoadout, AbilitySlotId, HealerAbilityId } from "../types"; +import { shuffleWithRandom } from "./random"; +import type { RandomState } from "./types"; +import { MAX_EQUIPPED_SPELLS, MAX_SPELL_RANK, SPELL_OFFERS_PER_WAVE, SPELL_PICKS_PER_WAVE } from "./types"; + +export interface RpgRoguelikeSpellPolicy { + readonly blockedSpellIds: readonly HealerAbilityId[]; +} + +/** + * RPG-owned content policy. Add an ability ID here to suppress it from future + * drafts. Existing runs keep already-selected spells so saves stay valid. + */ +export const RPG_ROGUELIKE_SPELL_POLICY: RpgRoguelikeSpellPolicy = { + blockedSpellIds: [ + // "paladin-crusader-strike", + ], +}; + +export function buildRpgRoguelikeSpellPool( + registeredSpellIds: readonly HealerAbilityId[], + policy: RpgRoguelikeSpellPolicy = RPG_ROGUELIKE_SPELL_POLICY, +): HealerAbilityId[] { + const blockedSpellIds = new Set(policy.blockedSpellIds); + const eligibleSpellIds = registeredSpellIds.filter((spellId) => !blockedSpellIds.has(spellId)); + if (eligibleSpellIds.length < SPELL_OFFERS_PER_WAVE) { + throw new Error(`RPG Roguelike needs at least ${SPELL_OFFERS_PER_WAVE} eligible healer abilities.`); + } + return eligibleSpellIds; +} + +/** Compatibility export: canonical healer registry minus RPG-specific blocks. */ +export const HEALER_SPELL_IDS: readonly HealerAbilityId[] = Object.freeze( + buildRpgRoguelikeSpellPool(HEALER_ABILITY_IDS), +); + +export const ABILITY_LOADOUT_SLOTS: readonly AbilitySlotId[] = [ + "ability1", + "ability2", + "ability3", + "ability4", + "ability5", + "ability6", +]; + +export function generateSpellDraftOffers( + source: RandomState, + seenOfferIds: readonly HealerAbilityId[] = [], + eligibleSpellIds: readonly HealerAbilityId[] = HEALER_SPELL_IDS, +): { offers: HealerAbilityId[]; random: RandomState } { + const seen = new Set(seenOfferIds); + const unseen = eligibleSpellIds.filter((spellId) => !seen.has(spellId)); + const pool = unseen.length >= SPELL_OFFERS_PER_WAVE ? unseen : eligibleSpellIds; + const shuffled = shuffleWithRandom(source, pool); + return { offers: shuffled.values.slice(0, SPELL_OFFERS_PER_WAVE), random: shuffled.random }; +} + +export function canPickSpell( + selectedSpellIds: readonly HealerAbilityId[], + picksThisWave: number, + spellId: HealerAbilityId, +): boolean { + return selectedSpellIds.length < MAX_EQUIPPED_SPELLS + && picksThisWave < SPELL_PICKS_PER_WAVE + && !selectedSpellIds.includes(spellId); +} + +/** Final controller order is deterministic draft order after removals. */ +export function abilityLoadoutFromSpellIds(spellIds: readonly HealerAbilityId[]): AbilityLoadout { + const loadout: AbilityLoadout = {}; + for (let index = 0; index < Math.min(MAX_EQUIPPED_SPELLS, spellIds.length); index += 1) { + loadout[ABILITY_LOADOUT_SLOTS[index]] = spellIds[index]; + } + return loadout; +} + +/** Fills the lowest open controller slot without moving retained spells. */ +export function addSpellToAbilityLoadout( + loadout: AbilityLoadout, + spellId: HealerAbilityId, +): AbilityLoadout { + if (Object.values(loadout).includes(spellId)) return loadout; + const slotId = ABILITY_LOADOUT_SLOTS.find((candidate) => !loadout[candidate]); + return slotId ? { ...loadout, [slotId]: spellId } : loadout; +} + +/** Removes one spell while preserving every other controller binding. */ +export function removeSpellFromAbilityLoadout( + loadout: AbilityLoadout, + spellId: HealerAbilityId, +): AbilityLoadout { + const next: AbilityLoadout = {}; + for (const slotId of ABILITY_LOADOUT_SLOTS) { + const current = loadout[slotId]; + if (current && current !== spellId) next[slotId] = current; + } + return next; +} + +export function incrementSpellRank( + ranks: Partial>, + spellId: HealerAbilityId, +): Partial> { + const current = Math.max(0, Math.min(MAX_SPELL_RANK, Math.floor(ranks[spellId] ?? 0))); + return current >= MAX_SPELL_RANK ? { ...ranks } : { ...ranks, [spellId]: current + 1 }; +} diff --git a/src/game/rpgRoguelike/types.ts b/src/game/rpgRoguelike/types.ts new file mode 100644 index 0000000..f5f655c --- /dev/null +++ b/src/game/rpgRoguelike/types.ts @@ -0,0 +1,278 @@ +import type { AbilityLoadout, BossId, HealerAbilityId } from "../types"; + +export type { AbilityLoadout } from "../types"; + +export const RPG_ROGUELIKE_MODE_ID = "rpg-roguelike" as const; +export const PARTY_DRAFT_WAVE_COUNT = 3; +export const PARTY_OFFERS_PER_WAVE = 5; +export const PARTY_RECRUITS_PER_WAVE = 3; +export const MAX_ACTIVE_ROSTER = 4; +export const SPELL_DRAFT_WAVE_COUNT = 3; +export const SPELL_OFFERS_PER_WAVE = 3; +export const SPELL_PICKS_PER_WAVE = 2; +export const MAX_EQUIPPED_SPELLS = 6; +export const ACT_COUNT = 3; +export const BOSSES_PER_ACT = 3; +export const ACT_BOSS_COUNT = ACT_COUNT * BOSSES_PER_ACT; +export const TOTAL_BOSS_COUNT = ACT_BOSS_COUNT + 1; +export const MAX_SPELL_RANK = 5; +export const MAX_RUN_GEAR_ENHANCEMENT = 5; + +export type RpgRunPhase = + | "party-draft" + | "spell-draft" + | "challenge-briefing" + | "challenge-active" + | "boss-briefing" + | "boss-combat" + | "boss-cleared" + | "reward" + | "shop" + | "victory" + | "defeat"; + +export interface RandomState { + readonly seed: number; + readonly state: number; + readonly draws: number; +} + +export type PartyRarity = "white" | "green" | "blue" | "purple" | "gold"; +export type PartyRole = "Tank" | "Damage"; +export type PartyClassId = "knight" | "ranger" | "mage" | "rogue" | "warrior" | "warlock" | "monk"; +export type PartyArchetypeId = + | "knight-tank" + | "ranger-damage" + | "mage-damage" + | "rogue-damage" + | "warrior-tank" + | "warrior-damage" + | "warlock-damage" + | "monk-tank" + | "monk-damage"; +export type PartyTraitId = + | "steadfast" + | "battle-hardened" + | "guardian-instinct" + | "royal-aegis" + | "sure-footed" + | "eagle-eye" + | "rapid-volley" + | "perfect-shot" + | "arcane-focus" + | "spell-echo" + | "volatile-power" + | "astral-mastery" + | "light-footed" + | "exploit-opening" + | "blade-dance" + | "shadow-master" + | "iron-blood" + | "whirlwind" + | "war-cry" + | "unstoppable" + | "soul-drain" + | "doom-mark" + | "dark-pact" + | "soul-tyrant" + | "centered" + | "counterstrike" + | "flow-state" + | "perfect-form"; + +export interface PartyCombatStats { + readonly maxHp: number; + readonly singleTarget: number; + readonly areaDamage: number; + readonly defense: number; + readonly moveSpeed: number; +} + +export interface PartyDraftCandidate { + readonly candidateId: string; + readonly name: string; + readonly archetypeId: PartyArchetypeId; + readonly classId: PartyClassId; + readonly className: string; + readonly role: PartyRole; + readonly rarity: PartyRarity; + readonly stats: PartyCombatStats; + readonly traitIds: PartyTraitId[]; + readonly color: string; +} + +export interface PartyRosterMember extends PartyDraftCandidate { + readonly instanceId: string; + readonly hp: number; +} + +export type AllyCombatSlotId = "brann" | "nia" | "orin" | "vale"; +export type CombatPartyAssignment = Record; + +export interface PartyDraftState { + readonly waveIndex: number; + readonly offers: PartyDraftCandidate[]; + readonly recruitedThisWaveIds: string[]; +} + +export interface SpellDraftState { + readonly waveIndex: number; + readonly offers: HealerAbilityId[]; + readonly pickedThisWaveIds: HealerAbilityId[]; + readonly seenOfferIds: HealerAbilityId[]; +} + +export type ChallengeId = "blockbreaker" | "hockey" | "aether-assault"; +export type ChallengeMetric = "bricksBroken" | "bossKills" | "kills"; + +export interface ChallengeMetrics { + readonly bricksBroken: number; + readonly bossKills: number; + readonly kills: number; +} + +export interface ChallengeObjective { + readonly challengeId: ChallengeId; + readonly name: string; + readonly metric: ChallengeMetric; + readonly target: number; + readonly repeatIndex: number; + readonly rewardCurrency: number; + readonly chestQualityBonus: number; +} + +export interface ChallengeRunState { + readonly objective: ChallengeObjective; + readonly metrics: ChallengeMetrics; +} + +export interface ChallengeResult { + readonly objective: ChallengeObjective; + readonly metrics: ChallengeMetrics; + readonly succeeded: boolean; +} + +export type RunGearSlotId = "weapon" | "armor" | "trinket"; +export type RunGearOwnerId = "player" | string; +export type RunGearStatId = "damage" | "maxHealth" | "haste"; + +export interface RunGearItem { + readonly id: string; + readonly ownerId: RunGearOwnerId; + readonly slotId: RunGearSlotId; + readonly enhancement: 0 | 1 | 2 | 3 | 4 | 5; + readonly name: string; + readonly statId: RunGearStatId; + readonly statValue: number; + readonly sellPrice: number; +} + +export type RunEquipment = Record>>; + +export type RewardChoice = + | { + readonly id: string; + readonly kind: "spell-rank"; + readonly spellId: HealerAbilityId; + readonly nextRank: number; + readonly label: string; + } + | { + readonly id: string; + readonly kind: "member-rarity"; + readonly memberId: string; + readonly nextRarity: PartyRarity; + readonly label: string; + } + | { + readonly id: string; + readonly kind: "run-gear"; + readonly item: RunGearItem; + readonly label: string; + } + | { + readonly id: string; + readonly kind: "currency"; + readonly amount: number; + readonly label: string; + }; + +export interface RewardChest { + readonly id: string; + readonly quality: number; + readonly choices: RewardChoice[]; +} + +export interface ShopOffer { + readonly id: string; + readonly item: RunGearItem; + readonly price: number; + readonly sold: boolean; +} + +export interface RunShopState { + readonly act: number; + readonly offers: ShopOffer[]; + readonly restCost: number; + readonly reviveCost: number; +} + +export interface PartyVitalUpdate { + readonly instanceId: string; + readonly hp: number; +} + +export interface RpgRoguelikeRunState { + readonly version: 1; + readonly seed: number; + readonly random: RandomState; + readonly phase: RpgRunPhase; + readonly partyDraft: PartyDraftState | null; + readonly spellDraft: SpellDraftState | null; + readonly roster: PartyRosterMember[]; + readonly selectedSpellIds: HealerAbilityId[]; + readonly abilityLoadout: AbilityLoadout; + readonly spellRanks: Partial>; + /** Gear-independent healer vitality, normalized against a 100 HP run base. */ + readonly playerHp: number; + readonly bossRoute: BossId[]; + readonly bossIndex: number; + readonly bossesDefeated: number; + readonly challengeCounts: Record; + readonly currentChallenge: ChallengeRunState | null; + readonly lastChallengeResult: ChallengeResult | null; + readonly nextChestQualityBonus: number; + readonly pendingReward: RewardChest | null; + readonly equipment: RunEquipment; + readonly bag: RunGearItem[]; + readonly currency: number; + readonly shop: RunShopState | null; +} + +export interface RpgRoguelikeRunConfig { + readonly seed: number | string; + readonly bossPool?: readonly BossId[]; + readonly startingCurrency?: number; +} + +export type RpgRoguelikeAction = + | { readonly type: "party-recruit"; readonly candidateId: string } + | { readonly type: "party-remove"; readonly memberId: string } + | { readonly type: "party-next-wave" } + | { readonly type: "spell-pick"; readonly spellId: HealerAbilityId } + | { readonly type: "spell-remove"; readonly spellId: HealerAbilityId } + | { readonly type: "spell-next-wave" } + | { readonly type: "challenge-start" } + | { readonly type: "challenge-progress"; readonly metrics: Partial } + | { readonly type: "challenge-complete"; readonly metrics?: Partial; readonly forcedFailure?: boolean } + | { readonly type: "boss-start" } + | { readonly type: "party-vitals"; readonly vitals: readonly PartyVitalUpdate[]; readonly playerHp?: number } + | { readonly type: "boss-won"; readonly vitals?: readonly PartyVitalUpdate[]; readonly playerHp?: number } + | { readonly type: "boss-lost"; readonly vitals?: readonly PartyVitalUpdate[]; readonly playerHp?: number } + | { readonly type: "reward-open" } + | { readonly type: "reward-choose"; readonly choiceId: string } + | { readonly type: "shop-buy"; readonly offerId: string } + | { readonly type: "shop-sell"; readonly itemId: string } + | { readonly type: "shop-rest" } + | { readonly type: "shop-revive"; readonly memberId: string } + | { readonly type: "shop-leave" }; diff --git a/src/game/rpgRoguelike/uiModel.test.ts b/src/game/rpgRoguelike/uiModel.test.ts new file mode 100644 index 0000000..39e3977 --- /dev/null +++ b/src/game/rpgRoguelike/uiModel.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_ACTIVE_ROSTER, + PARTY_RECRUITS_PER_WAVE, + createRpgRoguelikeRun, + createRunGearItem, + moveRpgFocus, + reduceRpgRoguelikeRun, + rpgFocusId, + rpgFocusItems, + type RpgRoguelikeRunState, +} from "."; + +function reachSpellDraft(seed = 810): RpgRoguelikeRunState { + let state = createRpgRoguelikeRun({ seed }); + while (state.phase === "party-draft") { + const draft = state.partyDraft!; + const tank = draft.offers.find((offer) => offer.role === "Tank"); + const ordered = tank ? [tank, ...draft.offers.filter((offer) => offer !== tank)] : draft.offers; + for (const offer of ordered) { + if (state.roster.length >= MAX_ACTIVE_ROSTER || state.partyDraft!.recruitedThisWaveIds.length >= PARTY_RECRUITS_PER_WAVE) break; + state = reduceRpgRoguelikeRun(state, { type: "party-recruit", candidateId: offer.candidateId }); + } + state = reduceRpgRoguelikeRun(state, { type: "party-next-wave" }); + } + return state; +} + +describe("RPG Roguelike semantic UI focus", () => { + it("moves horizontally within card rows and vertically between action groups", () => { + let state = createRpgRoguelikeRun({ seed: 120 }); + const offers = state.partyDraft!.offers; + const first = rpgFocusId.partyOffer(offers[0].candidateId); + const second = rpgFocusId.partyOffer(offers[1].candidateId); + const last = rpgFocusId.partyOffer(offers[offers.length - 1].candidateId); + expect(moveRpgFocus(state, first, "right")).toBe(second); + expect(moveRpgFocus(state, first, "left")).toBe(last); + expect(moveRpgFocus(state, second, "down")).toBe(rpgFocusId.partyContinue); + + state = reduceRpgRoguelikeRun(state, { type: "party-recruit", candidateId: offers[0].candidateId }); + state = reduceRpgRoguelikeRun(state, { type: "party-next-wave" }); + const laterOffer = rpgFocusId.partyOffer(state.partyDraft!.offers[0].candidateId); + const rosterAction = rpgFocusId.partyMember(offers[0].candidateId); + expect(moveRpgFocus(state, laterOffer, "down")).toBe(rosterAction); + expect(moveRpgFocus(state, rosterAction, "down")).toBe(rpgFocusId.partyContinue); + }); + + it("orders party offers, later-wave roster removals, then Continue", () => { + let state = createRpgRoguelikeRun({ seed: 12 }); + const firstOffer = state.partyDraft!.offers[0]; + expect(rpgFocusItems(state).map((item) => item.id)).toEqual([ + ...state.partyDraft!.offers.map((offer) => rpgFocusId.partyOffer(offer.candidateId)), + rpgFocusId.partyContinue, + ]); + + state = reduceRpgRoguelikeRun(state, { type: "party-recruit", candidateId: firstOffer.candidateId }); + expect(rpgFocusItems(state).map((item) => item.id)).not.toContain(rpgFocusId.partyMember(firstOffer.candidateId)); + state = reduceRpgRoguelikeRun(state, { type: "party-next-wave" }); + expect(rpgFocusItems(state).map((item) => item.id)).toEqual([ + ...state.partyDraft!.offers.map((offer) => rpgFocusId.partyOffer(offer.candidateId)), + rpgFocusId.partyMember(firstOffer.candidateId), + rpgFocusId.partyContinue, + ]); + }); + + it("omits final-wave removals that cannot be refilled", () => { + let state = createRpgRoguelikeRun({ seed: 91 }); + state = reduceRpgRoguelikeRun(state, { type: "party-next-wave" }); + const prior = state.partyDraft!.offers.find((offer) => offer.role === "Tank")!; + state = reduceRpgRoguelikeRun(state, { type: "party-recruit", candidateId: prior.candidateId }); + state = reduceRpgRoguelikeRun(state, { type: "party-next-wave" }); + for (const offer of state.partyDraft!.offers.slice(0, 3)) { + state = reduceRpgRoguelikeRun(state, { type: "party-recruit", candidateId: offer.candidateId }); + } + + expect(state.roster).toHaveLength(4); + expect(rpgFocusItems(state).map((item) => item.id)).not.toContain(rpgFocusId.partyMember(prior.candidateId)); + expect(reduceRpgRoguelikeRun(state, { type: "party-remove", memberId: prior.candidateId })).toBe(state); + }); + + it("locks first-wave spell picks, then exposes selected removals before Continue", () => { + let state = reachSpellDraft(); + const spellId = state.spellDraft!.offers[0]; + state = reduceRpgRoguelikeRun(state, { type: "spell-pick", spellId }); + expect(rpgFocusItems(state).map((item) => item.id)).not.toContain(rpgFocusId.spellSelected(spellId)); + + state = reduceRpgRoguelikeRun(state, { type: "spell-next-wave" }); + expect(rpgFocusItems(state).map((item) => item.id)).toEqual([ + ...state.spellDraft!.offers.map((offer) => rpgFocusId.spellOffer(offer)), + rpgFocusId.spellSelected(spellId), + rpgFocusId.spellContinue, + ]); + }); + + it("orders shop purchases, bag sales, Rest, Revive, then Leave and omits disabled actions", () => { + const drafted = reachSpellDraft(44); + const offerItem = createRunGearItem("shop-weapon", "player", "weapon", 1); + const soldItem = createRunGearItem("sold-armor", "player", "armor", 1); + const bagItem = createRunGearItem("bag-trinket", "player", "trinket", 0); + const first = drafted.roster[0]; + const second = drafted.roster[1]; + const state: RpgRoguelikeRunState = { + ...drafted, + phase: "shop", + currency: 200, + roster: drafted.roster.map((member) => member.instanceId === first.instanceId + ? { ...member, hp: 0 } + : member.instanceId === second.instanceId + ? { ...member, hp: 1 } + : member), + bag: [bagItem], + shop: { + act: 1, + offers: [ + { id: "available", item: offerItem, price: 80, sold: false }, + { id: "sold", item: soldItem, price: 20, sold: true }, + ], + restCost: 35, + reviveCost: 60, + }, + }; + expect(rpgFocusItems(state).map((item) => item.id)).toEqual([ + rpgFocusId.shopOffer("available"), + rpgFocusId.shopSell(bagItem.id), + rpgFocusId.shopRest, + rpgFocusId.shopRevive(first.instanceId), + rpgFocusId.shopLeave, + ]); + }); +}); diff --git a/src/game/rpgRoguelike/uiModel.ts b/src/game/rpgRoguelike/uiModel.ts new file mode 100644 index 0000000..de63686 --- /dev/null +++ b/src/game/rpgRoguelike/uiModel.ts @@ -0,0 +1,247 @@ +import type { RpgRoguelikeAction, RpgRoguelikeRunState } from "./types"; +import { canRemovePartyMember } from "./run"; +import { + MAX_ACTIVE_ROSTER, + MAX_EQUIPPED_SPELLS, + PARTY_DRAFT_WAVE_COUNT, + PARTY_RECRUITS_PER_WAVE, + SPELL_DRAFT_WAVE_COUNT, + SPELL_PICKS_PER_WAVE, +} from "./types"; + +export type RpgUiCommand = + | { readonly type: "run-action"; readonly action: RpgRoguelikeAction } + | { readonly type: "restart-run" } + | { readonly type: "exit-run" }; + +export interface RpgFocusItem { + readonly id: string; + readonly label: string; + readonly command: RpgUiCommand; +} + +export type RpgFocusDirection = "left" | "right" | "up" | "down"; + +export const rpgFocusId = { + partyOffer: (candidateId: string) => `party-offer:${candidateId}`, + partyMember: (memberId: string) => `party-member:${memberId}`, + partyContinue: "party-continue", + spellOffer: (spellId: string) => `spell-offer:${spellId}`, + spellSelected: (spellId: string) => `spell-selected:${spellId}`, + spellContinue: "spell-continue", + challengeStart: "challenge-start", + bossStart: "boss-start", + rewardOpen: "reward-open", + rewardChoice: (choiceId: string) => `reward:${choiceId}`, + shopOffer: (offerId: string) => `shop-offer:${offerId}`, + shopSell: (itemId: string) => `shop-sell:${itemId}`, + shopRest: "shop-rest", + shopRevive: (memberId: string) => `shop-revive:${memberId}`, + shopLeave: "shop-leave", + restartRun: "restart-run", + exitRun: "exit-run", +} as const; + +function action(id: string, label: string, runAction: RpgRoguelikeAction): RpgFocusItem { + return { id, label, command: { type: "run-action", action: runAction } }; +} + +function canAdvancePartyDraft(state: RpgRoguelikeRunState): boolean { + const draft = state.partyDraft; + if (!draft) return false; + if (draft.waveIndex === PARTY_DRAFT_WAVE_COUNT - 1) { + return state.roster.length === MAX_ACTIVE_ROSTER && state.roster.some((member) => member.role === "Tank"); + } + const remainingWaves = PARTY_DRAFT_WAVE_COUNT - draft.waveIndex - 1; + return state.roster.length + remainingWaves * PARTY_RECRUITS_PER_WAVE >= MAX_ACTIVE_ROSTER; +} + +function canAdvanceSpellDraft(state: RpgRoguelikeRunState): boolean { + const draft = state.spellDraft; + return Boolean(draft && (draft.waveIndex < SPELL_DRAFT_WAVE_COUNT - 1 || state.selectedSpellIds.length > 0)); +} + +/** Canonical ordered semantic targets shared by controller and both displays. */ +export function rpgFocusItems(state: RpgRoguelikeRunState): RpgFocusItem[] { + if (state.phase === "party-draft" && state.partyDraft) { + const draft = state.partyDraft; + const canRemove = draft.waveIndex > 0; + const canRecruit = state.roster.length < MAX_ACTIVE_ROSTER + && draft.recruitedThisWaveIds.length < PARTY_RECRUITS_PER_WAVE; + const offerItems = draft.offers.flatMap((candidate) => { + const recruited = state.roster.some((member) => member.instanceId === candidate.candidateId); + if ((recruited && !canRemovePartyMember(state, candidate.candidateId)) || (!recruited && !canRecruit)) return []; + return [action( + rpgFocusId.partyOffer(candidate.candidateId), + `${recruited ? "Remove" : "Recruit"} ${candidate.name}`, + recruited + ? { type: "party-remove", memberId: candidate.candidateId } + : { type: "party-recruit", candidateId: candidate.candidateId }, + )]; + }); + const rosterItems = canRemove ? state.roster.filter((member) => canRemovePartyMember(state, member.instanceId)).map((member) => action( + rpgFocusId.partyMember(member.instanceId), + `Remove ${member.name}`, + { type: "party-remove", memberId: member.instanceId }, + )) : []; + return [ + ...offerItems, + ...rosterItems, + ...(canAdvancePartyDraft(state) + ? [action(rpgFocusId.partyContinue, "Next party draft", { type: "party-next-wave" })] + : []), + ]; + } + + if (state.phase === "spell-draft" && state.spellDraft) { + const draft = state.spellDraft; + const canRemove = draft.waveIndex > 0; + const canPick = state.selectedSpellIds.length < MAX_EQUIPPED_SPELLS + && draft.pickedThisWaveIds.length < SPELL_PICKS_PER_WAVE; + const offerItems = draft.offers.flatMap((spellId) => { + const selected = state.selectedSpellIds.includes(spellId); + if ((selected && !canRemove) || (!selected && !canPick)) return []; + return [action( + rpgFocusId.spellOffer(spellId), + `${selected ? "Remove" : "Learn"} ${spellId}`, + selected ? { type: "spell-remove", spellId } : { type: "spell-pick", spellId }, + )]; + }); + const selectedItems = canRemove ? state.selectedSpellIds.map((spellId) => action( + rpgFocusId.spellSelected(spellId), + `Remove ${spellId}`, + { type: "spell-remove", spellId }, + )) : []; + return [ + ...offerItems, + ...selectedItems, + ...(canAdvanceSpellDraft(state) + ? [action(rpgFocusId.spellContinue, "Next spell draft", { type: "spell-next-wave" })] + : []), + ]; + } + + if (state.phase === "challenge-briefing" && state.currentChallenge) { + return [action(rpgFocusId.challengeStart, "Start challenge", { type: "challenge-start" })]; + } + if (state.phase === "boss-briefing") { + return [action(rpgFocusId.bossStart, "Enter boss room", { type: "boss-start" })]; + } + if (state.phase === "boss-cleared") { + return [action(rpgFocusId.rewardOpen, "Open reward chest", { type: "reward-open" })]; + } + if (state.phase === "reward" && state.pendingReward) { + return state.pendingReward.choices.map((choice) => action( + rpgFocusId.rewardChoice(choice.id), + `Choose ${choice.label}`, + { type: "reward-choose", choiceId: choice.id }, + )); + } + if (state.phase === "shop" && state.shop) { + const items: RpgFocusItem[] = state.shop.offers.flatMap((offer) => ( + !offer.sold && state.currency >= offer.price + ? [action(rpgFocusId.shopOffer(offer.id), `Buy ${offer.item.name}`, { type: "shop-buy", offerId: offer.id })] + : [] + )); + items.push(...state.bag.map((item) => action( + rpgFocusId.shopSell(item.id), + `Sell ${item.name}`, + { type: "shop-sell", itemId: item.id }, + ))); + const needsRest = state.playerHp > 0 && state.playerHp < 100 + || state.roster.some((member) => member.hp > 0 && member.hp < member.stats.maxHp); + if (needsRest && state.currency >= state.shop.restCost) { + items.push(action(rpgFocusId.shopRest, "Rest party", { type: "shop-rest" })); + } + if (state.currency >= state.shop.reviveCost) { + items.push(...state.roster.filter((member) => member.hp <= 0).map((member) => action( + rpgFocusId.shopRevive(member.instanceId), + `Revive ${member.name}`, + { type: "shop-revive", memberId: member.instanceId }, + ))); + } + items.push(action(rpgFocusId.shopLeave, "Leave shop", { type: "shop-leave" })); + return items; + } + if (state.phase === "victory" || state.phase === "defeat") { + return [ + { id: rpgFocusId.restartRun, label: "Start a new run", command: { type: "restart-run" } }, + { id: rpgFocusId.exitRun, label: "Return to mode select", command: { type: "exit-run" } }, + ]; + } + return []; +} + +export function resolveRpgFocusCommand( + state: RpgRoguelikeRunState, + focusId: string | null | undefined, +): RpgUiCommand | null { + const items = rpgFocusItems(state); + return items.find((item) => item.id === focusId)?.command ?? items[0]?.command ?? null; +} + +export function normalizeRpgFocusId( + state: RpgRoguelikeRunState, + focusId: string | null | undefined, +): string | null { + const items = rpgFocusItems(state); + return items.some((item) => item.id === focusId) ? focusId ?? null : items[0]?.id ?? null; +} + +function rpgFocusRows(state: RpgRoguelikeRunState, items: readonly RpgFocusItem[]): string[][] { + const enabled = new Set(items.map((item) => item.id)); + const keep = (ids: readonly string[]) => ids.filter((id) => enabled.has(id)); + if (state.phase === "party-draft" && state.partyDraft) { + return [ + keep(state.partyDraft.offers.map((offer) => rpgFocusId.partyOffer(offer.candidateId))), + keep(state.roster.map((member) => rpgFocusId.partyMember(member.instanceId))), + keep([rpgFocusId.partyContinue]), + ].filter((row) => row.length > 0); + } + if (state.phase === "spell-draft" && state.spellDraft) { + return [ + keep(state.spellDraft.offers.map((spellId) => rpgFocusId.spellOffer(spellId))), + keep(state.selectedSpellIds.map((spellId) => rpgFocusId.spellSelected(spellId))), + keep([rpgFocusId.spellContinue]), + ].filter((row) => row.length > 0); + } + if (state.phase === "shop" && state.shop) { + return [ + keep(state.shop.offers.map((offer) => rpgFocusId.shopOffer(offer.id))), + keep([ + ...state.bag.map((item) => rpgFocusId.shopSell(item.id)), + rpgFocusId.shopRest, + ...state.roster.map((member) => rpgFocusId.shopRevive(member.instanceId)), + ]), + keep([rpgFocusId.shopLeave]), + ].filter((row) => row.length > 0); + } + return items.length ? [items.map((item) => item.id)] : []; +} + +/** Four-direction semantic navigation for card grids and stacked action groups. */ +export function moveRpgFocus( + state: RpgRoguelikeRunState, + focusId: string | null | undefined, + direction: RpgFocusDirection, +): string | null { + const items = rpgFocusItems(state); + if (!items.length) return null; + const current = normalizeRpgFocusId(state, focusId) ?? items[0].id; + const rows = rpgFocusRows(state, items); + const rowIndex = rows.findIndex((row) => row.includes(current)); + if (rowIndex < 0) return items[0].id; + const row = rows[rowIndex]; + const columnIndex = row.indexOf(current); + if (direction === "left" || direction === "right") { + const offset = direction === "left" ? -1 : 1; + return row[(columnIndex + offset + row.length) % row.length]; + } + const targetRowIndex = rowIndex + (direction === "up" ? -1 : 1); + if (targetRowIndex < 0 || targetRowIndex >= rows.length) return current; + const targetRow = rows[targetRowIndex]; + const proportionalColumn = row.length <= 1 + ? 0 + : Math.round(columnIndex / (row.length - 1) * Math.max(0, targetRow.length - 1)); + return targetRow[Math.min(targetRow.length - 1, proportionalColumn)]; +} diff --git a/src/game/rpgRoguelikeStore.test.ts b/src/game/rpgRoguelikeStore.test.ts new file mode 100644 index 0000000..292808b --- /dev/null +++ b/src/game/rpgRoguelikeStore.test.ts @@ -0,0 +1,300 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { ARENA_CENTER, ARENA_WALL_RADIUS } from "./arena"; +import { createClassInventory } from "./healers"; +import { createDefaultGearProgress } from "./progression/gear"; +import { equipPassiveInfusion } from "./progression/infusions"; +import { MAX_ACTIVE_ROSTER, PARTY_RECRUITS_PER_WAVE } from "./rpgRoguelike"; +import { useGameStore } from "./store"; + +function finishRpgDrafts() { + let safety = 0; + while (useGameStore.getState().rpgRun?.phase === "party-draft" && safety++ < 20) { + const run = useGameStore.getState().rpgRun!; + const draft = run.partyDraft!; + const tank = draft.offers.find((offer) => offer.role === "Tank"); + const offers = tank ? [tank, ...draft.offers.filter((offer) => offer !== tank)] : draft.offers; + for (const offer of offers) { + const current = useGameStore.getState().rpgRun!; + if (current.roster.length >= MAX_ACTIVE_ROSTER) break; + if ((current.partyDraft?.recruitedThisWaveIds.length ?? 0) >= PARTY_RECRUITS_PER_WAVE) break; + useGameStore.getState().dispatchRpgAction({ type: "party-recruit", candidateId: offer.candidateId }); + } + useGameStore.getState().dispatchRpgAction({ type: "party-next-wave" }); + } + while (useGameStore.getState().rpgRun?.phase === "spell-draft" && safety++ < 40) { + const offers = useGameStore.getState().rpgRun!.spellDraft!.offers.slice(0, 2); + for (const spellId of offers) useGameStore.getState().dispatchRpgAction({ type: "spell-pick", spellId }); + useGameStore.getState().dispatchRpgAction({ type: "spell-next-wave" }); + } + if (safety >= 40) throw new Error("RPG draft did not terminate"); +} + +function silenceBosses() { + useGameStore.setState((state) => ({ + boss: { ...state.boss, nextMeleeAt: 999 }, + bossMotion: { ...state.bossMotion, nextMechanicAt: 999 }, + additionalBosses: state.additionalBosses.map((entry) => ({ + ...entry, + boss: { ...entry.boss, nextMeleeAt: 999 }, + motion: { ...entry.motion, nextMechanicAt: 999 }, + })), + })); +} + +function completeCurrentChallenge() { + const objective = useGameStore.getState().rpgRun!.currentChallenge!.objective; + useGameStore.setState((state) => ({ + endlessBossKills: objective.metric === "bossKills" ? objective.target : state.endlessBossKills, + blockbreaker: objective.metric === "bricksBroken" + ? { ...state.blockbreaker, bricksBroken: objective.target } + : state.blockbreaker, + aetherAssault: objective.metric === "kills" + ? { ...state.aetherAssault, kills: objective.target } + : state.aetherAssault, + })); + silenceBosses(); + useGameStore.getState().tick(0.1); +} + +describe("RPG Roguelike store integration", () => { + beforeEach(() => { + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + "bulldrome", + "rpg-roguelike", + ); + }); + + it("starts in a deterministic gated party draft with semantic focus", () => { + const state = useGameStore.getState(); + expect(state.runMode).toBe("rpg-roguelike"); + expect(state.phase).toBe("briefing"); + expect(state.rpgRun?.phase).toBe("party-draft"); + expect(state.rpgRun?.partyDraft?.offers).toHaveLength(5); + expect(state.abilityLoadout).toEqual({}); + expect(state.rpgFocusId).toMatch(/^party-offer:/); + }); + + it("starts with neutral permanent gear and infusion modifiers", () => { + const progress = createDefaultGearProgress(); + progress.vale.slots.feet.level = 10; + const infused = equipPassiveInfusion(progress, "priest", "mend-efficiency"); + useGameStore.getState().configureHealer( + "priest", + "Aelia", + createClassInventory("priest"), + "bulldrome", + "rpg-roguelike", + infused, + ); + + const state = useGameStore.getState(); + expect(state.passiveRunBuffId).toBeNull(); + expect(state.runModifiers.mendManaMultiplier).toBe(1); + expect(state.gearModifiers.aelia.maxHealth).toBe(1); + }); + + it("projects four drafted companions and a mixed six-spell loadout into a challenge", () => { + finishRpgDrafts(); + const drafted = useGameStore.getState().rpgRun!; + expect(drafted.phase).toBe("challenge-briefing"); + expect(drafted.roster).toHaveLength(4); + expect(drafted.selectedSpellIds).toHaveLength(6); + + expect(useGameStore.getState().dispatchRpgAction({ type: "challenge-start" })).toBe(true); + const state = useGameStore.getState(); + expect(state.phase).toBe("combat"); + expect(state.rpgRun?.phase).toBe("challenge-active"); + expect(state.party).toHaveLength(5); + expect(state.party.slice(1).every((member) => member.runProfile)).toBe(true); + expect(Object.values(state.abilityLoadout)).toEqual(drafted.selectedSpellIds); + expect(state.endlessMode).toBe(true); + }); + + it("tracks Paladin and Chronomancer resources independently in mixed spell runs", () => { + finishRpgDrafts(); + useGameStore.getState().dispatchRpgAction({ type: "challenge-start" }); + silenceBosses(); + useGameStore.setState((state) => ({ + abilityLoadout: { + ability1: "paladin-crusader-strike", + ability2: "paladin-word-of-glory", + ability3: "chronomancer-time-anchor", + }, + selectedMemberId: "brann", + party: state.party.map((member) => member.id === "brann" ? { ...member, hp: 40 } : member), + })); + + expect(useGameStore.getState().castAbility("ability1")).toBe(true); + expect(useGameStore.getState().rpgSpellResources.conviction).toBe(1); + expect(useGameStore.getState().healerMechanic.resource).toBe(0); + useGameStore.getState().tick(0.51); + expect(useGameStore.getState().castAbility("ability2")).toBe(true); + expect(useGameStore.getState().rpgSpellResources.conviction).toBe(0); + + useGameStore.getState().tick(0.51); + expect(useGameStore.getState().castAbility("ability3")).toBe(true); + useGameStore.getState().tick(0.51); + useGameStore.setState((state) => ({ + party: state.party.map((member) => member.id === "brann" ? { ...member, hp: 30 } : member), + })); + expect(useGameStore.getState().castAbility("ability3")).toBe(true); + expect(useGameStore.getState().rpgSpellResources.chronoshards).toBe(1); + }); + + it("applies public in-combat RPG actions without rebuilding the encounter", () => { + finishRpgDrafts(); + useGameStore.getState().dispatchRpgAction({ type: "challenge-start" }); + const member = useGameStore.getState().rpgRun!.roster[0]; + useGameStore.setState((state) => ({ + time: 12, + mana: 37, + boss: { ...state.boss, hp: state.boss.maxHp * 0.5 }, + })); + const bossHp = useGameStore.getState().boss.hp; + + useGameStore.getState().dispatchRpgAction({ + type: "party-vitals", + vitals: [{ instanceId: member.instanceId, hp: member.hp - 1 }], + }); + + const state = useGameStore.getState(); + expect(state.time).toBe(12); + expect(state.mana).toBe(37); + expect(state.boss.hp).toBe(bossHp); + expect(state.rpgRun?.roster.find((candidate) => candidate.instanceId === member.instanceId)?.hp).toBe(member.hp - 1); + }); + + it("turns challenge failure into a boss briefing instead of ending the run", () => { + finishRpgDrafts(); + useGameStore.getState().dispatchRpgAction({ type: "challenge-start" }); + silenceBosses(); + useGameStore.setState((state) => ({ party: state.party.map((member) => ({ ...member, hp: 0 })) })); + + useGameStore.getState().tick(0.1); + + const state = useGameStore.getState(); + expect(state.rpgRun?.phase).toBe("boss-briefing"); + expect(state.rpgRun?.lastChallengeResult?.succeeded).toBe(false); + expect(state.phase).toBe("briefing"); + expect(state.endlessMode).toBe(false); + }); + + it("ends the run when the healer falls, even if companions finish the boss", () => { + finishRpgDrafts(); + useGameStore.getState().dispatchRpgAction({ type: "challenge-start" }); + const objective = useGameStore.getState().rpgRun!.currentChallenge!.objective; + useGameStore.setState((state) => ({ + endlessBossKills: objective.metric === "bossKills" ? objective.target : state.endlessBossKills, + blockbreaker: objective.metric === "bricksBroken" + ? { ...state.blockbreaker, bricksBroken: objective.target } + : state.blockbreaker, + aetherAssault: objective.metric === "kills" + ? { ...state.aetherAssault, kills: objective.target } + : state.aetherAssault, + })); + silenceBosses(); + useGameStore.getState().tick(0.1); + useGameStore.getState().dispatchRpgAction({ type: "boss-start" }); + silenceBosses(); + useGameStore.setState((state) => ({ + boss: { ...state.boss, hp: 0 }, + party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member), + })); + + useGameStore.getState().tick(0.1); + + const state = useGameStore.getState(); + expect(state.phase).toBe("defeat"); + expect(state.rpgRun?.phase).toBe("defeat"); + expect(state.rpgRun?.playerHp).toBe(0); + expect(state.rpgRun?.bossesDefeated).toBe(0); + }); + + it("ends the run when every companion falls, even if the healer and boss remain", () => { + finishRpgDrafts(); + useGameStore.getState().dispatchRpgAction({ type: "challenge-start" }); + completeCurrentChallenge(); + useGameStore.getState().dispatchRpgAction({ type: "boss-start" }); + silenceBosses(); + useGameStore.setState((state) => ({ + party: state.party.map((member) => member.id === "aelia" ? member : { ...member, hp: 0 }), + })); + + useGameStore.getState().tick(0.1); + + expect(useGameStore.getState().phase).toBe("defeat"); + expect(useGameStore.getState().rpgRun?.phase).toBe("defeat"); + expect(useGameStore.getState().rpgRun?.bossesDefeated).toBe(0); + }); + + it("targets a living companion at room start and blocks casts after the boss is cleared", () => { + finishRpgDrafts(); + useGameStore.getState().dispatchRpgAction({ type: "challenge-start" }); + completeCurrentChallenge(); + const first = useGameStore.getState().rpgRun!.roster[0]; + useGameStore.getState().dispatchRpgAction({ + type: "party-vitals", + vitals: [{ instanceId: first.instanceId, hp: 0 }], + }); + useGameStore.getState().dispatchRpgAction({ type: "boss-start" }); + expect(useGameStore.getState().selectedMemberId).not.toBe("brann"); + silenceBosses(); + useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } })); + useGameStore.getState().tick(0.1); + + const before = useGameStore.getState(); + expect(before.rpgRun?.phase).toBe("boss-cleared"); + expect(before.castAbility("ability1")).toBe(false); + expect(useGameStore.getState().mana).toBe(before.mana); + }); + + it("unlocks the north portal after a boss and opens exactly one run chest", () => { + finishRpgDrafts(); + useGameStore.getState().dispatchRpgAction({ type: "challenge-start" }); + const objective = useGameStore.getState().rpgRun!.currentChallenge!.objective; + useGameStore.setState((state) => ({ + endlessBossKills: objective.metric === "bossKills" ? objective.target : state.endlessBossKills, + blockbreaker: objective.metric === "bricksBroken" + ? { ...state.blockbreaker, bricksBroken: objective.target } + : state.blockbreaker, + aetherAssault: objective.metric === "kills" + ? { ...state.aetherAssault, kills: objective.target } + : state.aetherAssault, + })); + silenceBosses(); + useGameStore.getState().tick(0.1); + expect(useGameStore.getState().rpgRun?.phase).toBe("boss-briefing"); + + useGameStore.getState().dispatchRpgAction({ type: "boss-start" }); + silenceBosses(); + const companion = useGameStore.getState().party[1]; + const companionInstanceId = companion.runProfile!.instanceId; + useGameStore.setState((state) => ({ + boss: { ...state.boss, hp: 0 }, + party: state.party.map((member) => member.id === companion.id ? { ...member, hp: member.maxHp * 0.25 } : member), + })); + useGameStore.getState().tick(0.1); + expect(useGameStore.getState().rpgRun?.phase).toBe("boss-cleared"); + expect(useGameStore.getState().phase).toBe("combat"); + const runMaxHp = useGameStore.getState().rpgRun!.roster + .find((member) => member.instanceId === companionInstanceId)!.stats.maxHp; + expect(useGameStore.getState().rpgRun!.roster.find((member) => member.instanceId === companionInstanceId)!.hp) + .toBeCloseTo(runMaxHp * 0.25); + + useGameStore.setState((state) => ({ + party: state.party.map((member) => member.id === companion.id ? { ...member, hp: member.maxHp } : member), + })); + + useGameStore.getState().setPlayerPosition([ARENA_CENTER[0], ARENA_CENTER[1] - ARENA_WALL_RADIUS - 0.7]); + const reward = useGameStore.getState().rpgRun; + expect(reward?.phase).toBe("reward"); + expect(reward?.roster.find((member) => member.instanceId === companionInstanceId)?.hp).toBe(runMaxHp); + expect(reward?.pendingReward?.choices).toHaveLength(3); + const chestId = reward?.pendingReward?.id; + useGameStore.getState().setPlayerPosition([ARENA_CENTER[0], ARENA_CENTER[1] - ARENA_WALL_RADIUS - 0.8]); + expect(useGameStore.getState().rpgRun?.pendingReward?.id).toBe(chestId); + }); +}); diff --git a/src/game/runModes.ts b/src/game/runModes.ts new file mode 100644 index 0000000..4c9ad20 --- /dev/null +++ b/src/game/runModes.ts @@ -0,0 +1,13 @@ +import type { GameplayActivity, RunMode } from "./types"; + +/** Competitive runs always use normalized combat stats, regardless of saved gear. */ +export function isPvpRunMode(runMode: RunMode): boolean { + return runMode.endsWith("-pvp"); +} + +export function defaultGameplayActivity(runMode: RunMode): GameplayActivity { + if (runMode === "hockey-healing" || runMode === "hockey-healing-pvp" || runMode === "blockbreaker" || runMode === "aether-assault") { + return runMode; + } + return "boss"; +} diff --git a/src/game/staffCastGlow.test.ts b/src/game/staffCastGlow.test.ts new file mode 100644 index 0000000..167072b --- /dev/null +++ b/src/game/staffCastGlow.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + STAFF_CAST_AFTERGLOW_SECONDS, + STAFF_CAST_GLOW_PROFILES, + isHealerPulseKind, + staffCastGlowStrength, +} from "./staffCastGlow"; + +describe("staff cast glow", () => { + it("uses a distinct color for every healer class", () => { + expect(STAFF_CAST_GLOW_PROFILES.priest.color).toBe("#ffd66b"); + expect(STAFF_CAST_GLOW_PROFILES.druid.color).toBe("#72df72"); + expect(STAFF_CAST_GLOW_PROFILES.shaman.color).toBe("#62bdff"); + expect(STAFF_CAST_GLOW_PROFILES.paladin.color).toBe("#f2a65a"); + expect(STAFF_CAST_GLOW_PROFILES.chronomancer.color).toBe("#62d4d9"); + expect(new Set(Object.values(STAFF_CAST_GLOW_PROFILES).map((profile) => profile.color)).size).toBe(5); + }); + + it("accepts every healer cast pulse and rejects encounter pulses", () => { + for (const kind of ["direct-heal", "periodic-heal", "protective", "cleanse", "group-heal", "field"] as const) { + expect(isHealerPulseKind(kind)).toBe(true); + } + expect(isHealerPulseKind("boss")).toBe(false); + expect(isHealerPulseKind("venom")).toBe(false); + }); + + it("ramps during a cast, flashes on completion, then fades out", () => { + const castStart = staffCastGlowStrength({ castingProgress: 0, afterglowAge: null }); + const castEnd = staffCastGlowStrength({ castingProgress: 1, afterglowAge: null }); + const completed = staffCastGlowStrength({ castingProgress: null, afterglowAge: 0 }); + const fading = staffCastGlowStrength({ castingProgress: null, afterglowAge: STAFF_CAST_AFTERGLOW_SECONDS / 2 }); + const finished = staffCastGlowStrength({ castingProgress: null, afterglowAge: STAFF_CAST_AFTERGLOW_SECONDS }); + + expect(castStart).toBeGreaterThan(0); + expect(castEnd).toBeGreaterThan(castStart); + expect(completed).toBe(1); + expect(fading).toBeGreaterThan(0); + expect(fading).toBeLessThan(completed); + expect(finished).toBe(0); + }); + + it("stays dark without an active or completed cast", () => { + expect(staffCastGlowStrength({ castingProgress: null, afterglowAge: null })).toBe(0); + }); +}); diff --git a/src/game/staffCastGlow.ts b/src/game/staffCastGlow.ts new file mode 100644 index 0000000..f92ae6d --- /dev/null +++ b/src/game/staffCastGlow.ts @@ -0,0 +1,45 @@ +import type { HealerClassId, HealerPulseKind, PulseKind } from "./types"; + +export interface StaffCastGlowProfile { + color: string; +} + +export const STAFF_CAST_GLOW_PROFILES: Record = { + priest: { color: "#ffd66b" }, + druid: { color: "#72df72" }, + shaman: { color: "#62bdff" }, + paladin: { color: "#f2a65a" }, + chronomancer: { color: "#62d4d9" }, +}; + +export const STAFF_CAST_AFTERGLOW_SECONDS = 0.85; + +export function isHealerPulseKind(kind: PulseKind): kind is HealerPulseKind { + return kind === "direct-heal" + || kind === "periodic-heal" + || kind === "protective" + || kind === "cleanse" + || kind === "group-heal" + || kind === "field"; +} + +function clamp01(value: number) { + return Math.max(0, Math.min(1, value)); +} + +export function staffCastGlowStrength({ + castingProgress, + afterglowAge, +}: { + castingProgress: number | null; + afterglowAge: number | null; +}) { + const progress = castingProgress === null ? 0 : clamp01(castingProgress); + const smoothProgress = progress * progress * (3 - 2 * progress); + const castingStrength = castingProgress === null ? 0 : 0.42 + smoothProgress * 0.58; + const afterglowProgress = afterglowAge === null + ? 1 + : clamp01(afterglowAge / STAFF_CAST_AFTERGLOW_SECONDS); + const afterglowStrength = afterglowAge === null ? 0 : (1 - afterglowProgress) ** 2; + return Math.max(castingStrength, afterglowStrength); +} diff --git a/src/game/store.test.ts b/src/game/store.test.ts index fc47a48..85be685 100644 --- a/src/game/store.test.ts +++ b/src/game/store.test.ts @@ -3,6 +3,7 @@ import { BULL_CHARGE } from "./bossMechanics"; import { distance, pointToSegmentDistance } from "./geometry"; import { RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store"; import { createClassInventory, HEALER_CLASSES } from "./healers"; +import { healingEffect } from "./healerEffects"; import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool"; import { ARENA_CENTER, isInsideArena } from "./arena"; import { BOSS_DEFINITIONS } from "./bossCatalog"; @@ -37,7 +38,7 @@ describe("Disc Priest combat simulation", () => { useGameStore.setState({ party }); useGameStore.getState().selectMember("nia"); - expect(useGameStore.getState().castAbility("mend")).toBe(true); + expect(useGameStore.getState().castAbility("ability1")).toBe(true); useGameStore.getState().tick(0.4); expect(useGameStore.getState().party.find((member) => member.id === "nia")?.hp).toBe(40); useGameStore.getState().tick(0.11); @@ -45,28 +46,28 @@ describe("Disc Priest combat simulation", () => { const state = useGameStore.getState(); expect(state.party.find((member) => member.id === "nia")?.hp).toBe(78); expect(state.activeCast).toBeNull(); - expect(state.cooldowns.mend).toBe(0); - expect(state.castAbility("mend")).toBe(true); + expect(state.cooldowns.ability1).toBe(0); + expect(state.castAbility("ability1")).toBe(true); }); it("uses the rebalanced Priest mana costs", () => { expect(Object.fromEntries(Object.entries(HEALER_CLASSES.priest.abilities).map(([id, ability]) => [id, ability.mana]))).toEqual({ - mend: 5, - renew: 7, - shield: 8, - purify: 5, - radiance: 12, - barrier: 10, + ability1: 5, + ability2: 7, + ability3: 8, + ability4: 5, + ability5: 12, + ability6: 10, }); }); it("uses a three-second Purify cooldown for every healer class", () => { - expect(HEALER_CLASSES.priest.abilities.purify.cooldown).toBe(3); - expect(HEALER_CLASSES.druid.abilities.purify.cooldown).toBe(3); - expect(HEALER_CLASSES.shaman.abilities.purify.cooldown).toBe(3); + expect(HEALER_CLASSES.priest.abilities.ability4.cooldown).toBe(3); + expect(HEALER_CLASSES.druid.abilities.ability4.cooldown).toBe(3); + expect(HEALER_CLASSES.shaman.abilities.ability4.cooldown).toBe(3); }); - it("configures placeholder healer kits and class-owned inventory", () => { + it("configures distinct healer kits and class-owned inventory", () => { const inventory = createClassInventory("druid"); useGameStore.getState().configureHealer("druid", "Aelia", inventory); @@ -75,8 +76,8 @@ describe("Disc Priest combat simulation", () => { expect(state.party[0].className).toBe("Restoration Druid"); expect(state.party[0].name).toBe("Aelia"); expect(state.inventory).toEqual(inventory); - expect(HEALER_CLASSES.druid.abilities.mend.name).toBe("Healing Touch"); - expect(HEALER_CLASSES.shaman.abilities.radiance.name).toBe("Chain Heal"); + expect(HEALER_CLASSES.druid.abilities.ability1.name).toBe("Regrowth"); + expect(HEALER_CLASSES.shaman.abilities.ability5.name).toBe("Chain Heal"); }); it("ticks Renew once per second for eight seconds", () => { @@ -84,37 +85,37 @@ describe("Disc Priest combat simulation", () => { member.id === "brann" ? { ...member, hp: 70 } : member, ); useGameStore.setState({ party }); - useGameStore.getState().castAbility("renew"); + useGameStore.getState().castAbility("ability2"); for (let index = 0; index < 8; index += 1) useGameStore.getState().tick(1); const brann = useGameStore.getState().party.find((member) => member.id === "brann")!; - expect(brann.renewExpiresAt).toBe(0); + expect(healingEffect(brann, "renew")).toBeUndefined(); expect(brann.hp).toBeGreaterThan(70); }); it("gives Renew no individual cooldown", () => { - expect(useGameStore.getState().castAbility("renew")).toBe(true); - expect(useGameStore.getState().cooldowns.renew).toBe(0); + expect(useGameStore.getState().castAbility("ability2")).toBe(true); + expect(useGameStore.getState().cooldowns.ability2).toBe(0); useGameStore.getState().tick(0.5); - expect(useGameStore.getState().castAbility("renew")).toBe(true); - expect(useGameStore.getState().cooldowns.renew).toBe(0); + expect(useGameStore.getState().castAbility("ability2")).toBe(true); + expect(useGameStore.getState().cooldowns.ability2).toBe(0); }); it("blocks all abilities during the shared 0.5 second global cooldown", () => { - expect(useGameStore.getState().castAbility("renew")).toBe(true); - expect(useGameStore.getState().castAbility("shield")).toBe(false); + expect(useGameStore.getState().castAbility("ability2")).toBe(true); + expect(useGameStore.getState().castAbility("ability3")).toBe(false); useGameStore.getState().tick(0.49); - expect(useGameStore.getState().castAbility("shield")).toBe(false); + expect(useGameStore.getState().castAbility("ability3")).toBe(false); useGameStore.getState().tick(0.02); - expect(useGameStore.getState().castAbility("shield")).toBe(true); + expect(useGameStore.getState().castAbility("ability3")).toBe(true); }); it("uses blue absorption before health", () => { - useGameStore.getState().castAbility("shield"); + useGameStore.getState().castAbility("ability3"); useGameStore.getState().tick(2); const brann = useGameStore.getState().party.find((member) => member.id === "brann")!; @@ -131,7 +132,7 @@ describe("Disc Priest combat simulation", () => { expect(branded.debuffs).toHaveLength(1); useGameStore.getState().selectMember(branded.id); - expect(useGameStore.getState().castAbility("purify")).toBe(true); + expect(useGameStore.getState().castAbility("ability4")).toBe(true); expect(useGameStore.getState().party.find((member) => member.id === branded.id)?.debuffs).toHaveLength(0); }); @@ -139,7 +140,7 @@ describe("Disc Priest combat simulation", () => { useGameStore.setState({ party: useGameStore.getState().party.map((member) => ({ ...member, hp: member.hp - 30 })), }); - useGameStore.getState().castAbility("radiance"); + useGameStore.getState().castAbility("ability5"); for (const member of useGameStore.getState().party) { expect(member.hp).toBe(member.maxHp - 8); @@ -151,17 +152,17 @@ describe("Disc Priest combat simulation", () => { useGameStore.setState((state) => ({ boss: { ...state.boss }, })); - expect(useGameStore.getState().castAbility("barrier")).toBe(true); + expect(useGameStore.getState().castAbility("ability6")).toBe(true); useGameStore.getState().tick(2); const state = useGameStore.getState(); expect(state.party.find((member) => member.id === "brann")?.hp).toBeCloseTo(139.5, 4); expect(barrierProtects(state.partyPositions.brann, state.barrier, state.time)).toBe(true); - expect(state.cooldowns.barrier).toBe(60); + expect(state.cooldowns.ability6).toBe(60); }); it("expires Barrier after eight seconds", () => { - useGameStore.getState().castAbility("barrier"); + useGameStore.getState().castAbility("ability6"); const barrier = useGameStore.getState().barrier; expect(barrierProtects(barrier.center, barrier, 7.99)).toBe(true); expect(barrierProtects(barrier.center, barrier, 8)).toBe(false); @@ -190,7 +191,7 @@ describe("Disc Priest combat simulation", () => { expect(paused.time).toBe(before.time); expect(paused.party).toEqual(before.party); expect(paused.boss.hp).toBe(before.boss.hp); - expect(paused.castAbility("renew")).toBe(false); + expect(paused.castAbility("ability2")).toBe(false); }); it("cancels an active cast and blocks new abilities when the healer falls", () => { @@ -200,7 +201,7 @@ describe("Disc Priest combat simulation", () => { party: state.party.map((member) => member.id === "nia" ? { ...member, hp: 40 } : member), })); useGameStore.getState().selectMember("nia"); - expect(useGameStore.getState().castAbility("mend")).toBe(true); + expect(useGameStore.getState().castAbility("ability1")).toBe(true); useGameStore.setState((state) => ({ party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member), })); @@ -210,7 +211,7 @@ describe("Disc Priest combat simulation", () => { expect(state.phase).toBe("combat"); expect(state.activeCast).toBeNull(); expect(state.party.find((member) => member.id === "nia")?.hp).toBe(40); - expect(state.castAbility("renew")).toBe(false); + expect(state.castAbility("ability2")).toBe(false); }); it("does not publish unchanged player positions while idle", () => { @@ -423,7 +424,7 @@ describe("Broodfang encounter", () => { expect(poisoned).toBeDefined(); useGameStore.getState().selectMember(poisoned.id); - expect(useGameStore.getState().castAbility("purify")).toBe(true); + expect(useGameStore.getState().castAbility("ability4")).toBe(true); const state = useGameStore.getState(); expect(state.party.find((member) => member.id === poisoned.id)?.debuffs).toEqual([]); expect(state.bossMotion.hazards).toHaveLength(1); @@ -522,7 +523,7 @@ describe("PVE dual-boss encounter", () => { const poisoned = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Widow Venom"))!; useGameStore.getState().selectMember(poisoned.id); - expect(useGameStore.getState().castAbility("purify")).toBe(true); + expect(useGameStore.getState().castAbility("ability4")).toBe(true); expect(useGameStore.getState().bossMotion.hazards.filter((hazard) => hazard.kind === "venom_pool")).toHaveLength(1); expect(useGameStore.getState().additionalBosses[0].motion.hazards.filter((hazard) => hazard.kind === "venom_pool")).toHaveLength(0); }); @@ -539,7 +540,7 @@ describe("Roguelike ability buffs", () => { })); useGameStore.getState().selectMember("brann"); - expect(useGameStore.getState().castAbility("mend")).toBe(true); + expect(useGameStore.getState().castAbility("ability1")).toBe(true); expect(useGameStore.getState().mana).toBe(97); expect(useGameStore.getState().activeCast?.completesAt).toBeCloseTo(0.5 * 0.75 ** 3); useGameStore.getState().tick(0.22); @@ -560,10 +561,10 @@ describe("Roguelike ability buffs", () => { })), })); useGameStore.getState().selectMember("brann"); - expect(useGameStore.getState().castAbility("renew")).toBe(true); + expect(useGameStore.getState().castAbility("ability2")).toBe(true); let party = useGameStore.getState().party; - expect(party.filter((member) => member.renewExpiresAt === 12).map((member) => member.id)).toEqual(["brann", "nia", "orin"]); + expect(party.filter((member) => healingEffect(member, "renew")?.expiresAt === 12).map((member) => member.id)).toEqual(["brann", "nia", "orin"]); useGameStore.getState().tick(1.01); party = useGameStore.getState().party; expect(party.find((member) => member.id === "brann")?.hp).toBeCloseTo(59.8); @@ -580,7 +581,7 @@ describe("Roguelike ability buffs", () => { })), })); useGameStore.getState().selectMember("brann"); - expect(useGameStore.getState().castAbility("shield")).toBe(true); + expect(useGameStore.getState().castAbility("ability3")).toBe(true); const party = useGameStore.getState().party; expect(party.find((member) => member.id === "brann")?.absorb).toBe(54); @@ -600,13 +601,13 @@ describe("Roguelike ability buffs", () => { : member), })); useGameStore.getState().selectMember("brann"); - expect(useGameStore.getState().castAbility("purify")).toBe(true); + expect(useGameStore.getState().castAbility("ability4")).toBe(true); const party = useGameStore.getState().party; for (const id of ["brann", "nia"] as const) { const member = party.find((candidate) => candidate.id === id)!; expect(member.debuffs).toEqual([]); - expect(member.renewExpiresAt).toBe(8); + expect(healingEffect(member, "renew")?.expiresAt).toBe(8); expect(member.absorb).toBe(18); } expect(party.find((member) => member.id === "orin")?.debuffs).toHaveLength(1); @@ -615,11 +616,11 @@ describe("Roguelike ability buffs", () => { it("applies Radiance cooldown, party Renew, and party absorption", () => { startBuffedEncounter({ "radiance-cooldown": 3, "radiance-renew": 1, "radiance-shield": 2 }); useGameStore.setState((state) => ({ party: state.party.map((member) => ({ ...member, hp: Math.max(1, member.hp - 30) })) })); - expect(useGameStore.getState().castAbility("radiance")).toBe(true); + expect(useGameStore.getState().castAbility("ability5")).toBe(true); const state = useGameStore.getState(); - expect(state.cooldowns.radiance).toBeCloseTo(14 * 0.8 ** 3); - expect(state.party.every((member) => member.renewExpiresAt === 8)).toBe(true); + expect(state.cooldowns.ability5).toBeCloseTo(14 * 0.8 ** 3); + expect(state.party.every((member) => healingEffect(member, "renew")?.expiresAt === 8)).toBe(true); expect(state.party.every((member) => member.absorb === 18)).toBe(true); }); @@ -628,8 +629,8 @@ describe("Roguelike ability buffs", () => { useGameStore.setState((state) => ({ party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 50 } : member), })); - expect(useGameStore.getState().castAbility("barrier")).toBe(true); - expect(useGameStore.getState().cooldowns.barrier).toBeCloseTo(60 * 0.8 ** 3); + expect(useGameStore.getState().castAbility("ability6")).toBe(true); + expect(useGameStore.getState().cooldowns.ability6).toBeCloseTo(60 * 0.8 ** 3); expect(useGameStore.getState().barrier.expiresAt).toBe(14); useGameStore.getState().tick(1.01); expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBe(59); diff --git a/src/game/store.ts b/src/game/store.ts index 5e2097d..65ab002 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -7,16 +7,42 @@ import { upcomingMechanic, } from "./bossMechanics"; import { BOSS_DEFINITIONS } from "./bossCatalog"; -import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath"; +import { bossDeathDespawnSeconds } from "./bossDeath"; import { normalizeEncounterBossIds } from "./bossSelection"; -import { clampToArena, constrainBossMotion } from "./arena"; +import { clampToArena, clampToHockeyArena, clampToHockeyEnemyHalf, clampToHockeyHealerHalf, constrainBossMotion } from "./arena"; import { cloneMotion } from "./bosses/shared"; import { freshParty } from "./data"; import { distance } from "./geometry"; -import { createClassInventory, HEALER_CLASSES } from "./healers"; +import { + createClassAbilityLoadout, + createClassInventory, + HEALER_ABILITIES, + HEALER_CLASSES, + resolveSlottedAbility, +} from "./healers"; +import { + advanceHealingEffects, + chainHealIndexes, + equalizeHealthPercentages, + flourishParty, + healingEffect, + removeHealingEffect, + resolveReactiveHealAfterDamage, + setHealingEffect, + triggerHealingEffects, +} from "./healerEffects"; +import { + createHealerMechanicState, + expireHealerMechanics, + healMostInjured, + healTargetAndBeacon, + placeOrRewindTimeAnchor, + resolveTimeLoop, + startTimeLoop, +} from "./healerMechanics"; import { combatFormation, updatePartyPositions } from "./partyBehaviors"; import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat"; -import { isPartyWiped } from "./partyState"; +import { areAllNonHealerAlliesDefeated, isPartyWiped } from "./partyState"; import { RUN_BUFFS, bossHealthMultiplier, @@ -33,19 +59,41 @@ import { type CompiledRunModifiers, } from "./roguelike"; import { createDefaultGearProgress, type GearProgress } from "./progression/gear"; -import { aiCombatModifiers, applyGearHealth, createEncounterGearModifiers, type EncounterGearModifiers } from "./progression/gearEffects"; +import { aiCombatModifiers, applyGearHealth, createBaseEncounterGearModifiers, createEncounterGearModifiers, type EncounterGearModifiers } from "./progression/gearEffects"; import { passiveInfusionUnlocked } from "./progression/infusions"; import { DIFFICULTY_BY_SLUG, normalizeDifficultySlug, type DifficultySlug } from "./progression/loot"; +import { + HOCKEY_DEFAULT_PLAYER_POSITION, + advanceHockeyHealing, + createHockeyHealingState, + setHockeyAim, + type HockeyHealingState, +} from "./hockeyHealing"; +import { + HOCKEY_PVP_GOAL_DAMAGE, + advanceHockeyPvpCpuGoalie, + advanceHockeyPvpPuck, + createHockeyPvpState, + hockeyPvpHealingEffectiveness, + hockeyPvpBossAt, + mirrorHockeyPvpPuck, + type HockeyPvpMatchConfig, + type HockeyPvpRemoteSnapshot, + type HockeyPvpState, +} from "./hockeyHealingPvp"; import type { ActiveCast, - AbilityId, + AbilityLoadout, + AbilitySlotId, BarrierState, BossMotionState, BossState, BossId, BottomTab, GamePhase, + GameplayActivity, HealerClassId, + HealerMechanicState, InventoryItem, MemberId, PartyMember, @@ -55,6 +103,41 @@ import type { ScenePulse, WorldPosition, } from "./types"; +import { defaultGameplayActivity, isPvpRunMode } from "./runModes"; +import { + BLOCKBREAKER_BREACH_DAMAGE, + advanceBlockbreaker, + createBlockbreakerSeed, + createBlockbreakerState, + setBlockbreakerAim, + type BlockbreakerState, +} from "./blockbreaker"; +import { + advanceAetherAssault, + createAetherAssaultSeed, + createAetherAssaultState, + type AetherAssaultState, +} from "./aetherAssault"; +import { + assignRosterToCombatSlots, + BOSSES_PER_ACT, + createRpgRoguelikeRun, + reduceRpgRoguelikeRun, + selectCurrentBossId, + type ChallengeMetrics, + type RpgRoguelikeAction, + type RpgRoguelikeRunState, +} from "./rpgRoguelike"; +import { + extractRpgPlayerVital, + extractRpgPartyVitals, + projectRpgCombat, + spellRankCooldownMultiplier, + spellRankPowerMultiplier, + type RpgPartyDamageProfiles, +} from "./rpgRoguelike/combatAdapter"; +import { CLOSED_BOSS_ARENA_PORTALS, NORTH_OPEN_BOSS_ARENA_PORTALS, clampToBossArenaWithPortals, detectBossArenaExit } from "./rpgRoguelike/playSpace"; +import { moveRpgFocus, normalizeRpgFocusId, rpgFocusItems, type RpgFocusDirection } from "./rpgRoguelike/uiModel"; export interface CombatLogEntry { id: number; @@ -69,21 +152,49 @@ export interface AdditionalBossState { motion: BossMotionState; } +export interface HockeyPvpOpponentState { + party: PartyMember[]; + partyPositions: Record; + boss: BossState; + bossMotion: BossMotionState; + partyCombat: PartyCombatState; +} + +export interface RpgSpellResources { + verdancy: number; + tidalSurge: number; + conviction: number; + chronoshards: number; +} + export interface GameState { bossId: BossId; bossInstanceId: string; paused: boolean; pauseSelection: "resume" | "exit"; healerClassId: HealerClassId; + abilityLoadout: AbilityLoadout; playerName: string; phase: GamePhase; runMode: RunMode; + activityMode: GameplayActivity; + /** Authoritative, serializable state for the modular RPG Roguelike mode. */ + rpgRun: RpgRoguelikeRunState | null; + /** Semantic focus target shared by both displays; never depends on DOM focus. */ + rpgFocusId: string | null; + rpgDamageProfiles: RpgPartyDamageProfiles | null; + rpgSpellResources: RpgSpellResources; round: number; seenBossIds: BossId[]; endlessMode: boolean; endlessBossKills: number; endlessSpawnSequence: number; endlessChoiceSelection: "continue" | "quit"; + hockey: HockeyHealingState; + blockbreaker: BlockbreakerState; + aetherAssault: AetherAssaultState; + hockeyPvp: HockeyPvpState; + hockeyPvpOpponent: HockeyPvpOpponentState; runBuffRanks: RunBuffRanks; draftBuffIds: RunBuffId[]; selectedRunBuffId: RunBuffId | null; @@ -106,7 +217,7 @@ export interface GameState { mana: number; maxMana: number; selectedMemberId: MemberId; - cooldowns: Record; + cooldowns: Record; globalCooldownUntil: number; activeTab: BottomTab; selectedItemId: string; @@ -116,16 +227,19 @@ export interface GameState { playerPosition: [number, number]; activeCast: ActiveCast | null; barrier: BarrierState; - configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[], runMode?: RunMode, gearProgress?: GearProgress, difficultySlug?: DifficultySlug) => void; + healerMechanic: HealerMechanicState; + configureHealer: (classId: HealerClassId, playerName: string, inventory: InventoryItem[], bossIds?: BossId | readonly BossId[], runMode?: RunMode, gearProgress?: GearProgress, difficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => void; startEncounter: () => void; restart: () => void; tick: (delta: number) => void; - castAbility: (abilityId: AbilityId) => boolean; + castAbility: (abilitySlotId: AbilitySlotId) => boolean; selectMember: (memberId: MemberId) => void; cycleMember: (direction: 1 | -1) => void; setActiveTab: (tab: BottomTab) => void; selectItem: (itemId: string) => void; setPlayerPosition: (position: [number, number]) => void; + setHockeyAimDirection: (direction: [number, number]) => void; + applyHockeyPvpRemoteSnapshot: (snapshot: HockeyPvpRemoteSnapshot, hostPuck?: HockeyPvpRemoteSnapshot["puck"]) => void; setPaused: (paused: boolean) => void; togglePause: () => void; setPauseSelection: (selection: "resume" | "exit") => void; @@ -134,15 +248,20 @@ export interface GameState { continueRoguelikeRound: () => boolean; startRogueTrialsEndless: () => boolean; setEndlessChoiceSelection: (selection: "continue" | "quit") => void; + setAbilityLoadout: (loadout: AbilityLoadout) => void; + dispatchRpgAction: (action: RpgRoguelikeAction) => boolean; + setRpgFocusId: (focusId: string) => void; + cycleRpgFocus: (direction: 1 | -1) => void; + moveRpgFocus: (direction: RpgFocusDirection) => void; } -const emptyCooldowns = (): Record => ({ - mend: 0, - renew: 0, - shield: 0, - purify: 0, - radiance: 0, - barrier: 0, +const emptyCooldowns = (): Record => ({ + ability1: 0, + ability2: 0, + ability3: 0, + ability4: 0, + ability5: 0, + ability6: 0, }); export const GLOBAL_COOLDOWN_SECONDS = 0.5; @@ -156,7 +275,15 @@ const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): Bo return normalizeEncounterBossIds(requested); }; -function createEncounterMotion(bossId: BossId, index: number, count: number, startsAt = 0): BossMotionState { +type EncounterLayout = "standard" | "hockey"; + +function createEncounterMotion( + bossId: BossId, + index: number, + count: number, + startsAt = 0, + layout: EncounterLayout = "standard", +): BossMotionState { const motion = cloneMotion(createBossMotionState(bossId)); const offset = count === 3 ? [-3.7, 0, 3.7][index] : count === 2 ? [-2.65, 2.65][index] : 0; motion.formationOffsetX = offset; @@ -164,21 +291,46 @@ function createEncounterMotion(bossId: BossId, index: number, count: number, sta motion.chargeStart[0] += offset; motion.chargeEnd[0] += offset; motion.pounceCenter[0] += offset; + if (layout === "hockey") { + const anchor: WorldPosition = index === 0 ? [-2.8, -10] : index === 1 ? [2.8, -6.5] : [0, -8]; + const dx = anchor[0] - motion.position[0]; + const dz = anchor[1] - motion.position[1]; + motion.position = [...anchor]; + motion.chargeStart = [motion.chargeStart[0] + dx, motion.chargeStart[1] + dz]; + motion.chargeEnd = [motion.chargeEnd[0] + dx, motion.chargeEnd[1] + dz]; + motion.pounceCenter = [motion.pounceCenter[0] + dx, motion.pounceCenter[1] + dz]; + } const stagger = index * 2.4; if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += startsAt + stagger; return constrainBossMotion(motion); } -function createEncounterBoss(bossId: BossId, index: number, count: number, healthMultiplier: number, startsAt = 0): AdditionalBossState { +function createEncounterBoss( + bossId: BossId, + index: number, + count: number, + healthMultiplier: number, + startsAt = 0, + layout: EncounterLayout = "standard", +): AdditionalBossState { const boss = createBossState(bossId); boss.maxHp = Math.round(boss.maxHp * healthMultiplier); boss.hp = boss.maxHp; const stagger = index * 0.8; if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += startsAt + stagger; - return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count, startsAt) }; + return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count, startsAt, layout) }; } -const freshPartyPositions = (bossIds: readonly BossId[]): Record => { +const freshPartyPositions = (bossIds: readonly BossId[], layout: EncounterLayout = "standard"): Record => { + if (layout === "hockey") { + return { + aelia: [...HOCKEY_DEFAULT_PLAYER_POSITION], + brann: [-1.2, -2.8], + nia: [-4.2, -1.6], + orin: [4.2, -1.6], + vale: [1.1, -8.4], + }; + } const bossPosition = createBossMotionState(bossIds[0]).position; if (bossIds.length > 1) bossPosition[0] = 0; const formation = combatFormation(bossPosition); @@ -205,8 +357,20 @@ export function healMember(member: PartyMember, amount: number): PartyMember { return { ...member, hp: Math.min(member.maxHp, member.hp + amount) }; } +function effectiveHealingMultiplier(state: Pick): number { + if (state.runMode !== "hockey-healing-pvp") return state.healingMultiplier; + return state.healingMultiplier * hockeyPvpHealingEffectiveness( + state.endlessBossKills, + state.hockeyPvp.opponentBossKills, + ); +} + export function barrierProtects(position: WorldPosition, barrier: BarrierState, time: number) { - return barrier.expiresAt > time && distance(position, barrier.center) <= BARRIER_RADIUS; + return barrier.kind === "barrier" && healerFieldContains(position, barrier, time); +} + +export function healerFieldContains(position: WorldPosition, barrier: BarrierState, time: number) { + return barrier.kind !== null && barrier.expiresAt > time && distance(position, barrier.center) <= BARRIER_RADIUS; } function lowestHealthIndexes( @@ -223,12 +387,83 @@ function lowestHealthIndexes( .map(({ index }) => index); } -function applyRenewAt(party: PartyMember[], index: number, time: number, modifiers: CompiledRunModifiers) { +function applyRenewAt(party: PartyMember[], index: number, time: number, modifiers: CompiledRunModifiers, power = 1) { + if (party[index].hp <= 0) return; + party[index] = setHealingEffect(party[index], { + id: "renew", + expiresAt: time + 8 + modifiers.renewDurationBonus, + nextTickAt: time + 1, + tickInterval: 1, + healingPerTick: 7 * modifiers.renewHealingMultiplier * power, + stacks: 1, + }); +} + +function applyRejuvenationAt(party: PartyMember[], index: number, time: number, modifiers: CompiledRunModifiers, power = 1) { + if (party[index].hp <= 0) return; + party[index] = setHealingEffect(party[index], { + id: "rejuvenation", + expiresAt: time + 8 + modifiers.renewDurationBonus, + nextTickAt: time + 1, + tickInterval: 1, + healingPerTick: 6 * modifiers.renewHealingMultiplier * power, + stacks: 1, + }); +} + +function applyRiptideAt(party: PartyMember[], index: number, time: number, modifiers: CompiledRunModifiers, power = 1) { + if (party[index].hp <= 0) return; + party[index] = setHealingEffect(party[index], { + id: "riptide", + expiresAt: time + 6 + modifiers.renewDurationBonus, + nextTickAt: time + 1, + tickInterval: 1, + healingPerTick: 5 * modifiers.renewHealingMultiplier * power, + stacks: 1, + }); +} + +function applyLifebloomAt( + party: PartyMember[], + index: number, + time: number, + modifiers: CompiledRunModifiers, + stacksToAdd = 1, + power = 1, +) { + if (party[index].hp <= 0) return 0; + const current = healingEffect(party[index], "lifebloom"); + const stacks = Math.min(3, (current?.stacks ?? 0) + stacksToAdd); + party[index] = setHealingEffect(party[index], { + id: "lifebloom", + expiresAt: time + 7, + nextTickAt: current?.nextTickAt && current.expiresAt > time ? current.nextTickAt : time + 1, + tickInterval: 1, + healingPerTick: 2 * modifiers.shieldAbsorbMultiplier * power, + stacks, + expirationHealingPerStack: 10 * modifiers.shieldAbsorbMultiplier * power, + }); + return stacks; +} + +function applyEarthShieldAt( + party: PartyMember[], + index: number, + time: number, + modifiers: CompiledRunModifiers, + charges = 6, + power = 1, +) { if (party[index].hp <= 0) return; party[index] = { ...party[index], - renewExpiresAt: time + 8 + modifiers.renewDurationBonus, - renewNextTickAt: time + 1, + reactiveHeal: { + id: "earth-shield", + charges, + expiresAt: time + 30, + nextTriggerAt: time, + healingPerTrigger: 9 * modifiers.shieldAbsorbMultiplier * power, + }, }; } @@ -260,6 +495,7 @@ function damageMemberAt( gearModifiers?: EncounterGearModifiers, kind: "direct" | "hazard" = "direct", shieldDamageTakenMultiplier = 1, + onReactiveHeal?: () => void, ) { amount *= incomingDamageMultiplier; if (kind === "hazard") amount *= gearModifiers?.[member.id].hazardDamageTaken ?? 1; @@ -270,8 +506,15 @@ function damageMemberAt( barrierProtects(position, barrier, time) ? BARRIER_DAMAGE_REDUCTION : 0, protectedByTank ? partyCombat?.tankAura.damageReduction ?? 0 : 0, ); - const shieldMultiplier = member.absorb > 0 ? shieldDamageTakenMultiplier : 1; - return damageMember(member, amount * (1 - reduction) * shieldMultiplier); + const lifebloom = healingEffect(member, "lifebloom"); + const warded = member.absorb > 0 + || Boolean(member.reactiveHeal && member.reactiveHeal.expiresAt > time) + || Boolean(lifebloom && lifebloom.expiresAt > time); + const shieldMultiplier = warded ? shieldDamageTakenMultiplier : 1; + const damaged = damageMember(member, amount * (1 - reduction) * shieldMultiplier); + const reactive = resolveReactiveHealAfterDamage(member, damaged, time, gearModifiers?.aelia.healingPower ?? 1); + if (reactive.triggered) onReactiveHeal?.(); + return reactive.member; } function addLog( @@ -296,22 +539,48 @@ function initialState( gearProgress: GearProgress = createDefaultGearProgress(), requestedDifficultySlug: DifficultySlug = "initiate", seenBossIds: readonly BossId[] = [], + hockeyPvpMatch?: HockeyPvpMatchConfig, + requestedAbilityLoadout?: AbilityLoadout, ) { const difficultySlug = normalizeDifficultySlug(requestedDifficultySlug); const difficulty = DIFFICULTY_BY_SLUG[difficultySlug]; const bossIds = normalizeBossIds(requestedBossIds); + const activityMode = defaultGameplayActivity(runMode); + const hockeyLayout = activityMode !== "boss"; + const layout: EncounterLayout = hockeyLayout ? "hockey" : "standard"; const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss( bossId, index, bossIds.length, bossHealthMultiplier(round) * difficulty.healthMultiplier, + 0, + layout, )); const primary = encounterBosses[0]; - const gearModifiers = createEncounterGearModifiers(gearProgress, healerClassId); - const passiveInfusionId = passiveInfusionUnlocked(gearProgress) ? gearProgress[healerClassId].passiveInfusionId : null; + const normalizedExternalGear = isPvpRunMode(runMode) || runMode === "rpg-roguelike"; + const gearModifiers = normalizedExternalGear + ? createBaseEncounterGearModifiers() + : createEncounterGearModifiers(gearProgress, healerClassId); + const passiveInfusionId = !normalizedExternalGear && passiveInfusionUnlocked(gearProgress) + ? gearProgress[healerClassId].passiveInfusionId + : null; const runModifiers = compileRunModifiers(runBuffRanks, passiveInfusionId); - const draftBuffIds = runMode !== "encounter" ? selectRunBuffDraft(runBuffRanks, passiveInfusionId) : []; + const draftBuffIds = runMode === "roguelike" || runMode === "rogue-trials" + ? selectRunBuffDraft(runBuffRanks, passiveInfusionId) + : []; const party = applyGearHealth(freshParty(healerClassId, playerName), gearModifiers); + const opponentParty = applyGearHealth( + freshParty(healerClassId, hockeyPvpMatch?.opponentName ?? "CPU Willow"), + gearModifiers, + ); + const opponentBoss = createEncounterBoss( + primary.boss.id, + 0, + 1, + difficulty.healthMultiplier, + 0, + "hockey", + ); const maxMana = 100; return { bossId: primary.boss.id, @@ -319,15 +588,38 @@ function initialState( paused: false, pauseSelection: "resume" as const, healerClassId, + abilityLoadout: { ...(requestedAbilityLoadout ?? createClassAbilityLoadout(healerClassId)) }, playerName, phase: "briefing" as GamePhase, runMode, + activityMode, + rpgRun: null as RpgRoguelikeRunState | null, + rpgFocusId: null as string | null, + rpgDamageProfiles: null as RpgPartyDamageProfiles | null, + rpgSpellResources: { verdancy: 0, tidalSurge: 0, conviction: 0, chronoshards: 0 } as RpgSpellResources, round, seenBossIds: [...new Set([...seenBossIds, ...bossIds])], - endlessMode: false, + endlessMode: hockeyLayout, endlessBossKills: 0, - endlessSpawnSequence: 0, + endlessSpawnSequence: hockeyLayout ? encounterBosses.length : 0, endlessChoiceSelection: "continue" as const, + hockey: createHockeyHealingState(activityMode === "hockey-healing"), + blockbreaker: createBlockbreakerState( + activityMode === "blockbreaker", + activityMode === "blockbreaker" ? createBlockbreakerSeed() : 1, + ), + aetherAssault: createAetherAssaultState( + activityMode === "aether-assault", + activityMode === "aether-assault" ? createAetherAssaultSeed() : 1, + ), + hockeyPvp: createHockeyPvpState(activityMode === "hockey-healing-pvp" ? hockeyPvpMatch : undefined), + hockeyPvpOpponent: { + party: opponentParty, + partyPositions: freshPartyPositions([primary.boss.id], "hockey"), + boss: opponentBoss.boss, + bossMotion: opponentBoss.motion, + partyCombat: createPartyCombatState(opponentParty), + } as HockeyPvpOpponentState, runBuffRanks: { ...runBuffRanks }, draftBuffIds, selectedRunBuffId: draftBuffIds[0] ?? null, @@ -343,7 +635,7 @@ function initialState( party, boss: primary.boss, additionalBosses: encounterBosses.slice(1), - partyPositions: freshPartyPositions(bossIds), + partyPositions: freshPartyPositions(bossIds, layout), bossMotion: primary.motion, partyCombat: createPartyCombatState(party), partyDamageEvents: [] as PartyDamageEvent[], @@ -356,25 +648,190 @@ function initialState( selectedItemId: inventory[0]?.id ?? "", inventory: structuredClone(inventory), combatLog: [] as CombatLogEntry[], - scenePulse: { id: 0, kind: "mend" as const }, - playerPosition: [0, 4.5] as [number, number], + scenePulse: { id: 0, kind: "direct-heal" as const }, + playerPosition: (hockeyLayout ? [...HOCKEY_DEFAULT_PLAYER_POSITION] : [0, 4.5]) as [number, number], activeCast: null as ActiveCast | null, - barrier: { center: [0, 4.5], expiresAt: 0, nextHealAt: 0 } as BarrierState, + barrier: { kind: null, center: hockeyLayout ? [...HOCKEY_DEFAULT_PLAYER_POSITION] : [0, 4.5], expiresAt: 0, nextHealAt: 0 } as BarrierState, + healerMechanic: createHealerMechanicState(healerClassId), + }; +} + +function rpgActivityForRun(run: RpgRoguelikeRunState): GameplayActivity { + if (run.phase !== "challenge-active" && run.phase !== "challenge-briefing") return "boss"; + const challengeId = run.currentChallenge?.objective.challengeId; + if (challengeId === "blockbreaker") return "blockbreaker"; + if (challengeId === "hockey") return "hockey-healing"; + return "aether-assault"; +} + +function gamePhaseForRpgRun(run: RpgRoguelikeRunState): GamePhase { + if (run.phase === "challenge-active" || run.phase === "boss-combat" || run.phase === "boss-cleared") return "combat"; + if (run.phase === "reward" || run.phase === "shop") return "intermission"; + if (run.phase === "victory") return "victory"; + if (run.phase === "defeat") return "defeat"; + return "briefing"; +} + +function rpgRunSeed(): number { + // One non-deterministic seed is captured at run creation. Every subsequent + // offer, route, reward, and shop draw advances the serialized RNG state. + return Date.now() ^ Math.floor(Math.random() * 0x7fffffff); +} + +function rpgChallengeMetrics( + activityMode: GameplayActivity, + blockbreaker: BlockbreakerState, + endlessBossKills: number, + aetherAssault: AetherAssaultState, +): ChallengeMetrics { + return { + bricksBroken: activityMode === "blockbreaker" ? blockbreaker.bricksBroken : 0, + bossKills: activityMode === "hockey-healing" ? endlessBossKills : 0, + kills: activityMode === "aether-assault" ? aetherAssault.kills : 0, + }; +} + +function createRpgCombatState(state: GameState, run: RpgRoguelikeRunState): Partial { + const activityMode = rpgActivityForRun(run); + const challenge = run.phase === "challenge-active"; + const currentBossId = selectCurrentBossId(run) ?? state.boss.id; + const challengePartner = run.bossRoute[(run.bossIndex + 1) % run.bossRoute.length] ?? currentBossId; + const bossIds = challenge && challengePartner !== currentBossId + ? [currentBossId, challengePartner] + : [currentBossId]; + const layout: EncounterLayout = challenge ? "hockey" : "standard"; + const act = Math.floor(run.bossIndex / BOSSES_PER_ACT); + const healthMultiplier = (challenge ? 0.82 + act * 0.08 : 1 + run.bossIndex * 0.14) + * DIFFICULTY_BY_SLUG[state.difficultySlug].healthMultiplier; + const encounterBosses = bossIds.map((bossId, index) => createEncounterBoss( + bossId, + index, + bossIds.length, + healthMultiplier, + 0, + layout, + )); + const primary = encounterBosses[0]; + const projection = projectRpgCombat(run, { + healerClassId: state.healerClassId, + playerName: state.playerName, + }); + const healerRatio = Math.max(0, Math.min(1, run.playerHp / 100)); + const party = projection.party.map((member) => member.id === "aelia" + ? { ...member, hp: member.maxHp * healerRatio } + : member); + const deterministicSeed = (run.random.state ^ ((run.bossIndex + 1) * 0x9e3779b9)) >>> 0; + const maxMana = 100; + + return { + rpgRun: run, + rpgFocusId: normalizeRpgFocusId(run, state.rpgFocusId), + rpgDamageProfiles: projection.damageProfiles, + rpgSpellResources: { verdancy: 0, tidalSurge: 0, conviction: 0, chronoshards: 0 }, + abilityLoadout: { ...run.abilityLoadout }, + activityMode, + phase: "combat", + round: run.bossIndex + 1, + endlessMode: challenge, + endlessBossKills: 0, + endlessSpawnSequence: encounterBosses.length, + bossId: primary.boss.id, + bossInstanceId: `rpg-${challenge ? "challenge" : "boss"}-${run.bossIndex}-${primary.boss.id}`, + boss: primary.boss, + bossMotion: primary.motion, + additionalBosses: encounterBosses.slice(1).map((entry, index) => ({ + ...entry, + instanceId: `rpg-${challenge ? "challenge" : "boss"}-${run.bossIndex}-${index + 1}-${entry.boss.id}`, + })), + seenBossIds: [...new Set([...state.seenBossIds, ...bossIds])], + party, + gearModifiers: projection.gearModifiers, + healingMultiplier: projection.gearModifiers.aelia.healingPower, + partyCombat: createPartyCombatState(party), + partyDamageEvents: [], + partyPositions: freshPartyPositions(bossIds, layout), + playerPosition: challenge ? [...HOCKEY_DEFAULT_PLAYER_POSITION] : [0, 4.5], + hockey: createHockeyHealingState(activityMode === "hockey-healing"), + blockbreaker: createBlockbreakerState(activityMode === "blockbreaker", deterministicSeed || 1), + aetherAssault: createAetherAssaultState(activityMode === "aether-assault", deterministicSeed || 1), + mana: maxMana, + maxMana, + cooldowns: emptyCooldowns(), + globalCooldownUntil: 0, + activeCast: null, + activeTab: "combat", + selectedMemberId: party.find((member) => member.id === "brann" && member.hp > 0)?.id + ?? party.find((member) => member.id !== "aelia" && member.hp > 0)?.id + ?? party.find((member) => member.hp > 0)?.id + ?? "aelia", + barrier: { + kind: null, + center: challenge ? [...HOCKEY_DEFAULT_PLAYER_POSITION] : [0, 4.5], + expiresAt: 0, + nextHealAt: 0, + }, + healerMechanic: createHealerMechanicState(state.healerClassId), + time: 0, + combatLog: [{ + id: Date.now(), + time: 0, + message: challenge + ? `${run.currentChallenge?.objective.name ?? "Hallway challenge"} begins.` + : `${primary.boss.name} guards room ${run.bossIndex + 1}.`, + tone: challenge ? "neutral" : "danger", + }], }; } export const useGameStore = create((set, get) => ({ ...initialState(), - configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate") => { - set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug)); + configureHealer: (healerClassId, playerName, inventory, bossIds = "bulldrome", runMode = "encounter", gearProgress = createDefaultGearProgress(), difficultySlug = "initiate", hockeyPvpMatch) => { + const base = initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug, [], hockeyPvpMatch); + if (runMode !== "rpg-roguelike") { + set(base); + return; + } + const rpgRun = createRpgRoguelikeRun({ seed: rpgRunSeed() }); + set({ + ...base, + rpgRun, + rpgFocusId: normalizeRpgFocusId(rpgRun, null), + rpgDamageProfiles: null, + abilityLoadout: {}, + phase: "briefing", + activityMode: "boss", + endlessMode: false, + }); }, startEncounter: () => { - const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, round, runBuffRanks, gearProgress, difficultySlug, seenBossIds } = get(); + const current = get(); + if (current.runMode === "rpg-roguelike" && current.rpgRun) { + if (current.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" }); + else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" }); + else if (current.rpgRun.phase === "boss-cleared") current.dispatchRpgAction({ type: "reward-open" }); + return; + } + const { healerClassId, abilityLoadout, playerName, inventory, boss, additionalBosses, runMode, round, runBuffRanks, gearProgress, difficultySlug, seenBossIds, hockeyPvp } = get(); const bossIds = [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]; set({ - ...initialState(healerClassId, playerName, inventory, bossIds, runMode, round, runBuffRanks, gearProgress, difficultySlug, seenBossIds), + ...initialState( + healerClassId, + playerName, + inventory, + bossIds, + runMode, + round, + runBuffRanks, + gearProgress, + difficultySlug, + seenBossIds, + runMode === "hockey-healing-pvp" + ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role } + : undefined, + abilityLoadout, + ), phase: "combat", activeTab: "combat", combatLog: [{ id: Date.now(), time: 0, message: `${bossIds.map((bossId) => BOSS_DEFINITIONS[bossId].name).join(" and ")} engaged.`, tone: "danger" }], @@ -382,11 +839,52 @@ export const useGameStore = create((set, get) => ({ }, restart: () => { - const { healerClassId, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug } = get(); - const bossIds = runMode === "rogue-trials" + const { healerClassId, abilityLoadout, playerName, inventory, boss, additionalBosses, runMode, gearProgress, difficultySlug, hockeyPvp } = get(); + if (runMode === "rpg-roguelike") { + const base = initialState( + healerClassId, + playerName, + inventory, + boss.id, + runMode, + 1, + {}, + gearProgress, + difficultySlug, + [], + undefined, + {}, + ); + const rpgRun = createRpgRoguelikeRun({ seed: rpgRunSeed() }); + set({ + ...base, + rpgRun, + rpgFocusId: normalizeRpgFocusId(rpgRun, null), + abilityLoadout: {}, + }); + return; + } + const bossIds = runMode === "rogue-trials" || runMode === "hockey-healing" || runMode === "blockbreaker" || runMode === "aether-assault" ? selectRandomBossPair() + : runMode === "hockey-healing-pvp" + ? [hockeyPvpBossAt(hockeyPvp.seed, 0)] : [boss.id, ...additionalBosses.map((entry) => entry.boss.id)]; - set(initialState(healerClassId, playerName, inventory, bossIds, runMode, 1, {}, gearProgress, difficultySlug)); + set(initialState( + healerClassId, + playerName, + inventory, + bossIds, + runMode, + 1, + {}, + gearProgress, + difficultySlug, + [], + runMode === "hockey-healing-pvp" + ? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role } + : undefined, + abilityLoadout, + )); }, selectMember: (selectedMemberId) => set({ selectedMemberId }), @@ -421,7 +919,7 @@ export const useGameStore = create((set, get) => ({ const bossIds = state.runMode === "rogue-trials" ? selectRogueTrialsBosses(round, state.seenBossIds) : selectRandomBossPair(previousBossIds); - const abilityName = HEALER_CLASSES[state.healerClassId].abilities[RUN_BUFFS[buffId].abilityId].name; + const abilityName = HEALER_CLASSES[state.healerClassId].abilities[RUN_BUFFS[buffId].abilitySlotId].name; set({ ...initialState(state.healerClassId, state.playerName, state.inventory, bossIds, state.runMode, round, runBuffRanks, state.gearProgress, state.difficultySlug, state.seenBossIds), phase: "combat", @@ -496,8 +994,89 @@ export const useGameStore = create((set, get) => ({ return true; }, setEndlessChoiceSelection: (endlessChoiceSelection) => set({ endlessChoiceSelection }), + setAbilityLoadout: (abilityLoadout) => set({ abilityLoadout: { ...abilityLoadout }, cooldowns: emptyCooldowns(), activeCast: null }), + dispatchRpgAction: (action) => { + const state = get(); + if (state.runMode !== "rpg-roguelike" || !state.rpgRun) return false; + const actionRun = action.type === "reward-open" && state.rpgRun.phase === "boss-cleared" + ? reduceRpgRoguelikeRun(state.rpgRun, { + type: "party-vitals", + vitals: extractRpgPartyVitals(state.party, assignRosterToCombatSlots(state.rpgRun.roster)), + playerHp: extractRpgPlayerVital(state.party), + }) + : state.rpgRun; + const nextRun = reduceRpgRoguelikeRun(actionRun, action); + if (nextRun === state.rpgRun) return false; + + const enteringCombat = state.rpgRun.phase !== nextRun.phase + && (nextRun.phase === "challenge-active" || nextRun.phase === "boss-combat"); + if (enteringCombat) { + set(createRpgCombatState(state, nextRun)); + return true; + } + if (nextRun.phase === "challenge-active" || nextRun.phase === "boss-combat") { + set({ rpgRun: nextRun }); + return true; + } + + set({ + rpgRun: nextRun, + rpgFocusId: normalizeRpgFocusId(nextRun, state.rpgFocusId), + abilityLoadout: { ...nextRun.abilityLoadout }, + activityMode: rpgActivityForRun(nextRun), + phase: gamePhaseForRpgRun(nextRun), + endlessMode: false, + activeCast: null, + activeTab: "combat", + }); + return true; + }, + setRpgFocusId: (requestedFocusId) => set((state) => { + if (!state.rpgRun) return state; + const rpgFocusId = normalizeRpgFocusId(state.rpgRun, requestedFocusId); + return rpgFocusId === state.rpgFocusId ? state : { rpgFocusId }; + }), + cycleRpgFocus: (direction) => set((state) => { + if (!state.rpgRun) return state; + const items = rpgFocusItems(state.rpgRun); + if (!items.length) return state; + const currentIndex = items.findIndex((item) => item.id === state.rpgFocusId); + const startIndex = currentIndex >= 0 ? currentIndex : 0; + const rpgFocusId = items[(startIndex + direction + items.length) % items.length].id; + return rpgFocusId === state.rpgFocusId ? state : { rpgFocusId }; + }), + moveRpgFocus: (direction) => set((state) => { + if (!state.rpgRun) return state; + const rpgFocusId = moveRpgFocus(state.rpgRun, state.rpgFocusId, direction); + return rpgFocusId === state.rpgFocusId ? state : { rpgFocusId }; + }), setPlayerPosition: (playerPosition) => set((state) => { - playerPosition = clampToArena(playerPosition); + if (state.runMode === "rpg-roguelike" && state.activityMode === "boss" && state.rpgRun) { + const portals = state.rpgRun.phase === "boss-cleared" ? NORTH_OPEN_BOSS_ARENA_PORTALS : CLOSED_BOSS_ARENA_PORTALS; + playerPosition = clampToBossArenaWithPortals(playerPosition, portals); + if (state.rpgRun.phase === "boss-cleared" && detectBossArenaExit(playerPosition, portals) === "north") { + const syncedRun = reduceRpgRoguelikeRun(state.rpgRun, { + type: "party-vitals", + vitals: extractRpgPartyVitals(state.party, assignRosterToCombatSlots(state.rpgRun.roster)), + playerHp: extractRpgPlayerVital(state.party), + }); + const rpgRun = reduceRpgRoguelikeRun(syncedRun, { type: "reward-open" }); + return { + playerPosition, + partyPositions: { ...state.partyPositions, aelia: [...playerPosition] }, + rpgRun, + rpgFocusId: normalizeRpgFocusId(rpgRun, null), + phase: gamePhaseForRpgRun(rpgRun), + activeCast: null, + }; + } + } else { + playerPosition = state.activityMode === "aether-assault" + ? clampToHockeyArena(playerPosition, 0.65) + : state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker" + ? clampToHockeyHealerHalf(playerPosition, 0.65) + : clampToArena(playerPosition); + } const current = state.playerPosition; const partyCurrent = state.partyPositions.aelia; if (current[0] === playerPosition[0] @@ -511,40 +1090,133 @@ export const useGameStore = create((set, get) => ({ partyPositions: { ...state.partyPositions, aelia: [...playerPosition] }, }; }), + setHockeyAimDirection: (direction) => set((state) => { + if (state.activityMode === "hockey-healing-pvp") { + if (state.hockeyPvp.aimDirection[0] === direction[0] && state.hockeyPvp.aimDirection[1] === direction[1]) return state; + return { hockeyPvp: { ...state.hockeyPvp, aimDirection: [...direction] } }; + } + if (state.activityMode === "blockbreaker") { + const blockbreaker = setBlockbreakerAim(state.blockbreaker, direction); + return blockbreaker === state.blockbreaker ? state : { blockbreaker }; + } + if (state.activityMode !== "hockey-healing") return state; + const hockey = setHockeyAim(state.hockey, direction); + return hockey === state.hockey ? state : { hockey }; + }), + applyHockeyPvpRemoteSnapshot: (snapshot, hostPuck) => set((state) => { + if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return state; + const authoritativePuck = state.hockeyPvp.role === "guest" && hostPuck + ? mirrorHockeyPvpPuck(hostPuck) + : undefined; + const previousLocalGoals = state.hockeyPvp.localGoalsConceded; + const nextLocalGoals = authoritativePuck?.localGoalsConceded ?? previousLocalGoals; + const newGoals = Math.max(0, nextLocalGoals - previousLocalGoals); + let reactiveTriggers = 0; + const party = newGoals > 0 + ? state.party.map((member) => { + const damaged = damageMember(member, HOCKEY_PVP_GOAL_DAMAGE * newGoals); + const reactive = resolveReactiveHealAfterDamage(member, damaged, state.time, state.gearModifiers.aelia.healingPower); + if (reactive.triggered) reactiveTriggers += 1; + return reactive.member; + }) + : state.party; + const opponentParty = snapshot.party.map((member) => ({ ...member, debuffs: [...member.debuffs] })); + const opponentWiped = isPartyWiped(opponentParty); + const localWiped = isPartyWiped(party); + const phase = localWiped ? "defeat" : opponentWiped ? "victory" : state.phase; + return { + party, + healerMechanic: state.healerClassId === "shaman" && reactiveTriggers > 0 + ? { ...state.healerMechanic, resource: Math.min(state.healerMechanic.maxResource, state.healerMechanic.resource + reactiveTriggers) } + : state.healerMechanic, + phase, + hockeyPvp: { + ...state.hockeyPvp, + ...(authoritativePuck ?? {}), + opponentBossKills: snapshot.bossKills, + opponentPlayerPosition: [...snapshot.playerPosition], + opponentAimDirection: [...snapshot.aimDirection], + networkSequence: Math.max(state.hockeyPvp.networkSequence, snapshot.sequence), + appliedGoalSequence: authoritativePuck?.goalSequence ?? state.hockeyPvp.appliedGoalSequence, + status: localWiped ? "lost" : opponentWiped ? "won" : state.hockeyPvp.status, + }, + hockeyPvpOpponent: { + ...state.hockeyPvpOpponent, + party: opponentParty, + partyPositions: structuredClone(snapshot.partyPositions), + boss: { + ...state.hockeyPvpOpponent.boss, + ...snapshot.boss, + nextMeleeAt: state.hockeyPvpOpponent.boss.nextMeleeAt, + }, + bossMotion: { + ...state.hockeyPvpOpponent.bossMotion, + bossId: snapshot.boss.id, + position: [...snapshot.bossPosition], + mode: snapshot.bossMode, + }, + }, + }; + }), - castAbility: (abilityId) => { + castAbility: (abilitySlotId) => { const state = get(); if (state.phase !== "combat") return false; + if (state.rpgRun && state.rpgRun.phase !== "challenge-active" && state.rpgRun.phase !== "boss-combat") return false; if (state.paused) return false; if (state.activeCast) return false; const healer = state.party.find((member) => member.id === "aelia"); if (!healer || healer.hp <= 0) return false; - const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId]; - const manaCost = runAbilityManaCost(abilityId, ability.mana, state.runModifiers); + const ability = resolveSlottedAbility(state.abilityLoadout, abilitySlotId); + if (!ability) return false; + const spellPower = state.rpgRun + ? spellRankPowerMultiplier(state.rpgRun.spellRanks, ability.id) + : 1; + const manaCost = runAbilityManaCost(abilitySlotId, ability.mana, state.runModifiers); const selectedIndex = state.party.findIndex((member) => member.id === state.selectedMemberId); const selected = state.party[selectedIndex]; - if (state.cooldowns[abilityId] > state.time + 0.01) return false; + if (state.cooldowns[abilitySlotId] > state.time + 0.01) return false; if (state.globalCooldownUntil > state.time + 0.001) return false; if (state.mana < manaCost) { - set({ combatLog: addLog(state.combatLog, state.time, "Not enough mana.", "danger") }); + set({ combatLog: addLog(state.combatLog, state.time, `Not enough ${HEALER_CLASSES[state.healerClassId].resourceName.toLowerCase()}.`, "danger") }); return false; } if (ability.targeting === "ally" && (!selected || selected.hp <= 0)) return false; - if (abilityId === "purify" && selected.debuffs.length === 0) { + if (ability.targeting === "enemy" && state.boss.hp <= 0 && state.additionalBosses.every((entry) => entry.boss.hp <= 0)) return false; + if (ability.pulseKind === "cleanse" && selected.debuffs.length === 0) { set({ combatLog: addLog(state.combatLog, state.time, `${selected.name} has nothing to ${ability.name}.`) }); return false; } - if (abilityId === "mend") { + if (ability.castTime) { + let resourceSpent = 0; + let castTime = runAbilityCastTime(abilitySlotId, ability.castTime, state.runModifiers); + const rpgSpellResources = { ...state.rpgSpellResources }; + if (ability.id === "druid-regrowth") { + resourceSpent = Math.min(3, state.rpgRun ? rpgSpellResources.verdancy : state.healerMechanic.resource); + if (state.rpgRun) rpgSpellResources.verdancy -= resourceSpent; + } + const availableSurge = state.rpgRun ? rpgSpellResources.tidalSurge : state.healerMechanic.resource; + if (ability.id === "shaman-healing-wave" && availableSurge > 0) { + resourceSpent = 1; + castTime *= 0.5; + if (state.rpgRun) rpgSpellResources.tidalSurge -= 1; + } set({ activeCast: { - abilityId: "mend", + slotId: abilitySlotId, + abilityId: ability.id, targetId: selected.id, startedAt: state.time, - completesAt: state.time + runAbilityCastTime("mend", ability.castTime ?? 0.5, state.runModifiers), + completesAt: state.time + castTime, + resourceSpent, }, + healerMechanic: resourceSpent > 0 && !state.rpgRun + ? { ...state.healerMechanic, resource: state.healerMechanic.resource - resourceSpent } + : state.healerMechanic, + rpgSpellResources, mana: Math.max(0, state.mana - manaCost), globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS, combatLog: addLog(state.combatLog, state.time, `Casting ${ability.name} on ${selected.name}...`), @@ -552,29 +1224,43 @@ export const useGameStore = create((set, get) => ({ return true; } - let party = state.party.map((member) => ({ ...member, debuffs: [...member.debuffs] })); + let party = state.party.map((member) => ({ + ...member, + debuffs: [...member.debuffs], + healingEffects: member.healingEffects.map((effect) => ({ ...effect })), + reactiveHeal: member.reactiveHeal ? { ...member.reactiveHeal } : null, + })); let message = ability.name; + const healingMultiplier = effectiveHealingMultiplier(state) * spellPower; let barrier = state.barrier; + let boss = state.boss; let bossMotion = state.bossMotion; let additionalBosses = state.additionalBosses; + let healerMechanic = { ...state.healerMechanic }; + const rpgSpellResources = { ...state.rpgSpellResources }; + const cooldowns = { ...state.cooldowns }; - switch (abilityId) { - case "renew": - applyRenewAt(party, selectedIndex, state.time, state.runModifiers); + switch (ability.id) { + case "priest-renew": + applyRenewAt(party, selectedIndex, state.time, state.runModifiers, spellPower); for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.renewExtraTargets)) { - applyRenewAt(party, index, state.time, state.runModifiers); + applyRenewAt(party, index, state.time, state.runModifiers, spellPower); } message = `${ability.name} placed on ${selected.name}.`; break; - case "shield": { - const amount = addHealerAbsorb(party, selectedIndex, 36, state.gearModifiers.aelia.healingPower, state.runModifiers); + case "priest-aegis-shield": { + const amount = addHealerAbsorb(party, selectedIndex, 36, state.gearModifiers.aelia.healingPower * spellPower, state.runModifiers); for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.shieldExtraTargets)) { - addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower, state.runModifiers); + addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower * spellPower, state.runModifiers); } message = `${selected.name} gains ${Math.round(amount)} absorption.`; break; } - case "purify": { + case "priest-purify": + case "druid-natures-cure": + case "shaman-cleanse-spirit": + case "paladin-cleanse-light": + case "chronomancer-erase-affliction": { const cleanseIndexes = [ selectedIndex, ...lowestHealthIndexes(party, selectedIndex, state.runModifiers.purifyExtraTargets, (member) => member.debuffs.length > 0), @@ -590,55 +1276,262 @@ export const useGameStore = create((set, get) => ({ return { ...entry, motion: dispel.motion }; }); party[index] = { ...party[index], debuffs: [] }; - if (state.runModifiers.purifyAppliesRenew) applyRenewAt(party, index, state.time, state.runModifiers); - if (state.runModifiers.purifyAppliesShield) { - addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower, state.runModifiers); + if (ability.id === "druid-natures-cure") { + party[index] = triggerHealingEffects(party[index], healingMultiplier).member; + if (state.runModifiers.purifyAppliesRenew) applyRejuvenationAt(party, index, state.time, state.runModifiers, spellPower); + if (state.runModifiers.purifyAppliesShield) applyLifebloomAt(party, index, state.time, state.runModifiers, 1, spellPower); + } else if (ability.id === "shaman-cleanse-spirit") { + if (state.runModifiers.purifyAppliesRenew) applyRiptideAt(party, index, state.time, state.runModifiers, spellPower); + if (state.runModifiers.purifyAppliesShield) applyEarthShieldAt(party, index, state.time, state.runModifiers, 3, spellPower); + } else { + if (state.runModifiers.purifyAppliesRenew) applyRenewAt(party, index, state.time, state.runModifiers, spellPower); + if (state.runModifiers.purifyAppliesShield) { + addHealerAbsorb(party, index, 18, state.gearModifiers.aelia.healingPower * spellPower, state.runModifiers); + } } } + if (ability.id === "shaman-cleanse-spirit") { + if (state.rpgRun) rpgSpellResources.tidalSurge = Math.min(2, rpgSpellResources.tidalSurge + 1); + else healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + 1); + } else if (ability.id === "chronomancer-erase-affliction") { + if (state.rpgRun) rpgSpellResources.chronoshards = Math.min(3, rpgSpellResources.chronoshards + 1); + else healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + 1); + } else if (ability.id === "paladin-cleanse-light") { + const result = healTargetAndBeacon(party, selectedIndex, 10 * healingMultiplier, healerMechanic, state.time); + party = result.party; + } message = primaryNames.includes("Widow Venom") ? "Widow Venom purged. A venom pool forms where the target stood." : `${primaryNames.join(", ") || "Harmful magic"} removed from ${selected.name}.`; break; } - case "radiance": - party = party.map((member) => healMember(member, 22 * state.healingMultiplier)); + case "priest-radiance": + party = party.map((member) => healMember(member, 22 * healingMultiplier)); if (state.runModifiers.radianceAppliesRenew) { - for (let index = 0; index < party.length; index += 1) applyRenewAt(party, index, state.time, state.runModifiers); + for (let index = 0; index < party.length; index += 1) applyRenewAt(party, index, state.time, state.runModifiers, spellPower); } if (state.runModifiers.radianceAbsorb > 0) { for (let index = 0; index < party.length; index += 1) { - addHealerAbsorb(party, index, state.runModifiers.radianceAbsorb, state.gearModifiers.aelia.healingPower, state.runModifiers); + addHealerAbsorb(party, index, state.runModifiers.radianceAbsorb, state.gearModifiers.aelia.healingPower * spellPower, state.runModifiers); } } message = `${ability.name} heals the full party.`; break; - case "barrier": + case "priest-barrier": barrier = { + kind: "barrier", center: [...state.partyPositions.aelia], expiresAt: state.time + 8 + state.runModifiers.barrierDurationBonus, nextHealAt: state.time + 1, }; message = `${ability.name} protects a 3m circle for ${8 + state.runModifiers.barrierDurationBonus} seconds.`; break; + case "druid-rejuvenation": + applyRejuvenationAt(party, selectedIndex, state.time, state.runModifiers, spellPower); + for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.renewExtraTargets)) { + applyRejuvenationAt(party, index, state.time, state.runModifiers, spellPower); + } + message = `${ability.name} begins restoring ${selected.name}.`; + break; + case "druid-lifebloom": { + const current = healingEffect(party[selectedIndex], "lifebloom"); + const verdancy = state.rpgRun ? rpgSpellResources.verdancy : healerMechanic.resource; + if (current?.stacks === 3 && verdancy >= 2) { + const bloom = 10 * current.stacks * state.runModifiers.shieldAbsorbMultiplier * healingMultiplier; + party[selectedIndex] = healMember(removeHealingEffect(party[selectedIndex], "lifebloom"), bloom); + if (state.rpgRun) rpgSpellResources.verdancy -= 2; + else healerMechanic.resource -= 2; + message = `${ability.name} blooms on ${selected.name} for ${Math.round(bloom)}.`; + } else { + const stacks = applyLifebloomAt(party, selectedIndex, state.time, state.runModifiers, 1, spellPower); + for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.shieldExtraTargets)) { + applyLifebloomAt(party, index, state.time, state.runModifiers, 1, spellPower); + } + message = `${ability.name} grows to ${stacks} ${stacks === 1 ? "stack" : "stacks"} on ${selected.name}.`; + } + break; + } + case "druid-wild-growth": { + const targets = lowestHealthIndexes(party, -1, 3); + for (const index of targets) { + party[index] = setHealingEffect(party[index], { + id: "wild-growth", + expiresAt: state.time + 6, + nextTickAt: state.time + 1, + tickInterval: 1, + healingPerTick: 5 * spellPower, + stacks: 1, + }); + } + if (state.runModifiers.radianceAppliesRenew) { + for (let index = 0; index < party.length; index += 1) applyRejuvenationAt(party, index, state.time, state.runModifiers, spellPower); + } + if (state.runModifiers.radianceAbsorb > 0) { + party = party.map((member) => healMember(member, state.runModifiers.radianceAbsorb * healingMultiplier)); + } + message = `${ability.name} spreads to ${targets.length} injured allies.`; + break; + } + case "druid-flourish": + party = flourishParty(party, state.time, 5 + state.runModifiers.barrierDurationBonus); + healerMechanic.flourishExpiresAt = state.time + 6 + state.runModifiers.barrierDurationBonus; + healerMechanic.nextPulseAt = state.time + 1; + message = `${ability.name} accelerates and extends every active growth.`; + break; + case "shaman-riptide": + party[selectedIndex] = healMember(party[selectedIndex], 12 * healingMultiplier); + applyRiptideAt(party, selectedIndex, state.time, state.runModifiers, spellPower); + for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.renewExtraTargets)) { + party[index] = healMember(party[index], 6 * healingMultiplier); + applyRiptideAt(party, index, state.time, state.runModifiers, spellPower); + } + if (state.rpgRun) rpgSpellResources.tidalSurge = Math.min(2, rpgSpellResources.tidalSurge + 1); + else healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + 1); + message = `${ability.name} marks ${selected.name} as a chain anchor.`; + break; + case "shaman-earth-shield": + applyEarthShieldAt(party, selectedIndex, state.time, state.runModifiers, 6, spellPower); + for (const index of lowestHealthIndexes(party, selectedIndex, state.runModifiers.shieldExtraTargets)) { + applyEarthShieldAt(party, index, state.time, state.runModifiers, 3, spellPower); + } + message = `${selected.name} gains 6 ${ability.name} charges.`; + break; + case "shaman-chain-heal": { + const surgeSpent = state.rpgRun ? rpgSpellResources.tidalSurge : healerMechanic.resource; + if (state.rpgRun) rpgSpellResources.tidalSurge = 0; + else healerMechanic.resource = 0; + const targets = chainHealIndexes(party, state.partyPositions, selectedIndex, 3 + surgeSpent); + for (let order = 0; order < targets.length; order += 1) { + const index = targets[order]; + const riptideBonus = order === 0 && healingEffect(party[index], "riptide") ? 1.25 : 1; + party[index] = healMember(party[index], 30 * 0.75 ** order * riptideBonus * healingMultiplier); + } + if (state.runModifiers.radianceAppliesRenew) { + for (let index = 0; index < party.length; index += 1) applyRiptideAt(party, index, state.time, state.runModifiers, spellPower); + } + if (state.runModifiers.radianceAbsorb > 0) { + party = party.map((member) => healMember(member, state.runModifiers.radianceAbsorb * healingMultiplier)); + } + message = `${ability.name} reaches ${targets.length} ${targets.length === 1 ? "ally" : "allies"}${surgeSpent ? ` using ${surgeSpent} Tidal Surge` : ""}.`; + break; + } + case "shaman-spirit-link": + barrier = { + kind: "spirit-link", + center: [...state.partyPositions.aelia], + expiresAt: state.time + 8 + state.runModifiers.barrierDurationBonus, + nextHealAt: state.time + 1, + }; + message = `${ability.name} links allies inside a 3m circle.`; + break; + case "paladin-crusader-strike": { + const requestedDamage = 18 * spellPower; + let damage = 0; + if (boss.hp > 0) { + damage = Math.min(boss.hp, requestedDamage); + boss = { ...boss, hp: boss.hp - damage }; + } else { + const targetIndex = additionalBosses.findIndex((entry) => entry.boss.hp > 0); + const target = additionalBosses[targetIndex]; + if (target) { + damage = Math.min(target.boss.hp, requestedDamage); + additionalBosses = additionalBosses.map((entry, index) => index === targetIndex + ? { ...entry, boss: { ...entry.boss, hp: entry.boss.hp - damage } } + : entry); + } + } + const result = healMostInjured(party, 18 * healingMultiplier); + party = result.party; + if (state.rpgRun) rpgSpellResources.conviction = Math.min(3, rpgSpellResources.conviction + 1); + else healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + 1); + message = result.targetId + ? `${ability.name} deals ${Math.round(damage)} and restores ${Math.round(result.healing)} health.` + : `${ability.name} deals ${Math.round(damage)} damage.`; + break; + } + case "paladin-beacon-of-light": + healerMechanic.beaconTargetId = selected.id; + healerMechanic.beaconExpiresAt = state.time + 30; + message = `${selected.name} becomes your Beacon of Light.`; + break; + case "paladin-word-of-glory": { + const conviction = state.rpgRun ? rpgSpellResources.conviction : healerMechanic.resource; + if (state.rpgRun) rpgSpellResources.conviction = 0; + else healerMechanic.resource = 0; + const amount = (18 + conviction * 14) * healingMultiplier; + const result = healTargetAndBeacon(party, selectedIndex, amount, healerMechanic, state.time); + party = result.party; + if (conviction === 3) party = party.map((member) => healMember(member, 8 * healingMultiplier)); + message = `${ability.name} spends ${conviction} Conviction and restores ${Math.round(result.directHealing)} health.`; + break; + } + case "paladin-avenging-crusader": + healerMechanic.avengingCrusaderExpiresAt = state.time + 10 + state.runModifiers.barrierDurationBonus; + message = `${ability.name} converts party damage into healing for ${10 + state.runModifiers.barrierDurationBonus} seconds.`; + break; + case "chronomancer-time-anchor": { + const result = placeOrRewindTimeAnchor(party, selectedIndex, healerMechanic, state.time); + party = result.party; + healerMechanic = result.mechanic; + if (result.restored > 0) { + if (state.rpgRun) rpgSpellResources.chronoshards = Math.min(3, rpgSpellResources.chronoshards + 1); + else healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + 1); + } + message = result.anchored + ? `${selected.name}'s timeline is anchored for 6 seconds.` + : `${selected.name} rewinds ${Math.round(result.restored)} health.`; + break; + } + case "chronomancer-echo-of-tomorrow": + party[selectedIndex] = setHealingEffect(healMember(party[selectedIndex], 10 * healingMultiplier), { + id: "temporal-echo", + expiresAt: state.time + 3, + nextTickAt: state.time + 3, + tickInterval: 3, + healingPerTick: 28 * spellPower, + stacks: 1, + }); + message = `${selected.name} receives healing now and again in 3 seconds.`; + break; + case "chronomancer-accelerate": { + const chronoshards = state.rpgRun ? rpgSpellResources.chronoshards : healerMechanic.resource; + if (state.rpgRun) rpgSpellResources.chronoshards = 0; + else healerMechanic.resource = 0; + party = party.map((member) => healMember(member, (10 + chronoshards * 8) * healingMultiplier)); + if (chronoshards > 0) { + for (const slotId of Object.keys(cooldowns) as AbilitySlotId[]) { + cooldowns[slotId] = Math.max(state.time, cooldowns[slotId] - chronoshards); + } + } + message = `${ability.name} spends ${chronoshards} Chronoshards and advances active cooldowns.`; + break; + } + case "chronomancer-time-loop": + healerMechanic = startTimeLoop(party, healerMechanic, state.time + 6); + message = `${ability.name} records every living ally for 6 seconds.`; + break; } - const cooldowns = { - ...state.cooldowns, - [abilityId]: ability.cooldown > 0 - ? state.time + runAbilityCooldown(abilityId, ability.cooldown, state.runModifiers) * state.gearModifiers.aelia.cooldown - : 0, - }; + cooldowns[abilitySlotId] = ability.cooldown > 0 + ? state.time + + runAbilityCooldown(abilitySlotId, ability.cooldown, state.runModifiers) + * (state.rpgRun ? spellRankCooldownMultiplier(state.rpgRun.spellRanks, ability.id) : 1) + * state.gearModifiers.aelia.cooldown + : 0; const pulse: ScenePulse = { id: state.scenePulse.id + 1, - kind: abilityId, - targetId: abilityId === "barrier" ? "aelia" : selected?.id, + kind: ability.pulseKind, + targetId: ability.targeting === "party" ? "aelia" : ability.targeting === "ally" ? selected?.id : undefined, }; set({ party, + boss, cooldowns, globalCooldownUntil: state.time + GLOBAL_COOLDOWN_SECONDS, barrier, + healerMechanic, + rpgSpellResources, bossMotion, additionalBosses, mana: Math.max(0, state.mana - manaCost), @@ -654,9 +1547,21 @@ export const useGameStore = create((set, get) => ({ const oldTime = state.time; const time = oldTime + Math.min(delta, 2); + const healingMultiplier = effectiveHealingMultiplier(state); + let reactiveResourceGains = 0; + const damageLocalMember = (member: PartyMember, amount: number, at = time) => { + const damaged = damageMember(member, amount); + const reactive = resolveReactiveHealAfterDamage(member, damaged, at, state.gearModifiers.aelia.healingPower); + if (reactive.triggered) reactiveResourceGains += 1; + return reactive.member; + }; // Debuffs are copied only by the periodic-debuff pass below. Copying them here // as well doubled short-lived allocations for every simulation step. - let party = state.party.map((member) => ({ ...member })); + let party = state.party.map((member) => ({ + ...member, + healingEffects: member.healingEffects.map((effect) => ({ ...effect })), + reactiveHeal: member.reactiveHeal ? { ...member.reactiveHeal } : null, + })); let boss = { ...state.boss }; let bossInstanceId = state.bossInstanceId; let bossMotion = { ...state.bossMotion }; @@ -669,15 +1574,146 @@ export const useGameStore = create((set, get) => ({ let combatLog = state.combatLog; let pulse = state.scenePulse; let activeCast = state.activeCast ? { ...state.activeCast } : null; + let healerMechanic = { ...state.healerMechanic }; + let rpgSpellResources = { ...state.rpgSpellResources }; let partyCombat = state.partyCombat; let partyDamageEvents = state.partyDamageEvents; let barrier = { ...state.barrier }; + let rpgRun = state.rpgRun; + let rpgFocusId = state.rpgFocusId; + let activityMode = state.activityMode; + let endlessMode = state.endlessMode; let endlessBossKills = state.endlessBossKills; let endlessSpawnSequence = state.endlessSpawnSequence; + let hockeyPvp = { ...state.hockeyPvp }; + let hockeyPvpOpponent: HockeyPvpOpponentState = { + party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, debuffs: [...member.debuffs] })), + partyPositions: structuredClone(state.hockeyPvpOpponent.partyPositions), + boss: { ...state.hockeyPvpOpponent.boss }, + bossMotion: cloneMotion(state.hockeyPvpOpponent.bossMotion), + partyCombat: state.hockeyPvpOpponent.partyCombat, + }; + let hockey = state.activityMode === "hockey-healing" + ? advanceHockeyHealing(state.hockey, { + delta: time - oldTime, + time, + playerPosition: state.partyPositions.aelia, + }) + : state.hockey; + let blockbreaker = state.activityMode === "blockbreaker" + ? advanceBlockbreaker(state.blockbreaker, { + delta: time - oldTime, + time, + playerPosition: state.partyPositions.aelia, + }) + : state.blockbreaker; + const aetherAdvance = state.activityMode === "aether-assault" + ? advanceAetherAssault(state.aetherAssault, { + delta: time - oldTime, + time, + playerPosition: state.partyPositions.aelia, + canAutoFire: party[0].hp > 0 && party[0].knockedUntil <= time, + }) + : { state: state.aetherAssault, playerDamage: 0 }; + let aetherAssault = aetherAdvance.state; + if (aetherAdvance.playerDamage > 0 && party[0].hp > 0) { + party[0] = damageMemberAt( + party[0], + aetherAdvance.playerDamage, + state.partyPositions.aelia, + barrier, + time, + partyCombat, + state.partyPositions[partyCombat.tankAura.sourceId], + state.difficultyDamageMultiplier, + state.gearModifiers, + "hazard", + state.runModifiers.shieldDamageTakenMultiplier, + () => { reactiveResourceGains += 1; }, + ); + combatLog = addLog(combatLog, time, `Aether strike hits ${state.playerName} for ${aetherAdvance.playerDamage} base damage.`, "danger"); + } + const blockbreakerBreachEvents = state.activityMode === "blockbreaker" + ? Math.max(0, blockbreaker.breaches - state.blockbreaker.breaches) + : 0; + if (blockbreakerBreachEvents > 0) { + const breachDamage = BLOCKBREAKER_BREACH_DAMAGE * blockbreakerBreachEvents; + party = party.map((member) => damageLocalMember(member, breachDamage)); + combatLog = addLog( + combatLog, + time, + `Brick breach — ${breachDamage} damage to every party member.`, + "danger", + ); + } + + if (state.activityMode === "hockey-healing-pvp") { + if (hockeyPvp.role === "cpu") { + const goalie = advanceHockeyPvpCpuGoalie( + hockeyPvp.opponentPlayerPosition, + hockeyPvp.puckPosition, + time - oldTime, + ); + hockeyPvp.opponentPlayerPosition = goalie.position; + hockeyPvp.opponentAimDirection = goalie.aimDirection; + hockeyPvpOpponent.partyPositions.aelia = [...goalie.position]; + } + hockeyPvp = advanceHockeyPvpPuck(hockeyPvp, { + delta: time - oldTime, + localPlayerPosition: state.partyPositions.aelia, + localAimDirection: hockeyPvp.aimDirection, + opponentPlayerPosition: hockeyPvp.opponentPlayerPosition, + opponentAimDirection: hockeyPvp.opponentAimDirection, + }); + const localGoals = Math.max(0, hockeyPvp.localGoalsConceded - state.hockeyPvp.localGoalsConceded); + if (localGoals > 0) { + party = party.map((member) => damageLocalMember(member, HOCKEY_PVP_GOAL_DAMAGE * localGoals)); + combatLog = addLog(combatLog, time, `Goal conceded — ${HOCKEY_PVP_GOAL_DAMAGE} partywide damage.`, "danger"); + } + const opponentGoals = Math.max(0, hockeyPvp.opponentGoalsConceded - state.hockeyPvp.opponentGoalsConceded); + if (opponentGoals > 0 && hockeyPvp.role === "cpu") { + hockeyPvpOpponent.party = hockeyPvpOpponent.party.map((member) => damageMember(member, HOCKEY_PVP_GOAL_DAMAGE * opponentGoals)); + combatLog = addLog(combatLog, time, `Goal scored — rival party takes ${HOCKEY_PVP_GOAL_DAMAGE} damage.`, "good"); + } + hockeyPvp.appliedGoalSequence = hockeyPvp.goalSequence; + } if (!party.some((member) => member.id === "aelia" && member.hp > 0)) activeCast = null; - if (state.endlessMode) { + if (state.activityMode === "hockey-healing-pvp" && boss.hp <= 0) { + const replacementId = hockeyPvpBossAt(hockeyPvp.seed, endlessBossKills); + endlessSpawnSequence += 1; + const replacement = createEncounterBoss( + replacementId, + 0, + 1, + DIFFICULTY_BY_SLUG[state.difficultySlug].healthMultiplier, + time, + "hockey", + ); + boss = replacement.boss; + bossMotion = replacement.motion; + bossInstanceId = `hockey-pvp-${endlessSpawnSequence}-${replacementId}`; + combatLog = addLog(combatLog, time, `${replacement.boss.name} enters both boss lanes.`, "danger"); + } + + if (state.activityMode === "hockey-healing-pvp" + && hockeyPvp.role === "cpu" + && hockeyPvpOpponent.boss.hp <= 0) { + const replacementId = hockeyPvpBossAt(hockeyPvp.seed, hockeyPvp.opponentBossKills); + const replacement = createEncounterBoss( + replacementId, + 0, + 1, + DIFFICULTY_BY_SLUG[state.difficultySlug].healthMultiplier, + time, + "hockey", + ); + hockeyPvpOpponent.boss = replacement.boss; + hockeyPvpOpponent.bossMotion = replacement.motion; + } + + if (state.endlessMode && state.runMode !== "hockey-healing-pvp") { const slots: AdditionalBossState[] = [ { instanceId: bossInstanceId, boss, motion: bossMotion }, ...additionalBosses, @@ -686,21 +1722,34 @@ export const useGameStore = create((set, get) => ({ if (slots[index].boss.hp > 0) continue; const defeatedAt = slots[index].boss.defeatedAt ?? oldTime; slots[index].boss.defeatedAt = defeatedAt; - if (time < defeatedAt + BOSS_DEATH_DESPAWN_SECONDS) continue; + if (time < defeatedAt + bossDeathDespawnSeconds(slots[index].boss.id)) continue; const activeBossIds = slots .filter((entry, slotIndex) => slotIndex !== index && entry.boss.hp > 0) .map((entry) => entry.boss.id); - const replacementId = selectUnseenBosses(1, [slots[index].boss.id, ...activeBossIds], Math.random, activeBossIds)[0]; + const replacementId = state.runMode === "rpg-roguelike" && rpgRun + ? rpgRun.bossRoute[(rpgRun.bossIndex + endlessSpawnSequence + index + 1) % rpgRun.bossRoute.length] + : selectUnseenBosses(1, [slots[index].boss.id, ...activeBossIds], Math.random, activeBossIds)[0]; endlessSpawnSequence += 1; const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug]; + const replacementHealthMultiplier = state.activityMode === "hockey-healing" || state.activityMode === "blockbreaker" || state.activityMode === "aether-assault" + ? difficulty.healthMultiplier + : bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier; const replacement = createEncounterBoss( replacementId, index, slots.length, - bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier, + replacementHealthMultiplier, time, + state.activityMode === "hockey-healing" || state.activityMode === "blockbreaker" || state.activityMode === "aether-assault" ? "hockey" : "standard", ); - slots[index] = { ...replacement, instanceId: `endless-${endlessSpawnSequence}-${replacementId}` }; + const instancePrefix = state.activityMode === "hockey-healing" + ? "hockey" + : state.activityMode === "blockbreaker" + ? "blockbreaker" + : state.activityMode === "aether-assault" + ? "aether-assault" + : "endless"; + slots[index] = { ...replacement, instanceId: `${instancePrefix}-${endlessSpawnSequence}-${replacementId}` }; combatLog = addLog(combatLog, time, `${replacement.boss.name} replaces the fallen boss.`, "danger"); } boss = slots[0].boss; @@ -709,43 +1758,87 @@ export const useGameStore = create((set, get) => ({ additionalBosses = slots.slice(1); } + healerMechanic = expireHealerMechanics(healerMechanic, time); + const timeLoopResult = resolveTimeLoop(party, healerMechanic, time); + party = timeLoopResult.party; + healerMechanic = timeLoopResult.mechanic; + if (timeLoopResult.restored > 0) { + combatLog = addLog(combatLog, time, `Time Loop restores ${Math.round(timeLoopResult.restored)} party health.`, "good"); + pulse = { id: pulse.id + 1, kind: "group-heal", targetId: "aelia" }; + } + if (activeCast && activeCast.completesAt <= time) { const targetIndex = party.findIndex((member) => member.id === activeCast!.targetId); const target = party[targetIndex]; if (target?.hp > 0) { - const healing = 38 * state.healingMultiplier; - party[targetIndex] = healMember(target, healing); + const castSpellPower = rpgRun + ? spellRankPowerMultiplier(rpgRun.spellRanks, activeCast.abilityId) + : 1; + let healing = 0; + if (activeCast.abilityId === "priest-mend") { + healing = 38 * healingMultiplier * castSpellPower; + party[targetIndex] = healMember(target, healing); + } else if (activeCast.abilityId === "druid-regrowth") { + healing = (24 + activeCast.resourceSpent * 7) * healingMultiplier * castSpellPower; + party[targetIndex] = setHealingEffect(healMember(target, healing), { + id: "regrowth", + expiresAt: activeCast.completesAt + 6, + nextTickAt: activeCast.completesAt + 1, + tickInterval: 1, + healingPerTick: 4 * castSpellPower, + stacks: 1, + }); + } else if (activeCast.abilityId === "shaman-healing-wave") { + const lowHealthBonus = target.hp / target.maxHp < 0.5 ? 1.35 : 1; + healing = (34 * lowHealthBonus + activeCast.resourceSpent * 10) * healingMultiplier * castSpellPower; + party[targetIndex] = healMember(target, healing); + } else if (activeCast.abilityId === "paladin-holy-light") { + healing = 34 * healingMultiplier * castSpellPower; + party = healTargetAndBeacon(party, targetIndex, healing, healerMechanic, activeCast.completesAt).party; + } else if (activeCast.abilityId === "chronomancer-mend-timeline") { + healing = 30 * healingMultiplier * castSpellPower; + party[targetIndex] = healMember(target, healing); + } for (const index of lowestHealthIndexes(party, targetIndex, state.runModifiers.mendExtraTargets)) { party[index] = healMember(party[index], healing * 0.5); + if (activeCast.abilityId === "druid-regrowth") { + party[index] = setHealingEffect(party[index], { + id: "regrowth", + expiresAt: activeCast.completesAt + 6, + nextTickAt: activeCast.completesAt + 1, + tickInterval: 1, + healingPerTick: 2 * castSpellPower, + stacks: 1, + }); + } } - const abilityName = HEALER_CLASSES[state.healerClassId].abilities.mend.name; + const ability = HEALER_ABILITIES[activeCast.abilityId]; + const abilityName = ability.name; combatLog = addLog(combatLog, activeCast.completesAt, `${abilityName} restores ${target.name} for ${Math.round(healing)}.`, "good"); - pulse = { id: pulse.id + 1, kind: "mend", targetId: target.id }; + pulse = { id: pulse.id + 1, kind: ability.pulseKind, targetId: target.id }; } activeCast = null; } party = party.map((member) => { - let next = member; - if (next.renewExpiresAt > oldTime && next.renewNextTickAt <= time) { - let tickAt = next.renewNextTickAt; - const lastTickAt = Math.min(time, next.renewExpiresAt); - while (tickAt <= lastTickAt + 0.001) { - next = healMember(next, 7 * state.healingMultiplier * state.runModifiers.renewHealingMultiplier); - tickAt += 1; - } - next = { - ...next, - renewExpiresAt: time >= next.renewExpiresAt ? 0 : next.renewExpiresAt, - renewNextTickAt: time >= next.renewExpiresAt ? 0 : tickAt, - }; + const advanced = advanceHealingEffects(member, oldTime, time, healingMultiplier, healerMechanic.flourishExpiresAt); + let next = advanced.member; + if (rpgRun) { + const verdancyTicks = (advanced.tickCounts.regrowth ?? 0) + + (advanced.tickCounts.rejuvenation ?? 0) + + (advanced.tickCounts.lifebloom ?? 0) + + (advanced.tickCounts["wild-growth"] ?? 0); + if (verdancyTicks > 0) rpgSpellResources.verdancy = Math.min(5, rpgSpellResources.verdancy + verdancyTicks); + } else if (state.healerClassId === "druid" && advanced.tickCount > 0) { + healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + advanced.tickCount); } + if (next.reactiveHeal && next.reactiveHeal.expiresAt <= time) next = { ...next, reactiveHeal: null }; const activeDebuffs = next.debuffs .map((debuff) => { let updated = { ...debuff }; while (updated.nextTickAt <= time && updated.nextTickAt < updated.expiresAt) { - next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, "direct", state.runModifiers.shieldDamageTakenMultiplier); + next = damageMemberAt(next, updated.tickDamage, state.partyPositions[next.id], barrier, updated.nextTickAt, partyCombat, state.partyPositions[partyCombat.tankAura.sourceId], state.difficultyDamageMultiplier, state.gearModifiers, "direct", state.runModifiers.shieldDamageTakenMultiplier, () => { reactiveResourceGains += 1; }); updated.nextTickAt += 1; } return updated; @@ -754,17 +1847,30 @@ export const useGameStore = create((set, get) => ({ return { ...next, debuffs: activeDebuffs }; }); - if (state.runModifiers.barrierHealingPerSecond > 0) { + if (barrier.kind && barrier.expiresAt > oldTime) { while (barrier.nextHealAt <= time && barrier.nextHealAt < barrier.expiresAt) { const pulseAt = barrier.nextHealAt; - const healing = state.runModifiers.barrierHealingPerSecond * state.healingMultiplier; - party = party.map((member) => barrierProtects(state.partyPositions[member.id], barrier, pulseAt) - ? healMember(member, healing) - : member); + if (barrier.kind === "spirit-link") { + party = equalizeHealthPercentages(party, state.partyPositions, barrier.center, BARRIER_RADIUS); + } + if (state.runModifiers.barrierHealingPerSecond > 0) { + const healing = state.runModifiers.barrierHealingPerSecond * healingMultiplier; + party = party.map((member) => distance(state.partyPositions[member.id], barrier.center) <= BARRIER_RADIUS + ? healMember(member, healing) + : member); + } barrier.nextHealAt += 1; } } + if (state.healerClassId === "druid" && healerMechanic.flourishExpiresAt > oldTime + && state.runModifiers.barrierHealingPerSecond > 0) { + while (healerMechanic.nextPulseAt <= time && healerMechanic.nextPulseAt < healerMechanic.flourishExpiresAt) { + party = party.map((member) => healMember(member, state.runModifiers.barrierHealingPerSecond * healingMultiplier)); + healerMechanic.nextPulseAt += 1; + } + } + const livingMotions = [ ...(boss.hp > 0 ? [bossMotion] : []), ...additionalBosses.filter((entry) => entry.boss.hp > 0).map((entry) => entry.motion), @@ -775,6 +1881,23 @@ export const useGameStore = create((set, get) => ({ orin: state.gearModifiers.orin.moveSpeed, vale: state.gearModifiers.vale.moveSpeed, }); + if (state.activityMode === "aether-assault") { + partyPositions = { + aelia: clampToHockeyArena(partyPositions.aelia, 0.65), + brann: clampToHockeyEnemyHalf(partyPositions.brann, 0.55), + nia: clampToHockeyEnemyHalf(partyPositions.nia, 0.55), + orin: clampToHockeyEnemyHalf(partyPositions.orin, 0.55), + vale: clampToHockeyEnemyHalf(partyPositions.vale, 0.55), + }; + } else if (state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker") { + partyPositions = { + aelia: clampToHockeyHealerHalf(partyPositions.aelia, 0.65), + brann: clampToHockeyEnemyHalf(partyPositions.brann, 0.55), + nia: clampToHockeyEnemyHalf(partyPositions.nia, 0.55), + orin: clampToHockeyEnemyHalf(partyPositions.orin, 0.55), + vale: clampToHockeyEnemyHalf(partyPositions.vale, 0.55), + }; + } const encounterBosses: AdditionalBossState[] = [ { instanceId: bossInstanceId, boss, motion: bossMotion }, @@ -785,13 +1908,20 @@ export const useGameStore = create((set, get) => ({ if (encounterBoss.boss.hp <= 0) continue; const partyBeforeMechanic = party; const mechanicResult = advanceBossMechanics({ + arenaLayout: state.activityMode === "aether-assault" + ? "aether-assault" + : state.activityMode === "hockey-healing" || state.activityMode === "blockbreaker" + ? "hockey-healing" + : state.activityMode === "hockey-healing-pvp" + ? "hockey-healing-pvp" + : "standard", boss: encounterBoss.boss, motion: encounterBoss.motion, party, partyPositions, time, delta: time - oldTime, - damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind, state.runModifiers.shieldDamageTakenMultiplier), + damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions[partyCombat.tankAura.sourceId], state.difficultyDamageMultiplier, state.gearModifiers, kind, state.runModifiers.shieldDamageTakenMultiplier, () => { reactiveResourceGains += 1; }), }); encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) }; party = mechanicResult.party.map((member) => { @@ -819,12 +1949,17 @@ export const useGameStore = create((set, get) => ({ targets: encounterBosses, upcomingMechanicRemaining: mechanicRemaining.length ? Math.min(...mechanicRemaining) : Number.POSITIVE_INFINITY, gearModifiers: aiCombatModifiers(state.gearModifiers), + damageProfiles: state.rpgDamageProfiles ?? undefined, }); partyCombat = partyCombatResult.state; partyDamageEvents = [...partyCombatResult.events].reverse().concat(partyDamageEvents).slice(0, 24); for (const event of partyCombatResult.events) { const target = encounterBosses.find((entry) => entry.instanceId === event.targetInstanceId); if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount); + if (healerMechanic.avengingCrusaderExpiresAt > event.at) { + const crusaderHeal = healMostInjured(party, event.amount * 0.2 * healingMultiplier); + party = crusaderHeal.party; + } } for (const entry of encounterBosses) { if (entry.boss.hp <= 0 && entry.boss.defeatedAt === undefined) entry.boss.defeatedAt = time; @@ -833,9 +1968,115 @@ export const useGameStore = create((set, get) => ({ bossInstanceId = encounterBosses[0].instanceId; bossMotion = encounterBosses[0].motion; additionalBosses = encounterBosses.slice(1); + + if (state.activityMode === "hockey-healing-pvp" && hockeyPvp.role === "cpu") { + while (hockeyPvp.nextCpuHealAt <= time) { + const living = hockeyPvpOpponent.party.filter((member) => member.hp > 0); + const lowest = living.reduce((current, member) => + !current || member.hp / member.maxHp < current.hp / current.maxHp ? member : current, null); + if (lowest) { + const averageHealth = living.reduce((total, member) => total + member.hp / member.maxHp, 0) / Math.max(1, living.length); + hockeyPvpOpponent.party = hockeyPvpOpponent.party.map((member) => member.hp <= 0 + ? member + : member.id === lowest.id + ? healMember(member, (averageHealth < 0.55 ? 42 : 32) * healingMultiplier) + : averageHealth < 0.55 + ? healMember(member, 12 * healingMultiplier) + : member); + } + hockeyPvp.nextCpuHealAt += 1.1; + } + + hockeyPvpOpponent.partyPositions = updatePartyPositions( + hockeyPvpOpponent.partyPositions, + hockeyPvpOpponent.boss.hp > 0 ? [hockeyPvpOpponent.bossMotion] : [], + hockeyPvpOpponent.party, + time, + time - oldTime, + undefined, + { + brann: state.gearModifiers.brann.moveSpeed, + nia: state.gearModifiers.nia.moveSpeed, + orin: state.gearModifiers.orin.moveSpeed, + vale: state.gearModifiers.vale.moveSpeed, + }, + ); + hockeyPvpOpponent.partyPositions = { + aelia: clampToHockeyHealerHalf(hockeyPvp.opponentPlayerPosition, 0.65), + brann: clampToHockeyEnemyHalf(hockeyPvpOpponent.partyPositions.brann, 0.55), + nia: clampToHockeyEnemyHalf(hockeyPvpOpponent.partyPositions.nia, 0.55), + orin: clampToHockeyEnemyHalf(hockeyPvpOpponent.partyPositions.orin, 0.55), + vale: clampToHockeyEnemyHalf(hockeyPvpOpponent.partyPositions.vale, 0.55), + }; + + if (hockeyPvpOpponent.boss.hp > 0) { + const opponentBeforeMechanic = hockeyPvpOpponent.party; + const opponentMechanic = advanceBossMechanics({ + arenaLayout: "hockey-healing-pvp", + boss: hockeyPvpOpponent.boss, + motion: hockeyPvpOpponent.bossMotion, + party: hockeyPvpOpponent.party, + partyPositions: hockeyPvpOpponent.partyPositions, + time, + delta: time - oldTime, + damageMember: (member, amount, position, at, kind) => damageMemberAt( + member, + amount, + position, + { kind: null, center: [0, 0], expiresAt: 0, nextHealAt: 0 }, + at, + hockeyPvpOpponent.partyCombat, + hockeyPvpOpponent.partyPositions[hockeyPvpOpponent.partyCombat.tankAura.sourceId], + state.difficultyDamageMultiplier, + state.gearModifiers, + kind, + state.runModifiers.shieldDamageTakenMultiplier, + ), + }); + hockeyPvpOpponent.boss = opponentMechanic.boss; + hockeyPvpOpponent.bossMotion = constrainBossMotion(opponentMechanic.motion); + hockeyPvpOpponent.party = opponentMechanic.party.map((member) => { + const previous = opponentBeforeMechanic.find((candidate) => candidate.id === member.id); + if (!previous || member.knockedUntil <= previous.knockedUntil || member.knockedUntil <= time) return member; + return { ...member, knockedUntil: time + (member.knockedUntil - time) * state.gearModifiers[member.id].stunDuration }; + }); + + const opponentCombat = advancePartyCombat(hockeyPvpOpponent.partyCombat, { + oldTime, + time, + party: hockeyPvpOpponent.party, + oldPositions: state.hockeyPvpOpponent.partyPositions, + positions: hockeyPvpOpponent.partyPositions, + targets: [{ instanceId: `hockey-pvp-opponent-${hockeyPvp.opponentBossKills}`, boss: hockeyPvpOpponent.boss, motion: hockeyPvpOpponent.bossMotion }], + upcomingMechanicRemaining: upcomingMechanic(hockeyPvpOpponent.boss, hockeyPvpOpponent.bossMotion, time).remaining, + gearModifiers: aiCombatModifiers(state.gearModifiers), + }); + hockeyPvpOpponent.partyCombat = opponentCombat.state; + for (const event of opponentCombat.events) { + hockeyPvpOpponent.boss.hp = Math.max(0, hockeyPvpOpponent.boss.hp - event.amount); + } + if (hockeyPvpOpponent.boss.hp <= 0 && state.hockeyPvpOpponent.boss.hp > 0) { + hockeyPvpOpponent.boss.defeatedAt = time; + hockeyPvp.opponentBossKills += 1; + } + } + } + const healer = party.find((member) => member.id === "aelia")!; - if (healer.hp <= 0) activeCast = null; + const healerDefeated = healer.hp <= 0; + if (healerDefeated) activeCast = null; const partyWiped = isPartyWiped(party); + const allCompanionsDefeated = areAllNonHealerAlliesDefeated(party); + const blockbreakerAlliesDefeated = state.activityMode === "blockbreaker" && allCompanionsDefeated; + if (blockbreakerAlliesDefeated && blockbreaker.status !== "lost") { + blockbreaker = { + ...blockbreaker, + status: "lost", + puckVelocity: [0, 0], + reServeAt: null, + lostAt: time, + }; + } let phase: GamePhase = state.phase; let runBuffInputUnlockAt = state.runBuffInputUnlockAt; let newlyDefeatedBossCount = 0; @@ -847,13 +2088,129 @@ export const useGameStore = create((set, get) => ({ : state.additionalBosses[index - 1]; if (current.boss.hp > 0 || previous?.instanceId !== current.instanceId || previous.boss.hp <= 0) continue; newlyDefeatedBossCount += 1; - combatLog = addLog(combatLog, time, `${current.boss.name} falls. Endless kill ${endlessBossKills + newlyDefeatedBossCount}.`, "good"); + const killLabel = state.activityMode === "hockey-healing-pvp" + ? "PVP boss kill" + : state.activityMode === "hockey-healing" + ? "Hockey kill" + : state.activityMode === "blockbreaker" + ? "Blockbreaker kill" + : state.activityMode === "aether-assault" + ? "Aether Assault kill" + : "Endless kill"; + combatLog = addLog(combatLog, time, `${current.boss.name} falls. ${killLabel} ${endlessBossKills + newlyDefeatedBossCount}.`, "good"); } } endlessBossKills += newlyDefeatedBossCount; - if (state.endlessMode && partyWiped) { + const hockeyLost = state.activityMode === "hockey-healing" && hockey.status === "lost"; + const blockbreakerLost = state.activityMode === "blockbreaker" && blockbreaker.status === "lost"; + const pvpMode = state.activityMode === "hockey-healing-pvp"; + const opponentWiped = pvpMode && isPartyWiped(hockeyPvpOpponent.party); + const rpgChallengeActive = rpgRun?.phase === "challenge-active"; + const rpgBossActive = rpgRun?.phase === "boss-combat"; + if (rpgChallengeActive && rpgRun) { + const metrics = rpgChallengeMetrics(state.activityMode, blockbreaker, endlessBossKills, aetherAssault); + const previousMetrics = rpgRun.currentChallenge?.metrics; + if (!previousMetrics + || previousMetrics.bricksBroken !== metrics.bricksBroken + || previousMetrics.bossKills !== metrics.bossKills + || previousMetrics.kills !== metrics.kills) { + rpgRun = reduceRpgRoguelikeRun(rpgRun, { type: "challenge-progress", metrics }); + } + const objective = rpgRun.currentChallenge?.objective; + const succeeded = Boolean(objective && metrics[objective.metric] >= objective.target); + const failed = healerDefeated || allCompanionsDefeated || partyWiped || hockeyLost || blockbreakerLost; + if (succeeded || failed) { + rpgRun = reduceRpgRoguelikeRun(rpgRun, { type: "challenge-complete", metrics, forcedFailure: failed }); + const cleared = rpgRun.lastChallengeResult?.succeeded ?? false; + phase = gamePhaseForRpgRun(rpgRun); + activityMode = "boss"; + endlessMode = false; + rpgFocusId = normalizeRpgFocusId(rpgRun, null); + activeCast = null; + combatLog = addLog( + combatLog, + time, + cleared ? "Hallway challenge cleared. Bonus chest quality secured." : "Hallway challenge failed. Boss route remains open.", + cleared ? "good" : "danger", + ); + } else { + phase = "combat"; + } + } else if (rpgBossActive && rpgRun) { + if (healerDefeated) { + const vitals = extractRpgPartyVitals(party, assignRosterToCombatSlots(rpgRun.roster)); + rpgRun = reduceRpgRoguelikeRun(rpgRun, { + type: "boss-lost", + vitals, + playerHp: 0, + }); + phase = "defeat"; + endlessMode = false; + rpgFocusId = normalizeRpgFocusId(rpgRun, null); + combatLog = addLog(combatLog, time, "The healer falls. The expedition ends.", "danger"); + } else if (allCompanionsDefeated) { + const vitals = extractRpgPartyVitals(party, assignRosterToCombatSlots(rpgRun.roster)); + rpgRun = reduceRpgRoguelikeRun(rpgRun, { + type: "boss-lost", + vitals, + playerHp: extractRpgPlayerVital(party), + }); + phase = "defeat"; + endlessMode = false; + rpgFocusId = normalizeRpgFocusId(rpgRun, null); + combatLog = addLog(combatLog, time, "Every companion falls. The expedition ends.", "danger"); + } else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) { + const vitals = extractRpgPartyVitals(party, assignRosterToCombatSlots(rpgRun.roster)); + rpgRun = reduceRpgRoguelikeRun(rpgRun, { + type: "boss-won", + vitals, + playerHp: extractRpgPlayerVital(party), + }); + phase = "combat"; + endlessMode = false; + rpgFocusId = normalizeRpgFocusId(rpgRun, null); + combatLog = addLog(combatLog, time, `${boss.name} falls. North door unlocked.`, "good"); + } else if (partyWiped) { + const vitals = extractRpgPartyVitals(party, assignRosterToCombatSlots(rpgRun.roster)); + rpgRun = reduceRpgRoguelikeRun(rpgRun, { + type: "boss-lost", + vitals, + playerHp: extractRpgPlayerVital(party), + }); + phase = "defeat"; + endlessMode = false; + rpgFocusId = normalizeRpgFocusId(rpgRun, null); + combatLog = addLog(combatLog, time, "Boss room claims the expedition.", "danger"); + } + } else if (rpgRun?.phase === "boss-cleared") { + phase = "combat"; + endlessMode = false; + } else if (pvpMode) { + if (partyWiped) { + phase = "defeat"; + hockeyPvp.status = "lost"; + combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName} wins the rally.`, "danger"); + } else if (opponentWiped) { + phase = "victory"; + hockeyPvp.status = "won"; + combatLog = addLog(combatLog, time, `${hockeyPvp.opponentName}'s party falls. PVP victory.`, "good"); + } else { + phase = "combat"; + } + } else if (state.endlessMode && (partyWiped || hockeyLost || blockbreakerLost)) { phase = "defeat"; - combatLog = addLog(combatLog, time, `${endlessBossKills} endless bosses defeated before the party fell.`, "danger"); + combatLog = addLog( + combatLog, + time, + hockeyLost + ? `Goal breached after ${hockey.returns} puck returns and ${endlessBossKills} boss kills.` + : blockbreakerAlliesDefeated + ? `All four allies fell after ${blockbreaker.bricksBroken} bricks and ${blockbreaker.score} points.` + : state.activityMode === "aether-assault" + ? `Party fell during wave ${aetherAssault.wave} with ${aetherAssault.score} Aether Assault points.` + : `${endlessBossKills} bosses defeated before the party fell.`, + "danger", + ); } else if (state.endlessMode) { phase = "combat"; } else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) { @@ -866,6 +2223,12 @@ export const useGameStore = create((set, get) => ({ combatLog = addLog(combatLog, time, `The party falls. ${boss.name} claims the vault.`, "danger"); } + if (rpgRun && reactiveResourceGains > 0) { + rpgSpellResources.tidalSurge = Math.min(2, rpgSpellResources.tidalSurge + reactiveResourceGains); + } else if (state.healerClassId === "shaman" && reactiveResourceGains > 0) { + healerMechanic.resource = Math.min(healerMechanic.maxResource, healerMechanic.resource + reactiveResourceGains); + } + set({ time, party, @@ -878,14 +2241,26 @@ export const useGameStore = create((set, get) => ({ partyPositions, bossMotion, phase, + activityMode, + endlessMode, + rpgRun, + rpgFocusId, + activeTab: pvpMode && (phase === "victory" || phase === "defeat") ? "combat" : state.activeTab, endlessBossKills, endlessSpawnSequence, + hockey, + blockbreaker, + aetherAssault, + hockeyPvp, + hockeyPvpOpponent, runBuffInputUnlockAt, mana: Math.min(state.maxMana, state.mana + 3.2 * (time - oldTime)), activeCast, combatLog, scenePulse: pulse, barrier, + healerMechanic, + rpgSpellResources, }); }, })); @@ -901,6 +2276,8 @@ export type GameSnapshot = Omit; export function getGameSnapshot(): GameSnapshot { @@ -923,6 +2305,8 @@ export function getGameSnapshot(): GameSnapshot { setActiveTab: _setActiveTab, selectItem: _selectItem, setPlayerPosition: _setPlayerPosition, + setHockeyAimDirection: _setHockeyAimDirection, + applyHockeyPvpRemoteSnapshot: _applyHockeyPvpRemoteSnapshot, setPaused: _setPaused, togglePause: _togglePause, setPauseSelection: _setPauseSelection, @@ -931,13 +2315,47 @@ export function getGameSnapshot(): GameSnapshot { continueRoguelikeRound: _continueRoguelikeRound, startRogueTrialsEndless: _startRogueTrialsEndless, setEndlessChoiceSelection: _setEndlessChoiceSelection, + setAbilityLoadout: _setAbilityLoadout, + dispatchRpgAction: _dispatchRpgAction, + setRpgFocusId: _setRpgFocusId, + cycleRpgFocus: _cycleRpgFocus, + moveRpgFocus: _moveRpgFocus, ...snapshot } = useGameStore.getState(); return snapshot; } -export function abilityRemaining(abilityId: AbilityId, time: number, cooldowns: Record) { - return Math.max(0, cooldowns[abilityId] - time); +export function getHockeyPvpNetworkSnapshot(): HockeyPvpRemoteSnapshot | null { + const state = useGameStore.getState(); + if (state.runMode !== "hockey-healing-pvp" || state.hockeyPvp.role === "cpu") return null; + const puck = state.hockeyPvp.role === "host" ? { + puckPosition: [...state.hockeyPvp.puckPosition] as WorldPosition, + puckVelocity: [...state.hockeyPvp.puckVelocity] as WorldPosition, + localReturns: state.hockeyPvp.localReturns, + opponentReturns: state.hockeyPvp.opponentReturns, + localGoalsConceded: state.hockeyPvp.localGoalsConceded, + opponentGoalsConceded: state.hockeyPvp.opponentGoalsConceded, + goalSequence: state.hockeyPvp.goalSequence, + lastGoalSide: state.hockeyPvp.lastGoalSide, + serveIndex: state.hockeyPvp.serveIndex, + } : undefined; + return { + sequence: Date.now(), + time: state.time, + party: structuredClone(state.party), + partyPositions: structuredClone(state.partyPositions), + boss: { id: state.boss.id, name: state.boss.name, hp: state.boss.hp, maxHp: state.boss.maxHp }, + bossPosition: [...state.bossMotion.position], + bossMode: state.bossMotion.mode, + bossKills: state.endlessBossKills, + playerPosition: [...state.partyPositions.aelia], + aimDirection: [...state.hockeyPvp.aimDirection], + puck, + }; +} + +export function abilityRemaining(abilitySlotId: AbilitySlotId, time: number, cooldowns: Record) { + return Math.max(0, cooldowns[abilitySlotId] - time); } export function isRunBuffInputLocked( @@ -954,5 +2372,8 @@ export function upcomingEncounterMechanic(state: Pick 0 ? [{ boss: state.boss, motion: state.bossMotion }] : []), ...state.additionalBosses.filter((entry) => entry.boss.hp > 0), ].map((entry) => upcomingMechanic(entry.boss, entry.motion, state.time)); + if (!candidates.length) { + return { name: "Replacement incoming", remaining: 0, cycle: 1, urgent: false }; + } return candidates.reduce((next, candidate) => candidate.remaining < next.remaining ? candidate : next, candidates[0]); } diff --git a/src/game/types.ts b/src/game/types.ts index 585d149..4239117 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -1,5 +1,37 @@ export type MemberId = "aelia" | "brann" | "nia" | "orin" | "vale"; -export type AbilityId = "mend" | "renew" | "shield" | "purify" | "radiance" | "barrier"; +export type AbilitySlotId = "ability1" | "ability2" | "ability3" | "ability4" | "ability5" | "ability6"; +export type HealerAbilityId = + | "priest-mend" + | "priest-renew" + | "priest-aegis-shield" + | "priest-purify" + | "priest-radiance" + | "priest-barrier" + | "druid-regrowth" + | "druid-rejuvenation" + | "druid-lifebloom" + | "druid-natures-cure" + | "druid-wild-growth" + | "druid-flourish" + | "shaman-healing-wave" + | "shaman-riptide" + | "shaman-earth-shield" + | "shaman-cleanse-spirit" + | "shaman-chain-heal" + | "shaman-spirit-link" + | "paladin-holy-light" + | "paladin-crusader-strike" + | "paladin-beacon-of-light" + | "paladin-cleanse-light" + | "paladin-word-of-glory" + | "paladin-avenging-crusader" + | "chronomancer-mend-timeline" + | "chronomancer-time-anchor" + | "chronomancer-echo-of-tomorrow" + | "chronomancer-erase-affliction" + | "chronomancer-accelerate" + | "chronomancer-time-loop"; +export type AbilityLoadout = Partial>; export type BossId = | "bulldrome" | "sandglass-scorpion" @@ -62,7 +94,8 @@ export type BossMechanicId = | "soul-siphon"; export type BossAnimationCue = "idle" | "move" | "attack" | "special"; export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat"; -export type RunMode = "encounter" | "roguelike" | "rogue-trials"; +export type RunMode = "encounter" | "roguelike" | "rpg-roguelike" | "rogue-trials" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault"; +export type GameplayActivity = "boss" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault"; export type RunBuffId = | "mend-echo" | "mend-efficiency" @@ -83,8 +116,9 @@ export type RunBuffId = | "barrier-duration" | "barrier-regen"; export type RunBuffRanks = Partial>; -export type BottomTab = "combat" | "map" | "pack"; -export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash"; +export type BottomTab = "combat" | "map" | "pack" | "pvp"; +export type HealerPulseKind = "direct-heal" | "periodic-heal" | "protective" | "cleanse" | "group-heal" | "field"; +export type PulseKind = HealerPulseKind | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash"; export type BossMotionMode = | "holding" | "telegraph" @@ -202,6 +236,26 @@ export interface Debuff { sourceBossId?: BossId; } +export type HealingEffectId = "renew" | "regrowth" | "rejuvenation" | "lifebloom" | "wild-growth" | "riptide" | "temporal-echo"; + +export interface HealingOverTimeEffect { + id: HealingEffectId; + expiresAt: number; + nextTickAt: number; + tickInterval: number; + healingPerTick: number; + stacks: number; + expirationHealingPerStack?: number; +} + +export interface ReactiveHealEffect { + id: "earth-shield"; + charges: number; + expiresAt: number; + nextTriggerAt: number; + healingPerTrigger: number; +} + export interface PartyMember { id: MemberId; name: string; @@ -211,12 +265,23 @@ export interface PartyMember { maxHp: number; hp: number; absorb: number; - renewExpiresAt: number; - renewNextTickAt: number; + healingEffects: HealingOverTimeEffect[]; + reactiveHeal: ReactiveHealEffect | null; knockedUntil: number; debuffs: Debuff[]; + /** Optional serializable projection supplied by modular run modes. */ + runProfile?: { + instanceId: string; + combatProfileId: string; + combatKitId: PartyCombatKitId; + tier: string; + visualArchetype: "knight" | "ranger" | "mage" | "rogue" | "druid"; + }; } +/** Renderer-independent companion behavior family; run modes may map any archetype to one. */ +export type PartyCombatKitId = "tank" | "ranged" | "caster" | "melee"; + export interface BossState { id: BossId; name: string; @@ -244,6 +309,8 @@ export interface BossMotionState { nextMechanicAt: number; mechanicCount: number; phaseStartedAt: number; + /** Simulation time of latest basic melee impact, or -1 before first impact. */ + lastMeleeAt: number; mechanicHitIds: MemberId[]; mechanicNextDamageAt: Partial>; tetherIds: MemberId[]; @@ -258,7 +325,9 @@ export interface BossMotionState { } export interface AbilityDefinition { - id: AbilityId; + id: HealerAbilityId; + slot: AbilitySlotId; + pulseKind: HealerPulseKind; name: string; shortName: string; key: string; @@ -273,18 +342,40 @@ export interface AbilityDefinition { } export interface ActiveCast { - abilityId: "mend"; + slotId: AbilitySlotId; + abilityId: HealerAbilityId; targetId: MemberId; startedAt: number; completesAt: number; + resourceSpent: number; } export interface BarrierState { + kind: "barrier" | "spirit-link" | null; center: WorldPosition; expiresAt: number; nextHealAt: number; } +export interface HealerMechanicState { + resource: number; + maxResource: number; + flourishExpiresAt: number; + nextPulseAt: number; + beaconTargetId: MemberId | null; + beaconExpiresAt: number; + avengingCrusaderExpiresAt: number; + temporalAnchor: { + targetId: MemberId; + hp: number; + expiresAt: number; + } | null; + timeLoop: { + restoresAt: number; + partyHp: Record; + } | null; +} + export interface ScenePulse { id: number; kind: PulseKind; @@ -302,7 +393,7 @@ export interface InventoryItem { equipped: boolean; } -export type HealerClassId = "priest" | "druid" | "shaman"; +export type HealerClassId = "priest" | "druid" | "shaman" | "paladin" | "chronomancer"; export interface HealerClassDefinition { id: HealerClassId; @@ -311,6 +402,7 @@ export interface HealerClassDefinition { icon: string; color: string; resourceName: string; + secondaryResourceName?: string; description: string; - abilities: Record; + abilities: Record; } diff --git a/src/game/useGameLoop.ts b/src/game/useGameLoop.ts index 253ade2..baa4237 100644 --- a/src/game/useGameLoop.ts +++ b/src/game/useGameLoop.ts @@ -3,6 +3,8 @@ import { subscribeControllerToken } from "../input/controller"; import { ABILITY_ORDER } from "./data"; import { isRunBuffInputLocked, useGameStore } from "./store"; import { ABILITY_BY_CONTROLLER_BUTTON } from "./controllerBindings"; +import { cycleBottomTab } from "./bottomTabs"; +import { resolveRpgFocusCommand } from "./rpgRoguelike/uiModel"; function cycleRunBuff(direction: 1 | -1) { const store = useGameStore.getState(); @@ -12,6 +14,21 @@ function cycleRunBuff(direction: 1 | -1) { store.setSelectedRunBuff(store.draftBuffIds[nextIndex]); } +function rpgInputIsGated() { + const run = useGameStore.getState().rpgRun; + return Boolean(run && run.phase !== "challenge-active" && run.phase !== "boss-combat"); +} + +function activateRpgFocus(onExit?: () => void) { + const store = useGameStore.getState(); + if (!store.rpgRun) return; + const command = resolveRpgFocusCommand(store.rpgRun, store.rpgFocusId); + if (!command) return; + if (command.type === "run-action") store.dispatchRpgAction(command.action); + else if (command.type === "restart-run") store.restart(); + else onExit?.(); +} + export function useActionBindings(enabled = true, onExit?: () => void) { const exitRef = useRef(onExit); exitRef.current = onExit; @@ -32,6 +49,16 @@ export function useActionBindings(enabled = true, onExit?: () => void) { } return; } + if (store.runMode === "rpg-roguelike" && store.rpgRun && rpgInputIsGated()) { + if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter", "escape"].includes(key)) event.preventDefault(); + if (key === "arrowleft") store.moveRpgFocus("left"); + if (key === "arrowright") store.moveRpgFocus("right"); + if (key === "arrowup") store.moveRpgFocus("up"); + if (key === "arrowdown") store.moveRpgFocus("down"); + if (key === "enter") activateRpgFocus(exitRef.current); + if (key === "escape") exitRef.current?.(); + return; + } if (store.phase === "intermission") { if (["arrowleft", "arrowup", "arrowright", "arrowdown", "enter"].includes(key)) event.preventDefault(); if (isRunBuffInputLocked(store)) return; @@ -71,7 +98,14 @@ export function useActionBindings(enabled = true, onExit?: () => void) { store.setActiveTab(store.activeTab === "map" ? "combat" : "map"); break; case "i": - store.setActiveTab(store.activeTab === "pack" ? "combat" : "pack"); + if (store.runMode !== "hockey-healing-pvp") { + store.setActiveTab(store.activeTab === "pack" ? "combat" : "pack"); + } + break; + case "p": + if (store.runMode === "hockey-healing-pvp") { + store.setActiveTab(store.activeTab === "pvp" ? "combat" : "pvp"); + } break; case "enter": if (store.phase === "briefing") store.startEncounter(); @@ -101,6 +135,15 @@ export function useActionBindings(enabled = true, onExit?: () => void) { } return; } + if (store.runMode === "rpg-roguelike" && store.rpgRun && rpgInputIsGated()) { + if (token === "Button12" || token === "Axis1-") store.moveRpgFocus("up"); + if (token === "Button13" || token === "Axis1+") store.moveRpgFocus("down"); + if (token === "Button14" || token === "Axis0-") store.moveRpgFocus("left"); + if (token === "Button15" || token === "Axis0+") store.moveRpgFocus("right"); + if (!repeat && token === "Button0") activateRpgFocus(exitRef.current); + if (!repeat && token === "Button1") exitRef.current?.(); + return; + } if (store.phase === "intermission") { if (isRunBuffInputLocked(store)) return; if (["Button12", "Button14", "Axis0-", "Axis1-"].includes(token)) cycleRunBuff(-1); @@ -129,7 +172,7 @@ export function useActionBindings(enabled = true, onExit?: () => void) { } if (token === "Button12") store.cycleMember(-1); if (token === "Button13") store.cycleMember(1); - if (token === "Button8") store.setActiveTab(store.activeTab === "map" ? "combat" : "map"); + if (token === "Button8") store.setActiveTab(cycleBottomTab(store.activeTab, store.runMode)); if (token === "Button9" || (token === "Button0" && store.phase !== "combat")) { if (store.phase === "briefing") store.startEncounter(); else if (store.phase === "victory" || store.phase === "defeat") store.restart(); diff --git a/src/game/weaponCatalog.test.ts b/src/game/weaponCatalog.test.ts new file mode 100644 index 0000000..848abb3 --- /dev/null +++ b/src/game/weaponCatalog.test.ts @@ -0,0 +1,198 @@ +import { readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getBounds, NodeIO } from "@gltf-transform/core"; +import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; +import { MeshoptDecoder } from "meshoptimizer"; +import { describe, expect, it } from "vitest"; +import { + CHARACTER_BACK_PROP_MODEL_IDS, + CHARACTER_WEAPON_MODEL_IDS, + CLAUDECRAFT_WEAPON_CATALOG, + LEGACY_WEAPON_ID_ALIASES, + TWO_HANDED_WEAPON_IDS, + heldItemForModel, + isWeaponCatalogId, + preferredWeaponAssetFile, + resolveCharacterWeaponModelId, + resolveWeaponCatalogId, + weaponDefinition, + weaponDefinitionsForSlot, + weaponIdsForCategory, + weaponIdsForGrip, + weaponIdsForSlot, + weaponSupportsSlot, + weaponUsesBothHands, +} from "./weaponCatalog"; + +const RUNTIME_DIRECTORY = fileURLToPath( + new URL("../assets/game/models/claudecraft/weapons", import.meta.url), +); + +function glbFiles(directory: string) { + return readdirSync(directory) + .filter((file) => file.endsWith(".glb")) + .sort(); +} + +describe("Claudecraft weapon catalog", () => { + it("catalogs every checked-in canonical asset exactly once", () => { + const ids = CLAUDECRAFT_WEAPON_CATALOG.map((entry) => entry.id); + const files = CLAUDECRAFT_WEAPON_CATALOG.map((entry) => entry.sourceFile); + const canonicalRuntimeFiles = glbFiles(RUNTIME_DIRECTORY) + .filter((file) => !file.endsWith("-uastc.glb")); + + expect(CLAUDECRAFT_WEAPON_CATALOG).toHaveLength(55); + expect(new Set(ids)).toHaveLength(55); + expect(new Set(files)).toHaveLength(55); + expect([...files].sort()).toEqual(canonicalRuntimeFiles); + }); + + it("references every optimized variant exactly once", () => { + const optimizedFiles = CLAUDECRAFT_WEAPON_CATALOG + .map((entry) => entry.optimizedFile) + .filter((file): file is NonNullable => file !== null) + .sort(); + const optimizedRuntimeFiles = glbFiles(RUNTIME_DIRECTORY) + .filter((file) => file.endsWith("-uastc.glb")); + + expect(optimizedFiles).toHaveLength(7); + expect(new Set(optimizedFiles)).toHaveLength(7); + expect(optimizedFiles).toEqual(optimizedRuntimeFiles); + }); + + it("has tracked runtime copies for every referenced source asset", () => { + const referencedFiles = CLAUDECRAFT_WEAPON_CATALOG + .flatMap((entry) => entry.optimizedFile + ? [entry.sourceFile, entry.optimizedFile] + : [entry.sourceFile]) + .sort(); + + expect(glbFiles(RUNTIME_DIRECTORY)).toEqual(referencedFiles); + }); + + it("keeps category coverage stable", () => { + expect(Object.fromEntries([ + "axe", + "crossbow", + "dagger", + "halberd", + "hammer", + "quiver", + "scythe", + "shield", + "spear", + "spellbook", + "staff", + "sword", + "wand", + ].map((category) => [category, weaponIdsForCategory(category as Parameters[0]).length]))).toEqual({ + axe: 9, + crossbow: 3, + dagger: 5, + halberd: 1, + hammer: 4, + quiver: 1, + scythe: 1, + shield: 4, + spear: 1, + spellbook: 1, + staff: 8, + sword: 13, + wand: 4, + }); + }); + + it("derives held items while keeping back-only props separate", () => { + expect(CHARACTER_WEAPON_MODEL_IDS).toHaveLength(54); + expect(CHARACTER_WEAPON_MODEL_IDS).not.toContain("cc/quiver"); + expect(CHARACTER_BACK_PROP_MODEL_IDS).toEqual(["cc/quiver"]); + expect(heldItemForModel("cc/adv_dagger")).toEqual({ + modelId: "cc/adv_dagger", + grip: "dagger", + }); + expect(weaponDefinitionsForSlot("back").map((entry) => entry.id)).toEqual(["cc/quiver"]); + }); + + it("identifies valid two-handed and long-weapon models", () => { + expect(TWO_HANDED_WEAPON_IDS).toHaveLength(18); + expect(new Set(TWO_HANDED_WEAPON_IDS)).toHaveLength(18); + expect(TWO_HANDED_WEAPON_IDS.every((id) => isWeaponCatalogId(id))).toBe(true); + expect(TWO_HANDED_WEAPON_IDS.every((id) => CHARACTER_WEAPON_MODEL_IDS.includes(id))).toBe(true); + expect(weaponUsesBothHands("cc/staff_d")).toBe(true); + expect(weaponUsesBothHands("cc/crossbow_1handed")).toBe(false); + expect(TWO_HANDED_WEAPON_IDS).not.toContain("cc/quiver"); + }); + + it("filters by slot, category, and attachment grip", () => { + expect(weaponSupportsSlot("cc/adv_sword_1handed", "main")).toBe(true); + expect(weaponSupportsSlot("cc/adv_sword_1handed", "off")).toBe(true); + expect(weaponSupportsSlot("cc/adv_sword_2handed", "off")).toBe(false); + expect(weaponIdsForSlot("off")).toContain("cc/shield_round"); + expect(weaponIdsForCategory("scythe")).toEqual(["cc/scythe"]); + expect(weaponIdsForGrip("polearm")).toEqual([ + "cc/halberd", + "cc/scythe", + "cc/spear_a", + ]); + expect(weaponDefinition("cc/wand_b").category).toBe("wand"); + }); + + it("resolves canonical and legacy save IDs without accepting prototype keys", () => { + expect(LEGACY_WEAPON_ID_ALIASES).toEqual({ + "druid-staff": "cc/adv_druid_staff", + sword: "cc/adv_sword_1handed", + shield: "cc/shield_badge", + wand: "cc/adv_wand", + spellbook: "cc/spellbook_open", + }); + expect(resolveWeaponCatalogId("cc/sword_g")).toBe("cc/sword_g"); + expect(resolveWeaponCatalogId("sword")).toBe("cc/adv_sword_1handed"); + expect(resolveCharacterWeaponModelId("spellbook")).toBe("cc/spellbook_open"); + expect(resolveCharacterWeaponModelId("cc/quiver")).toBeNull(); + expect(resolveWeaponCatalogId("toString")).toBeNull(); + expect(isWeaponCatalogId("__proto__")).toBe(false); + expect(resolveWeaponCatalogId(null)).toBeNull(); + }); + + it("prefers UASTC only when a catalog entry provides it", () => { + expect(preferredWeaponAssetFile("cc/adv_wand")).toBe("adv_wand-uastc.glb"); + expect(preferredWeaponAssetFile("cc/adv_wand", false)).toBe("adv_wand.glb"); + expect(preferredWeaponAssetFile("cc/wand_a")).toBe("wand_a.glb"); + }); + + it("keeps every runtime design finite, static, and inside the mobile geometry budget", async () => { + await MeshoptDecoder.ready; + const io = new NodeIO() + .registerExtensions(ALL_EXTENSIONS) + .registerDependencies({ "meshopt.decoder": MeshoptDecoder }); + + for (const definition of CLAUDECRAFT_WEAPON_CATALOG) { + const runtimeFiles = definition.optimizedFile + ? [definition.sourceFile, definition.optimizedFile] + : [definition.sourceFile]; + for (const runtimeFile of runtimeFiles) { + const contractLabel = `${definition.id} (${runtimeFile})`; + const document = await io.read(path.join(RUNTIME_DIRECTORY, runtimeFile)); + const root = document.getRoot(); + const scene = root.listScenes()[0]; + const bounds = getBounds(scene); + const values = [...bounds.min, ...bounds.max]; + const triangleCount = root.listMeshes().reduce((meshTotal, mesh) => meshTotal + + mesh.listPrimitives().reduce((primitiveTotal, primitive) => { + const elementCount = primitive.getIndices()?.getCount() + ?? primitive.getAttribute("POSITION")?.getCount() + ?? 0; + return primitiveTotal + Math.floor(elementCount / 3); + }, 0), 0); + + expect(values.every(Number.isFinite), contractLabel).toBe(true); + expect(bounds.max.some((value, index) => value > bounds.min[index]), contractLabel).toBe(true); + expect(triangleCount, contractLabel).toBeGreaterThan(0); + expect(triangleCount, contractLabel).toBeLessThanOrEqual(2_000); + expect(root.listAnimations(), contractLabel).toHaveLength(0); + expect(root.listSkins(), contractLabel).toHaveLength(0); + } + } + }); +}); diff --git a/src/game/weaponCatalog.ts b/src/game/weaponCatalog.ts new file mode 100644 index 0000000..b96542b --- /dev/null +++ b/src/game/weaponCatalog.ts @@ -0,0 +1,230 @@ +export const WEAPON_SLOTS = ["main", "off", "back"] as const; +export type WeaponSlot = typeof WEAPON_SLOTS[number]; + +export const WEAPON_CATEGORIES = [ + "axe", + "crossbow", + "dagger", + "halberd", + "hammer", + "quiver", + "scythe", + "shield", + "spear", + "spellbook", + "staff", + "sword", + "wand", +] as const; +export type WeaponCategory = typeof WEAPON_CATEGORIES[number]; + +/** Attachment transform profile. Gameplay weapon type remains `category`. */ +export const WEAPON_GRIPS = [ + "upright", + "dagger", + "staff", + "wand", + "crossbow", + "polearm", + "prop", + "back", +] as const; +export type WeaponGrip = typeof WEAPON_GRIPS[number]; + +export interface WeaponCatalogEntry { + id: `cc/${string}`; + label: string; + category: WeaponCategory; + allowedSlots: readonly WeaponSlot[]; + grip: WeaponGrip; + sourceFile: `${string}.glb`; + optimizedFile: `${string}-uastc.glb` | null; +} + +export const CLAUDECRAFT_WEAPON_CATALOG = [ + { id: "cc/adv_axe_1handed", label: "Adventurer One-Handed Axe", category: "axe", allowedSlots: ["main", "off"], grip: "upright", sourceFile: "adv_axe_1handed.glb", optimizedFile: null }, + { id: "cc/adv_axe_2handed", label: "Adventurer Two-Handed Axe", category: "axe", allowedSlots: ["main"], grip: "upright", sourceFile: "adv_axe_2handed.glb", optimizedFile: null }, + { id: "cc/adv_dagger", label: "Adventurer Dagger", category: "dagger", allowedSlots: ["main", "off"], grip: "dagger", sourceFile: "adv_dagger.glb", optimizedFile: "adv_dagger-uastc.glb" }, + { id: "cc/adv_druid_staff", label: "Druid Staff", category: "staff", allowedSlots: ["main"], grip: "staff", sourceFile: "adv_druid_staff.glb", optimizedFile: "adv_druid_staff-uastc.glb" }, + { id: "cc/adv_staff", label: "Adventurer Staff", category: "staff", allowedSlots: ["main"], grip: "staff", sourceFile: "adv_staff.glb", optimizedFile: null }, + { id: "cc/adv_sword_1handed", label: "Adventurer One-Handed Sword", category: "sword", allowedSlots: ["main", "off"], grip: "upright", sourceFile: "adv_sword_1handed.glb", optimizedFile: "adv_sword_1handed-uastc.glb" }, + { id: "cc/adv_sword_2handed", label: "Adventurer Two-Handed Sword", category: "sword", allowedSlots: ["main"], grip: "upright", sourceFile: "adv_sword_2handed.glb", optimizedFile: null }, + { id: "cc/adv_sword_2handed_color", label: "Adventurer Colored Greatsword", category: "sword", allowedSlots: ["main"], grip: "upright", sourceFile: "adv_sword_2handed_color.glb", optimizedFile: null }, + { id: "cc/adv_wand", label: "Adventurer Wand", category: "wand", allowedSlots: ["main"], grip: "wand", sourceFile: "adv_wand.glb", optimizedFile: "adv_wand-uastc.glb" }, + + { id: "cc/axe_1handed", label: "One-Handed Axe", category: "axe", allowedSlots: ["main", "off"], grip: "upright", sourceFile: "axe_1handed.glb", optimizedFile: null }, + { id: "cc/axe_2handed", label: "Two-Handed Axe", category: "axe", allowedSlots: ["main"], grip: "upright", sourceFile: "axe_2handed.glb", optimizedFile: null }, + { id: "cc/axe_a", label: "Axe A", category: "axe", allowedSlots: ["main"], grip: "upright", sourceFile: "axe_a.glb", optimizedFile: null }, + { id: "cc/axe_b", label: "Axe B", category: "axe", allowedSlots: ["main"], grip: "upright", sourceFile: "axe_b.glb", optimizedFile: null }, + { id: "cc/axe_c", label: "Axe C", category: "axe", allowedSlots: ["main"], grip: "upright", sourceFile: "axe_c.glb", optimizedFile: null }, + { id: "cc/axe_d", label: "Axe D", category: "axe", allowedSlots: ["main"], grip: "upright", sourceFile: "axe_d.glb", optimizedFile: null }, + + { id: "cc/crossbow_1handed", label: "One-Handed Crossbow", category: "crossbow", allowedSlots: ["main"], grip: "crossbow", sourceFile: "crossbow_1handed.glb", optimizedFile: null }, + { id: "cc/crossbow_2handed", label: "Two-Handed Crossbow", category: "crossbow", allowedSlots: ["main"], grip: "crossbow", sourceFile: "crossbow_2handed.glb", optimizedFile: "crossbow_2handed-uastc.glb" }, + + { id: "cc/dagger", label: "Dagger", category: "dagger", allowedSlots: ["main", "off"], grip: "dagger", sourceFile: "dagger.glb", optimizedFile: null }, + { id: "cc/dagger_a", label: "Dagger A", category: "dagger", allowedSlots: ["main", "off"], grip: "dagger", sourceFile: "dagger_a.glb", optimizedFile: null }, + { id: "cc/dagger_b", label: "Dagger B", category: "dagger", allowedSlots: ["main", "off"], grip: "dagger", sourceFile: "dagger_b.glb", optimizedFile: null }, + { id: "cc/dagger_c", label: "Dagger C", category: "dagger", allowedSlots: ["main", "off"], grip: "dagger", sourceFile: "dagger_c.glb", optimizedFile: null }, + + { id: "cc/halberd", label: "Halberd", category: "halberd", allowedSlots: ["main"], grip: "polearm", sourceFile: "halberd.glb", optimizedFile: null }, + + { id: "cc/hammer_a", label: "Hammer A", category: "hammer", allowedSlots: ["main"], grip: "upright", sourceFile: "hammer_a.glb", optimizedFile: null }, + { id: "cc/hammer_b", label: "Hammer B", category: "hammer", allowedSlots: ["main"], grip: "upright", sourceFile: "hammer_b.glb", optimizedFile: null }, + { id: "cc/hammer_c", label: "Hammer C", category: "hammer", allowedSlots: ["main"], grip: "upright", sourceFile: "hammer_c.glb", optimizedFile: null }, + { id: "cc/hammer_d", label: "Hammer D", category: "hammer", allowedSlots: ["main"], grip: "upright", sourceFile: "hammer_d.glb", optimizedFile: null }, + + { id: "cc/quiver", label: "Quiver", category: "quiver", allowedSlots: ["back"], grip: "back", sourceFile: "quiver.glb", optimizedFile: null }, + { id: "cc/scythe", label: "Scythe", category: "scythe", allowedSlots: ["main"], grip: "polearm", sourceFile: "scythe.glb", optimizedFile: null }, + + { id: "cc/shield_badge", label: "Badge Shield", category: "shield", allowedSlots: ["off"], grip: "prop", sourceFile: "shield_badge.glb", optimizedFile: "shield_badge-uastc.glb" }, + { id: "cc/shield_round", label: "Round Shield", category: "shield", allowedSlots: ["off"], grip: "prop", sourceFile: "shield_round.glb", optimizedFile: null }, + { id: "cc/shield_square", label: "Square Shield", category: "shield", allowedSlots: ["off"], grip: "prop", sourceFile: "shield_square.glb", optimizedFile: null }, + + { id: "cc/skeleton_axe", label: "Skeleton Axe", category: "axe", allowedSlots: ["main"], grip: "upright", sourceFile: "skeleton_axe.glb", optimizedFile: null }, + { id: "cc/skeleton_blade", label: "Skeleton Blade", category: "sword", allowedSlots: ["main"], grip: "upright", sourceFile: "skeleton_blade.glb", optimizedFile: null }, + { id: "cc/skeleton_crossbow", label: "Skeleton Crossbow", category: "crossbow", allowedSlots: ["main"], grip: "crossbow", sourceFile: "skeleton_crossbow.glb", optimizedFile: null }, + { id: "cc/skeleton_shield_large_a", label: "Skeleton Tower Shield", category: "shield", allowedSlots: ["off"], grip: "prop", sourceFile: "skeleton_shield_large_a.glb", optimizedFile: null }, + { id: "cc/skeleton_staff", label: "Skeleton Staff", category: "staff", allowedSlots: ["main"], grip: "staff", sourceFile: "skeleton_staff.glb", optimizedFile: null }, + + { id: "cc/spear_a", label: "Spear A", category: "spear", allowedSlots: ["main"], grip: "polearm", sourceFile: "spear_a.glb", optimizedFile: null }, + { id: "cc/spellbook_open", label: "Open Spellbook", category: "spellbook", allowedSlots: ["off"], grip: "prop", sourceFile: "spellbook_open.glb", optimizedFile: "spellbook_open-uastc.glb" }, + + { id: "cc/staff", label: "Staff", category: "staff", allowedSlots: ["main"], grip: "staff", sourceFile: "staff.glb", optimizedFile: null }, + { id: "cc/staff_a", label: "Staff A", category: "staff", allowedSlots: ["main"], grip: "staff", sourceFile: "staff_a.glb", optimizedFile: null }, + { id: "cc/staff_b", label: "Staff B", category: "staff", allowedSlots: ["main"], grip: "staff", sourceFile: "staff_b.glb", optimizedFile: null }, + { id: "cc/staff_c", label: "Staff C", category: "staff", allowedSlots: ["main"], grip: "staff", sourceFile: "staff_c.glb", optimizedFile: null }, + { id: "cc/staff_d", label: "Staff D", category: "staff", allowedSlots: ["main"], grip: "staff", sourceFile: "staff_d.glb", optimizedFile: null }, + + { id: "cc/sword_1handed", label: "One-Handed Sword", category: "sword", allowedSlots: ["main", "off"], grip: "upright", sourceFile: "sword_1handed.glb", optimizedFile: null }, + { id: "cc/sword_2handed", label: "Two-Handed Sword", category: "sword", allowedSlots: ["main"], grip: "upright", sourceFile: "sword_2handed.glb", optimizedFile: null }, + { id: "cc/sword_a", label: "Sword A", category: "sword", allowedSlots: ["main"], grip: "upright", sourceFile: "sword_a.glb", optimizedFile: null }, + { id: "cc/sword_b", label: "Sword B", category: "sword", allowedSlots: ["main"], grip: "upright", sourceFile: "sword_b.glb", optimizedFile: null }, + { id: "cc/sword_c", label: "Sword C", category: "sword", allowedSlots: ["main"], grip: "upright", sourceFile: "sword_c.glb", optimizedFile: null }, + { id: "cc/sword_d", label: "Sword D", category: "sword", allowedSlots: ["main"], grip: "upright", sourceFile: "sword_d.glb", optimizedFile: null }, + { id: "cc/sword_e", label: "Sword E", category: "sword", allowedSlots: ["main"], grip: "upright", sourceFile: "sword_e.glb", optimizedFile: null }, + { id: "cc/sword_f", label: "Sword F", category: "sword", allowedSlots: ["main"], grip: "upright", sourceFile: "sword_f.glb", optimizedFile: null }, + { id: "cc/sword_g", label: "Sword G", category: "sword", allowedSlots: ["main"], grip: "upright", sourceFile: "sword_g.glb", optimizedFile: null }, + + { id: "cc/wand", label: "Wand", category: "wand", allowedSlots: ["main"], grip: "wand", sourceFile: "wand.glb", optimizedFile: null }, + { id: "cc/wand_a", label: "Wand A", category: "wand", allowedSlots: ["main"], grip: "wand", sourceFile: "wand_a.glb", optimizedFile: null }, + { id: "cc/wand_b", label: "Wand B", category: "wand", allowedSlots: ["main"], grip: "wand", sourceFile: "wand_b.glb", optimizedFile: null }, +] as const satisfies readonly WeaponCatalogEntry[]; + +export type WeaponCatalogId = typeof CLAUDECRAFT_WEAPON_CATALOG[number]["id"]; +export type WeaponDefinition = typeof CLAUDECRAFT_WEAPON_CATALOG[number]; +export type LegacyWeaponModelId = keyof typeof LEGACY_WEAPON_ID_ALIASES; +export type CharacterBackPropModelId = "cc/quiver"; +export type CharacterWeaponModelId = Exclude; +export type CharacterWeaponGrip = Exclude; +export type CharacterWeaponCategory = Exclude; + +export interface CharacterHeldItemVisual { + modelId: CharacterWeaponModelId; + grip: CharacterWeaponGrip; +} + +const WEAPON_CATALOG_BY_ID = Object.fromEntries( + CLAUDECRAFT_WEAPON_CATALOG.map((entry) => [entry.id, entry]), +) as Record; + +export const LEGACY_WEAPON_ID_ALIASES = { + "druid-staff": "cc/adv_druid_staff", + sword: "cc/adv_sword_1handed", + shield: "cc/shield_badge", + wand: "cc/adv_wand", + spellbook: "cc/spellbook_open", +} as const satisfies Record; + +export const CHARACTER_BACK_PROP_MODEL_IDS = ["cc/quiver"] as const satisfies readonly CharacterBackPropModelId[]; + +export const CHARACTER_WEAPON_MODEL_IDS = CLAUDECRAFT_WEAPON_CATALOG + .filter((entry) => !(entry.allowedSlots as readonly WeaponSlot[]).includes("back")) + .map((entry) => entry.id) as CharacterWeaponModelId[]; + +export const TWO_HANDED_WEAPON_IDS = [ + "cc/adv_axe_2handed", + "cc/adv_druid_staff", + "cc/adv_staff", + "cc/adv_sword_2handed", + "cc/adv_sword_2handed_color", + "cc/axe_2handed", + "cc/crossbow_2handed", + "cc/halberd", + "cc/scythe", + "cc/skeleton_crossbow", + "cc/skeleton_staff", + "cc/spear_a", + "cc/staff", + "cc/staff_a", + "cc/staff_b", + "cc/staff_c", + "cc/staff_d", + "cc/sword_2handed", +] as const satisfies readonly CharacterWeaponModelId[]; + +const TWO_HANDED_WEAPON_ID_SET = new Set(TWO_HANDED_WEAPON_IDS); + +function owns(object: object, key: PropertyKey) { + return Object.prototype.hasOwnProperty.call(object, key); +} + +export function isWeaponCatalogId(value: unknown): value is WeaponCatalogId { + return typeof value === "string" && owns(WEAPON_CATALOG_BY_ID, value); +} + +export function resolveWeaponCatalogId(value: unknown): WeaponCatalogId | null { + if (isWeaponCatalogId(value)) return value; + if (typeof value !== "string" || !owns(LEGACY_WEAPON_ID_ALIASES, value)) return null; + return LEGACY_WEAPON_ID_ALIASES[value as LegacyWeaponModelId]; +} + +export function resolveCharacterWeaponModelId(value: unknown): CharacterWeaponModelId | null { + const id = resolveWeaponCatalogId(value); + return id && id !== "cc/quiver" ? id : null; +} + +export function weaponCatalogEntry(id: WeaponCatalogId) { + return WEAPON_CATALOG_BY_ID[id]; +} + +export const weaponDefinition = weaponCatalogEntry; + +export function weaponDefinitionsForSlot(slot: WeaponSlot): WeaponDefinition[] { + return CLAUDECRAFT_WEAPON_CATALOG + .filter((entry) => (entry.allowedSlots as readonly WeaponSlot[]).includes(slot)); +} + +export function heldItemForModel(modelId: CharacterWeaponModelId): CharacterHeldItemVisual { + const definition = weaponDefinition(modelId); + return { modelId, grip: definition.grip as CharacterWeaponGrip }; +} + +export function weaponUsesBothHands(modelId: CharacterWeaponModelId) { + return TWO_HANDED_WEAPON_ID_SET.has(modelId); +} + +export function weaponSupportsSlot(id: WeaponCatalogId, slot: WeaponSlot) { + return (weaponCatalogEntry(id).allowedSlots as readonly WeaponSlot[]).includes(slot); +} + +export function weaponIdsForSlot(slot: WeaponSlot): WeaponCatalogId[] { + return weaponDefinitionsForSlot(slot).map((entry) => entry.id); +} + +export function weaponIdsForCategory(category: WeaponCategory): WeaponCatalogId[] { + return CLAUDECRAFT_WEAPON_CATALOG + .filter((entry) => entry.category === category) + .map((entry) => entry.id); +} + +export function weaponIdsForGrip(grip: WeaponGrip): WeaponCatalogId[] { + return CLAUDECRAFT_WEAPON_CATALOG + .filter((entry) => entry.grip === grip) + .map((entry) => entry.id); +} + +export function preferredWeaponAssetFile(id: WeaponCatalogId, optimizedAssets = true) { + const entry = weaponCatalogEntry(id); + return optimizedAssets && entry.optimizedFile ? entry.optimizedFile : entry.sourceFile; +} diff --git a/src/input/controller.test.ts b/src/input/controller.test.ts index c12d236..9c1bb8c 100644 --- a/src/input/controller.test.ts +++ b/src/input/controller.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + emitControllerToken, getControllerMovement, resetControllerState, setExternalControllerMovement, subscribeControllerMovement, + subscribeControllerToken, } from "./controller"; describe("controller movement normalization", () => { @@ -25,4 +27,18 @@ describe("controller movement normalization", () => { unsubscribe(); }); + + it("routes semantic actions without a DOM focus target", () => { + const received: Array<{ token: string; repeat: boolean }> = []; + const unsubscribe = subscribeControllerToken((event) => received.push(event)); + + emitControllerToken({ token: "Button0", repeat: false }); + emitControllerToken({ token: "Button13", repeat: true }); + + expect(received).toEqual([ + { token: "Button0", repeat: false }, + { token: "Button13", repeat: true }, + ]); + unsubscribe(); + }); }); diff --git a/src/input/nativeControllerFocus.test.ts b/src/input/nativeControllerFocus.test.ts new file mode 100644 index 0000000..cd4df88 --- /dev/null +++ b/src/input/nativeControllerFocus.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const bridgeSource = readFileSync(new URL( + "../../android/app/src/main/java/com/phenomrom/iwanttoheal/ControllerBridgeActivity.java", + import.meta.url, +), "utf8"); + +describe("native controller bridge", () => { + it("never requests WebView or window focus for controller input", () => { + expect(bridgeSource).not.toMatch(/requestFocus\s*\(/); + expect(bridgeSource).not.toMatch(/setFocusable/); + expect(bridgeSource).not.toMatch(/onWindowFocusChanged/); + expect(bridgeSource).not.toMatch(/dispatchTouchEvent/); + }); + + it("routes controller input from activity-level key and motion dispatch", () => { + expect(bridgeSource).toMatch(/boolean dispatchKeyEvent\s*\(/); + expect(bridgeSource).toMatch(/boolean dispatchGenericMotionEvent\s*\(/); + expect(bridgeSource).toMatch(/evaluateJavascript\s*\(/); + }); +}); diff --git a/src/input/useMenuController.ts b/src/input/useMenuController.ts index e45ac25..e147ee3 100644 --- a/src/input/useMenuController.ts +++ b/src/input/useMenuController.ts @@ -10,6 +10,7 @@ export interface MenuAction { interface MenuControllerOptions { columns?: number; + initialId?: string; onBack?: () => void; } @@ -21,8 +22,11 @@ function nextIndex(index: number, direction: Direction, count: number, columns: } export function useMenuController(actions: MenuAction[], options: MenuControllerOptions = {}) { + // App-owned semantic cursor. Never coupled to document.activeElement or DOM focus. const enabled = actions.filter((action) => action.enabled !== false); - const [focusedId, setFocusedId] = useState(enabled[0]?.id ?? ""); + const [selectedId, setSelectedId] = useState(() => enabled.some((action) => action.id === options.initialId) + ? options.initialId! + : enabled[0]?.id ?? ""); const actionsRef = useRef(enabled); const backRef = useRef(options.onBack); const columnsRef = useRef(options.columns ?? 1); @@ -31,26 +35,26 @@ export function useMenuController(actions: MenuAction[], options: MenuController columnsRef.current = options.columns ?? 1; useEffect(() => { - if (!actionsRef.current.some((action) => action.id === focusedId)) { - setFocusedId(actionsRef.current[0]?.id ?? ""); + if (!actionsRef.current.some((action) => action.id === selectedId)) { + setSelectedId(actionsRef.current[0]?.id ?? ""); } - }, [actions, focusedId]); + }, [actions, selectedId]); const move = useCallback((direction: Direction) => { const current = actionsRef.current; if (!current.length) return; - const index = Math.max(0, current.findIndex((action) => action.id === focusedId)); + const index = Math.max(0, current.findIndex((action) => action.id === selectedId)); const neighborId = current[index]?.neighbors?.[direction]; if (neighborId && current.some((action) => action.id === neighborId)) { - setFocusedId(neighborId); + setSelectedId(neighborId); return; } - setFocusedId(current[nextIndex(index, direction, current.length, columnsRef.current)].id); - }, [focusedId]); + setSelectedId(current[nextIndex(index, direction, current.length, columnsRef.current)].id); + }, [selectedId]); const confirm = useCallback(() => { - actionsRef.current.find((action) => action.id === focusedId)?.run(); - }, [focusedId]); + actionsRef.current.find((action) => action.id === selectedId)?.run(); + }, [selectedId]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -81,8 +85,8 @@ export function useMenuController(actions: MenuAction[], options: MenuController }), [confirm, move]); return { - focusedId, - focus: setFocusedId, - isFocused: (id: string) => id === focusedId, + selectedId, + select: setSelectedId, + isSelected: (id: string) => id === selectedId, }; } diff --git a/src/platform/BottomDisplayApp.tsx b/src/platform/BottomDisplayApp.tsx index 5cbd593..e7ad92c 100644 --- a/src/platform/BottomDisplayApp.tsx +++ b/src/platform/BottomDisplayApp.tsx @@ -10,6 +10,7 @@ import type { DifficultySlug } from "../game/progression/loot"; import { useForcedThorDisplays } from "./useThorDualScreen"; import { createRateLimitedPublisher } from "./rateLimitedPublisher"; import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; +import type { HockeyPvpMatchConfig } from "../game/hockeyHealingPvp"; const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33; @@ -21,6 +22,7 @@ function screenTitle(screen: AppScreen) { case "home": return "Choose expedition"; case "profile": return "Hunter profile"; case "gear": return "Gear upgrade"; + case "appearance": return "Appearance Lab"; case "settings": return "Field settings"; case "mode": return "Prepare encounter"; case "game": return "Field console"; @@ -67,8 +69,8 @@ export function BottomDisplayApp() { channelRef.current?.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage); }, []); - const launchGame = useCallback((bossIds: readonly BossId[], difficultySlug?: DifficultySlug) => { - postFrontendCommand({ name: "launchGame", bossIds, difficultySlug }); + const launchGame = useCallback((bossIds: readonly BossId[], difficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => { + postFrontendCommand({ name: "launchGame", bossIds, difficultySlug, hockeyPvpMatch }); }, [postFrontendCommand]); useEffect(() => { @@ -117,7 +119,7 @@ export function BottomDisplayApp() { copySlot: (sourceId, targetId) => postFrontend({ name: "copySlot", sourceId, targetId }), uploadSlot: (slotId) => { postFrontend({ name: "uploadSlot", slotId }); - return Promise.resolve(); + return Promise.resolve(false); }, downloadSlot: (slotId) => { postFrontend({ name: "downloadSlot", slotId }); @@ -132,6 +134,20 @@ export function BottomDisplayApp() { selectInfusion: (infusionId) => postFrontend({ name: "selectInfusion", infusionId }), selectPassiveAbility: (abilityId) => postFrontend({ name: "selectPassiveAbility", abilityId }), selectPassiveInfusion: (passiveId) => postFrontend({ name: "selectPassiveInfusion", passiveId }), + selectProfileCollectionView: (view) => postFrontend({ name: "selectProfileCollectionView", view }), + selectProfileGroup: (groupId) => postFrontend({ name: "selectProfileGroup", groupId }), + selectProfileStat: (statId) => postFrontend({ name: "selectProfileStat", statId }), + openAppearanceLab: () => postFrontend({ name: "openAppearanceLab" }), + selectAppearanceClass: (classId) => postFrontend({ name: "selectAppearanceClass", classId }), + updateAppearanceDraft: (appearance) => postFrontend({ name: "updateAppearanceDraft", appearance }), + resetAppearanceDraft: () => postFrontend({ name: "resetAppearanceDraft" }), + saveAppearanceDraft: () => { + postFrontend({ name: "saveAppearanceDraft" }); + return false; + }, + closeAppearanceLab: () => postFrontend({ name: "closeAppearanceLab" }), + setAppearancePreviewMode: (mode) => postFrontend({ name: "setAppearancePreviewMode", mode }), + setAppearancePreviewAnimation: (animation) => postFrontend({ name: "setAppearancePreviewAnimation", animation }), upgradeSelectedGear: () => { postFrontend({ name: "upgradeSelectedGear" }); return false; @@ -174,6 +190,13 @@ export function BottomDisplayApp() { return false; }, setEndlessChoiceSelection: (selection) => postCommand({ name: "setEndlessChoiceSelection", selection }), + dispatchRpgAction: (action) => { + postCommand({ name: "dispatchRpgAction", action }); + return false; + }, + setRpgFocusId: (focusId) => postCommand({ name: "setRpgFocusId", focusId }), + cycleRpgFocus: (direction) => postCommand({ name: "cycleRpgFocus", direction }), + moveRpgFocus: (direction) => postCommand({ name: "moveRpgFocus", direction }), }); channel.onmessage = (event: MessageEvent) => { if (event.data.type === "authoritative-ready") { diff --git a/src/platform/dualScreenSync.test.ts b/src/platform/dualScreenSync.test.ts index 2e52147..dc9ea48 100644 --- a/src/platform/dualScreenSync.test.ts +++ b/src/platform/dualScreenSync.test.ts @@ -1,16 +1,32 @@ import { describe, expect, it } from "vitest"; +import { createClassAbilityLoadout } from "../game/healers"; import { diffBottomGameSnapshot, executeFrontendCommand, executeGameCommand, type BottomGameSnapshot } from "./dualScreenSync"; import { useGameStore } from "../game/store"; -import { useFrontendStore } from "../frontend/store"; +import { getFrontendSnapshot, useFrontendStore } from "../frontend/store"; +import { createHockeyHealingState } from "../game/hockeyHealing"; +import { createBlockbreakerState } from "../game/blockbreaker"; +import { createAetherAssaultState } from "../game/aetherAssault"; +import { createHockeyPvpState } from "../game/hockeyHealingPvp"; +import { freshParty } from "../game/data"; +import { createBossMotionState, createBossState } from "../game/bossMechanics"; +import { createPartyCombatState } from "../game/partyCombat"; +import { createDefaultHealerAppearance } from "../game/healerVisuals"; +import { heldItemForModel } from "../game/weaponCatalog"; function snapshot(): BottomGameSnapshot { return { bossId: "bulldrome", bossInstanceId: "boss-0-bulldrome", paused: false, - healerClassId: "priest", + healerClassId: "priest", + abilityLoadout: createClassAbilityLoadout("priest"), phase: "combat", round: 1, + runMode: "encounter", + activityMode: "boss", + rpgRun: null, + rpgFocusId: null, + rpgSpellResources: { verdancy: 0, tidalSurge: 0, conviction: 0, chronoshards: 0 }, endlessMode: false, endlessBossKills: 0, endlessChoiceSelection: "continue", @@ -30,7 +46,7 @@ function snapshot(): BottomGameSnapshot { bossMotion: { bossId: "bulldrome", activeMechanicId: null, formationOffsetX: 0, mode: "holding", position: [0, 0], chargeStart: [0, 0], chargeEnd: [0, 0], chargeTargetId: "aelia", chargeHitIds: [], phaseEndsAt: 0, chargeCount: 0, pounceTargetId: "aelia", pounceCenter: [0, 0], - nextMechanicAt: Infinity, mechanicCount: 0, phaseStartedAt: 0, mechanicHitIds: [], mechanicNextDamageAt: {}, tetherIds: [], tetherBreakDistance: 0, + nextMechanicAt: Infinity, mechanicCount: 0, phaseStartedAt: 0, lastMeleeAt: -1, mechanicHitIds: [], mechanicNextDamageAt: {}, tetherIds: [], tetherBreakDistance: 0, breathAngle: 0, breathStartAngle: 0, breathEndAngle: 0, hazards: [], slashLanes: [], poolMechanicCount: 0, poolTelegraphs: [], }, partyCombat: { @@ -40,20 +56,42 @@ function snapshot(): BottomGameSnapshot { orin: { id: "orin", readyAt: 0, resource: 0, points: 0, cooldowns: {}, activeAction: null, visualAction: null, overchargeStacks: 0, bladeFlurryUntil: 0, revengeReadyUntil: 0, lastHp: 100, damageDone: 0 }, vale: { id: "vale", readyAt: 0, resource: 0, points: 0, cooldowns: {}, activeAction: null, visualAction: null, overchargeStacks: 0, bladeFlurryUntil: 0, revengeReadyUntil: 0, lastHp: 100, damageDone: 0 }, }, - tankAura: { expiresAt: 0, radius: 3, damageReduction: 0.3 }, + tankAura: { expiresAt: 0, radius: 3, damageReduction: 0.3, sourceId: "brann" }, nextEventId: 1, }, mana: 100, maxMana: 100, selectedMemberId: "brann", - cooldowns: { mend: 0, renew: 0, shield: 0, purify: 0, radiance: 0, barrier: 0 }, + cooldowns: { ability1: 0, ability2: 0, ability3: 0, ability4: 0, ability5: 0, ability6: 0 }, globalCooldownUntil: 0, activeTab: "combat", selectedItemId: "", inventory: [], playerPosition: [0, 0], activeCast: null, - barrier: { center: [0, 0], expiresAt: 0, nextHealAt: 0 }, + barrier: { kind: null, center: [0, 0], expiresAt: 0, nextHealAt: 0 }, + healerMechanic: { + resource: 0, + maxResource: 0, + flourishExpiresAt: 0, + nextPulseAt: 0, + beaconTargetId: null, + beaconExpiresAt: 0, + avengingCrusaderExpiresAt: 0, + temporalAnchor: null, + timeLoop: null, + }, + hockey: createHockeyHealingState(false), + blockbreaker: createBlockbreakerState(false), + aetherAssault: createAetherAssaultState(false), + hockeyPvp: createHockeyPvpState(), + hockeyPvpOpponent: { + party: freshParty(), + partyPositions: { aelia: [0, 4.5], brann: [0, 0], nia: [-3, 2], orin: [3, 2], vale: [0, -2] }, + boss: createBossState("bulldrome"), + bossMotion: createBossMotionState("bulldrome"), + partyCombat: createPartyCombatState(freshParty()), + }, }; } @@ -67,6 +105,14 @@ describe("dual-screen game snapshots", () => { const next = { ...clonedWithoutChanges, time: 10.1, mana: 97 }; expect(diffBottomGameSnapshot(clonedWithoutChanges, next)).toEqual({ time: 10.1, mana: 97 }); + + const hockey = createHockeyHealingState(true); + const hockeyNext = { ...next, runMode: "hockey-healing" as const, hockey }; + expect(diffBottomGameSnapshot(next, hockeyNext)).toEqual({ runMode: "hockey-healing", hockey }); + + const aetherAssault = createAetherAssaultState(true, 42); + const aetherNext = { ...hockeyNext, runMode: "aether-assault" as const, aetherAssault }; + expect(diffBottomGameSnapshot(hockeyNext, aetherNext)).toEqual({ runMode: "aether-assault", aetherAssault }); }); it("routes maxed-run continuation and passive filter commands", () => { @@ -74,6 +120,9 @@ describe("dual-screen game snapshots", () => { const originalStartEndless = useGameStore.getState().startRogueTrialsEndless; const originalSelectAbility = useFrontendStore.getState().selectPassiveAbility; const originalSelectPassive = useFrontendStore.getState().selectPassiveInfusion; + const originalSelectProfileView = useFrontendStore.getState().selectProfileCollectionView; + const originalSelectProfileGroup = useFrontendStore.getState().selectProfileGroup; + const originalSelectProfileStat = useFrontendStore.getState().selectProfileStat; const calls: string[] = []; useGameStore.setState({ continueRoguelikeRound: () => { calls.push("continue"); return true; }, @@ -82,15 +131,105 @@ describe("dual-screen game snapshots", () => { useFrontendStore.setState({ selectPassiveAbility: (abilityId) => { calls.push(`ability:${abilityId}`); }, selectPassiveInfusion: (passiveId) => { calls.push(`passive:${passiveId}`); }, + selectProfileCollectionView: (view) => { calls.push(`profile-view:${view}`); }, + selectProfileGroup: (groupId) => { calls.push(`profile-group:${groupId}`); }, + selectProfileStat: (statId) => { calls.push(`profile-stat:${statId}`); }, }); executeGameCommand({ name: "continueRoguelikeRound" }); executeGameCommand({ name: "startRogueTrialsEndless" }); - executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "shield" }); + executeFrontendCommand({ name: "selectPassiveAbility", abilityId: "ability3" }); executeFrontendCommand({ name: "selectPassiveInfusion", passiveId: "shield-guard" }); - expect(calls).toEqual(["continue", "endless", "ability:shield", "passive:shield-guard"]); + executeFrontendCommand({ name: "selectProfileCollectionView", view: "stats" }); + executeFrontendCommand({ name: "selectProfileGroup", groupId: "bind-venom" }); + executeFrontendCommand({ name: "selectProfileStat", statId: "broodfang-spider" }); + expect(calls).toEqual([ + "continue", + "endless", + "ability:ability3", + "passive:shield-guard", + "profile-view:stats", + "profile-group:bind-venom", + "profile-stat:broodfang-spider", + ]); useGameStore.setState({ continueRoguelikeRound: originalContinue, startRogueTrialsEndless: originalStartEndless }); - useFrontendStore.setState({ selectPassiveAbility: originalSelectAbility, selectPassiveInfusion: originalSelectPassive }); + useFrontendStore.setState({ + selectPassiveAbility: originalSelectAbility, + selectPassiveInfusion: originalSelectPassive, + selectProfileCollectionView: originalSelectProfileView, + selectProfileGroup: originalSelectProfileGroup, + selectProfileStat: originalSelectProfileStat, + }); + }); + + it("includes authoritative profile selection in companion snapshots", () => { + const original = useFrontendStore.getState(); + original.selectProfileCollectionView("stats"); + original.selectProfileGroup("bind-venom"); + original.selectProfileStat("broodfang-spider"); + + const snapshot = getFrontendSnapshot(); + expect(snapshot.profileCollectionView).toBe("stats"); + expect(snapshot.selectedProfileGroupId).toBe("bind-venom"); + expect(snapshot.selectedProfileStatId).toBe("broodfang-spider"); + expect("selectProfileCollectionView" in snapshot).toBe(false); + + useFrontendStore.setState({ + profileCollectionView: original.profileCollectionView, + selectedProfileGroupId: original.selectedProfileGroupId, + selectedProfileStatId: original.selectedProfileStatId, + }); + }); + + it("routes Appearance Lab commands to the authoritative frontend store", () => { + const original = useFrontendStore.getState(); + const calls: string[] = []; + const appearance = { + ...createDefaultHealerAppearance("priest"), + headPartId: "rogue-head" as const, + mainHand: heldItemForModel("cc/staff_d"), + }; + useFrontendStore.setState({ + openAppearanceLab: () => { calls.push("open"); }, + selectAppearanceClass: (classId) => { calls.push(`class:${classId}`); }, + updateAppearanceDraft: (draft) => { calls.push(`draft:${draft.headPartId}:${draft.mainHand.modelId}`); }, + resetAppearanceDraft: () => { calls.push("reset"); }, + saveAppearanceDraft: () => { calls.push("save"); return true; }, + closeAppearanceLab: () => { calls.push("close"); }, + setAppearancePreviewMode: (mode) => { calls.push(`mode:${mode}`); }, + setAppearancePreviewAnimation: (animation) => { calls.push(`animation:${animation}`); }, + }); + + executeFrontendCommand({ name: "openAppearanceLab" }); + executeFrontendCommand({ name: "selectAppearanceClass", classId: "chronomancer" }); + executeFrontendCommand({ name: "updateAppearanceDraft", appearance }); + executeFrontendCommand({ name: "resetAppearanceDraft" }); + executeFrontendCommand({ name: "saveAppearanceDraft" }); + executeFrontendCommand({ name: "setAppearancePreviewMode", mode: "legacy" }); + executeFrontendCommand({ name: "setAppearancePreviewAnimation", animation: "cast" }); + executeFrontendCommand({ name: "closeAppearanceLab" }); + + expect(calls).toEqual([ + "open", + "class:chronomancer", + "draft:rogue-head:cc/staff_d", + "reset", + "save", + "mode:legacy", + "animation:cast", + "close", + ]); + + useFrontendStore.setState({ + openAppearanceLab: original.openAppearanceLab, + selectAppearanceClass: original.selectAppearanceClass, + updateAppearanceDraft: original.updateAppearanceDraft, + resetAppearanceDraft: original.resetAppearanceDraft, + saveAppearanceDraft: original.saveAppearanceDraft, + closeAppearanceLab: original.closeAppearanceLab, + setAppearancePreviewMode: original.setAppearancePreviewMode, + setAppearancePreviewAnimation: original.setAppearancePreviewAnimation, + }); }); }); diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts index 8691ed7..2d1bde7 100644 --- a/src/platform/dualScreenSync.ts +++ b/src/platform/dualScreenSync.ts @@ -1,20 +1,24 @@ -import type { AppScreen } from "../frontend/types"; -import type { FrontendSnapshot } from "../frontend/store"; +import type { AppScreen, ProfileCollectionView, ProfileStatId } from "../frontend/types"; +import type { AppearancePreviewAnimation, FrontendSnapshot } from "../frontend/store"; import { useFrontendStore } from "../frontend/store"; import { emitControllerToken, setExternalControllerMovement, type ControllerMovement, type ControllerTokenEvent } from "../input/controller"; import { type GameState, useGameStore } from "../game/store"; -import type { AbilityId, BottomTab, MemberId, RunBuffId } from "../game/types"; +import type { AbilitySlotId, BottomTab, MemberId, RunBuffId } from "../game/types"; import type { BossId, HealerClassId } from "../game/types"; import type { GameModeId, GameSettings, SaveSlotId } from "../frontend/types"; import type { GearOwnerId, GearSlotId } from "../game/progression/gear"; import type { DifficultySlug } from "../game/progression/loot"; +import type { BossGroupId } from "../game/bossCatalog"; +import type { HockeyPvpMatchConfig } from "../game/hockeyHealingPvp"; +import type { RpgFocusDirection, RpgRoguelikeAction } from "../game/rpgRoguelike"; +import type { CharacterAppearanceV1, CharacterModelMode } from "../game/characterAppearance"; const CHANNEL_NAME = "i-want-to-heal:thor-dual-screen:v1"; export type GameCommand = | { name: "startEncounter" } | { name: "restart" } - | { name: "castAbility"; abilityId: AbilityId } + | { name: "castAbility"; abilityId: AbilitySlotId } | { name: "selectMember"; memberId: MemberId } | { name: "cycleMember"; direction: 1 | -1 } | { name: "setActiveTab"; tab: BottomTab } @@ -25,7 +29,11 @@ export type GameCommand = | { name: "chooseRunBuff"; buffId: RunBuffId } | { name: "continueRoguelikeRound" } | { name: "startRogueTrialsEndless" } - | { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" }; + | { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" } + | { name: "dispatchRpgAction"; action: RpgRoguelikeAction } + | { name: "setRpgFocusId"; focusId: string } + | { name: "cycleRpgFocus"; direction: 1 | -1 } + | { name: "moveRpgFocus"; direction: RpgFocusDirection }; export type FrontendCommand = | { name: "signIn"; username: string; password: string } @@ -47,15 +55,26 @@ export type FrontendCommand = | { name: "selectGearSlot"; slotId: GearSlotId } | { name: "selectGearWorkshopMode"; mode: "upgrade" | "infusion" } | { name: "selectInfusion"; infusionId: string } - | { name: "selectPassiveAbility"; abilityId: AbilityId } + | { name: "selectPassiveAbility"; abilityId: AbilitySlotId } | { name: "selectPassiveInfusion"; passiveId: RunBuffId } + | { name: "selectProfileCollectionView"; view: ProfileCollectionView } + | { name: "selectProfileGroup"; groupId: BossGroupId } + | { name: "selectProfileStat"; statId: ProfileStatId } + | { name: "openAppearanceLab" } + | { name: "selectAppearanceClass"; classId: HealerClassId } + | { name: "updateAppearanceDraft"; appearance: CharacterAppearanceV1 } + | { name: "resetAppearanceDraft" } + | { name: "saveAppearanceDraft" } + | { name: "closeAppearanceLab" } + | { name: "setAppearancePreviewMode"; mode: CharacterModelMode } + | { name: "setAppearancePreviewAnimation"; animation: AppearancePreviewAnimation } | { name: "upgradeSelectedGear" } | { name: "equipSelectedInfusion" } | { name: "equipPassiveInfusion"; passiveId: RunBuffId } | { name: "selectHealerClass"; classId: HealerClassId } | { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] } | { name: "exitGame" } - | { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug }; + | { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig }; export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game"; export const DUAL_SCREEN_EXIT_EVENT = "iwt:dual-screen-exit-game"; @@ -92,6 +111,10 @@ export function executeGameCommand(command: GameCommand) { case "continueRoguelikeRound": game.continueRoguelikeRound(); break; case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break; case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break; + case "dispatchRpgAction": game.dispatchRpgAction(command.action); break; + case "setRpgFocusId": game.setRpgFocusId(command.focusId); break; + case "cycleRpgFocus": game.cycleRpgFocus(command.direction); break; + case "moveRpgFocus": game.moveRpgFocus(command.direction); break; } } @@ -119,13 +142,24 @@ export function executeFrontendCommand(command: FrontendCommand) { case "selectInfusion": frontend.selectInfusion(command.infusionId); break; case "selectPassiveAbility": frontend.selectPassiveAbility(command.abilityId); break; case "selectPassiveInfusion": frontend.selectPassiveInfusion(command.passiveId); break; + case "selectProfileCollectionView": frontend.selectProfileCollectionView(command.view); break; + case "selectProfileGroup": frontend.selectProfileGroup(command.groupId); break; + case "selectProfileStat": frontend.selectProfileStat(command.statId); break; + case "openAppearanceLab": frontend.openAppearanceLab(); break; + case "selectAppearanceClass": frontend.selectAppearanceClass(command.classId); break; + case "updateAppearanceDraft": frontend.updateAppearanceDraft(command.appearance); break; + case "resetAppearanceDraft": frontend.resetAppearanceDraft(); break; + case "saveAppearanceDraft": frontend.saveAppearanceDraft(); break; + case "closeAppearanceLab": frontend.closeAppearanceLab(); break; + case "setAppearancePreviewMode": frontend.setAppearancePreviewMode(command.mode); break; + case "setAppearancePreviewAnimation": frontend.setAppearancePreviewAnimation(command.animation); break; case "upgradeSelectedGear": frontend.upgradeSelectedGear(); break; case "equipSelectedInfusion": frontend.equipSelectedInfusion(); break; case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break; case "selectHealerClass": frontend.selectHealerClass(command.classId); break; case "updateSetting": frontend.updateSetting(command.key, command.value); break; case "exitGame": window.dispatchEvent(new Event(DUAL_SCREEN_EXIT_EVENT)); break; - case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug } })); break; + case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug, hockeyPvpMatch: command.hockeyPvpMatch } })); break; } } @@ -142,8 +176,14 @@ export type BottomGameSnapshot = Pick; const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [ - "bossId", "bossInstanceId", "paused", "healerClassId", "phase", "round", "endlessMode", "endlessBossKills", "endlessChoiceSelection", "runModifiers", "time", "party", "boss", "additionalBosses", + "bossId", "bossInstanceId", "paused", "healerClassId", "abilityLoadout", "phase", "round", "runMode", "activityMode", "rpgRun", "rpgFocusId", "rpgSpellResources", "endlessMode", "endlessBossKills", "endlessChoiceSelection", "runModifiers", "time", "party", "boss", "additionalBosses", "partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns", - "globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier", + "globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier", "healerMechanic", "hockey", "blockbreaker", "aetherAssault", "hockeyPvp", "hockeyPvpOpponent", ]; function structurallyEqual(left: unknown, right: unknown): boolean { @@ -198,8 +244,14 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot { bossInstanceId: state.bossInstanceId, paused: state.paused, healerClassId: state.healerClassId, + abilityLoadout: state.abilityLoadout, phase: state.phase, round: state.round, + runMode: state.runMode, + activityMode: state.activityMode, + rpgRun: state.rpgRun, + rpgFocusId: state.rpgFocusId, + rpgSpellResources: state.rpgSpellResources, endlessMode: state.endlessMode, endlessBossKills: state.endlessBossKills, endlessChoiceSelection: state.endlessChoiceSelection, @@ -222,6 +274,12 @@ export function currentBottomGameSnapshot(): BottomGameSnapshot { playerPosition: state.playerPosition, activeCast: state.activeCast, barrier: state.barrier, + healerMechanic: state.healerMechanic, + hockey: state.hockey, + blockbreaker: state.blockbreaker, + aetherAssault: state.aetherAssault, + hockeyPvp: state.hockeyPvp, + hockeyPvpOpponent: state.hockeyPvpOpponent, }; } diff --git a/src/styles.css b/src/styles.css index 0617216..6092cf3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -374,6 +374,9 @@ button:focus-visible { .boss-bar-wrap.is-trio { top: 1.1%; width: 42%; gap: 2px; } .boss-bar-wrap.is-trio .boss-name strong { font-size: 8px; } .boss-bar-wrap.is-trio .boss-bar { height: 5px; margin-top: 1px; } +.boss-bar-wrap.is-pvp { width: 42%; } +.boss-bar-entry.is-opponent .boss-bar { border-color: rgba(190, 92, 153, 0.52); } +.boss-bar-entry.is-opponent .boss-bar i { background: linear-gradient(90deg, #6f315f, #d65c96); box-shadow: 0 0 9px rgba(214, 92, 150, 0.55); } .boss-name { display: grid; @@ -431,6 +434,25 @@ button:focus-visible { text-transform: uppercase; } +.objective-chip.is-hockey { + border-right-color: #67e8ff; + background: linear-gradient(90deg, rgba(7, 24, 30, 0.62), rgba(5, 18, 24, 0.94)); +} + +.objective-chip.is-hockey span { color: #89efff; } +.objective-chip.is-pvp { border-right-color: #ef6c9f; background: linear-gradient(90deg, rgba(17, 24, 39, 0.64), rgba(37, 11, 25, 0.94)); } +.objective-chip.is-pvp span { color: #ff9cc2; } +.objective-chip.is-blockbreaker { + border-right-color: #a9ef64; + background: linear-gradient(90deg, rgba(9, 28, 32, 0.65), rgba(22, 34, 13, 0.95)); +} +.objective-chip.is-blockbreaker span { color: #baf77e; } +.objective-chip.is-aether { + border-right-color: #66f1ff; + background: linear-gradient(90deg, rgba(11, 37, 48, 0.68), rgba(15, 28, 50, 0.95)); +} +.objective-chip.is-aether span { color: #9ef7ff; } + .objective-chip span { color: var(--gold); font-size: 7px; @@ -463,6 +485,28 @@ button:focus-visible { .encounter-callout.is-urgent { border-color: var(--red); animation: warning-pulse 0.7s ease-in-out infinite alternate; } .encounter-callout.is-urgent strong { color: #ff826b; } +.dampening-indicator { + position: absolute; + top: 13.5%; + right: 2.3%; + width: 148px; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 3px 8px; + padding: 5px 7px 6px 9px; + border: 1px solid rgba(255, 126, 91, 0.38); + border-right: 2px solid #ff795d; + background: linear-gradient(90deg, rgba(38, 13, 12, 0.58), rgba(37, 10, 13, 0.92)); + text-align: right; + text-shadow: 0 1px 3px #000; +} + +.dampening-indicator span { color: #e3a092; font-size: 7px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; } +.dampening-indicator strong { color: #ffb19c; font-size: 12px; } +.dampening-indicator > i { grid-column: 1 / -1; height: 3px; overflow: hidden; background: #240908; } +.dampening-indicator > i em { display: block; height: 100%; background: linear-gradient(90deg, #c84635, #ff9776); box-shadow: 0 0 6px rgba(255, 104, 72, 0.75); transition: width 180ms ease; } + .casting-bar { position: absolute; left: 50%; @@ -570,6 +614,72 @@ button:focus-visible { .phase-intermission { background: radial-gradient(circle at center, rgba(94, 76, 26, 0.38), rgba(3, 9, 8, 0.84)); } .phase-defeat { background: radial-gradient(circle at center, rgba(85, 31, 21, 0.45), rgba(6, 5, 4, 0.86)); } +.goal-popup { + position: absolute; + z-index: 11; + top: 44%; + left: 50%; + min-width: 26%; + display: grid; + place-items: center; + padding: 10px 28px 12px; + border-top: 2px solid #9df4ff; + border-bottom: 2px solid #9df4ff; + color: #eaffff; + background: linear-gradient(90deg, transparent, rgba(13, 91, 104, 0.88) 20%, rgba(8, 43, 48, 0.94) 50%, rgba(13, 91, 104, 0.88) 80%, transparent); + filter: drop-shadow(0 0 16px rgba(83, 228, 255, 0.72)); + pointer-events: none; + transform: translate(-50%, -50%); + animation: goal-popup-burst 1.2s ease-out both; +} + +.goal-popup::before, +.goal-popup::after { + content: ""; + position: absolute; + top: 50%; + width: 34%; + height: 1px; + background: linear-gradient(90deg, transparent, #b8f8ff); +} + +.goal-popup::before { right: 100%; } +.goal-popup::after { left: 100%; transform: scaleX(-1); } +.goal-popup strong { font: 700 clamp(34px, 7.2vw, 76px) "Cinzel", serif; letter-spacing: 0.16em; text-indent: 0.16em; text-shadow: 0 0 10px #59dff5, 0 3px 5px #001315; } +.goal-popup.is-conceded { border-color: #ffb087; background: linear-gradient(90deg, transparent, rgba(132, 47, 31, 0.88) 20%, rgba(58, 19, 14, 0.94) 50%, rgba(132, 47, 31, 0.88) 80%, transparent); filter: drop-shadow(0 0 16px rgba(255, 111, 73, 0.74)); } +.goal-popup.is-conceded::before, +.goal-popup.is-conceded::after { background: linear-gradient(90deg, transparent, #ffd0ac); } +.goal-popup.is-conceded strong { text-shadow: 0 0 10px #ff815e, 0 3px 5px #190400; } + +.blockbreaker-score-popup { + position: absolute; + z-index: 11; + top: 58%; + left: 50%; + min-width: 150px; + display: grid; + justify-items: center; + padding: 8px 22px 10px; + border-top: 1px solid #a9ef64; + border-bottom: 1px solid #45d9ec; + color: #f2ffe7; + background: linear-gradient(90deg, transparent, rgba(28, 70, 35, 0.9) 30%, rgba(9, 44, 49, 0.92) 70%, transparent); + filter: drop-shadow(0 0 13px rgba(131, 235, 102, 0.58)); + pointer-events: none; + transform: translate(-50%, -50%); + animation: blockbreaker-score-burst 850ms ease-out both; +} +.blockbreaker-score-popup strong { font: 700 clamp(24px, 4.5vw, 48px) "Cinzel", serif; letter-spacing: .06em; text-shadow: 0 0 9px rgba(84, 231, 239, .8); } +.blockbreaker-score-popup small { color: #bef29b; font-size: clamp(7px, 1.1vw, 11px); font-weight: 700; letter-spacing: .14em; text-transform: uppercase; } +.aether-score-popup { + border-top-color: #ffcf77; + border-bottom-color: #66efff; + color: #f1fdff; + background: linear-gradient(90deg, transparent, rgba(19, 52, 76, .9) 28%, rgba(35, 32, 76, .92) 72%, transparent); + filter: drop-shadow(0 0 13px rgba(102, 239, 255, .62)); +} +.aether-score-popup small { color: #9ef7ff; } + .pause-overlay { position: absolute; z-index: 12; inset: 0; display: grid; place-items: center; background: rgba(2,8,7,0.76); backdrop-filter: blur(5px); pointer-events: auto; } .pause-panel { width: min(370px, 62%); display: grid; justify-items: center; padding: 25px 30px 20px; border: 1px solid rgba(232,200,114,0.48); background: linear-gradient(145deg, rgba(12,29,24,0.97), rgba(4,11,9,0.98)); box-shadow: 0 18px 55px rgba(0,0,0,0.6), inset 0 0 30px rgba(232,200,114,0.035); text-align: center; } .pause-panel > span { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; } @@ -578,7 +688,7 @@ button:focus-visible { .pause-actions { width: 100%; display: grid; gap: 7px; } .pause-actions button { display: flex; align-items: center; justify-content: space-between; min-height: 38px; padding: 8px 12px; border: 1px solid var(--gold); color: #172019; background: linear-gradient(110deg, #ffe499, #d1af54); font-family: "Cinzel", serif; font-size: 10px; text-align: left; } .pause-actions button.secondary { border-color: rgba(197,218,209,0.25); color: #dce9e4; background: rgba(8,20,17,0.9); } -.pause-actions button.is-controller-focused { outline: 2px solid #fff1b6; outline-offset: 2px; } +.pause-actions button.is-controller-selected { outline: 2px solid #fff1b6; outline-offset: 2px; } .pause-actions small { font-family: Inter, sans-serif; font-size: 6px; letter-spacing: 0.08em; } .pause-panel footer { display: flex; align-items: center; gap: 6px; margin-top: 14px; color: #72877f; font-size: 7px; text-transform: uppercase; } .pause-panel footer b { color: #dce9e4; } @@ -799,6 +909,8 @@ button:focus-visible { .shield-effect { border: 1px solid #63bfff; color: #9edbff; background: #153d58; } .renew-effect { border: 1px solid #59c888; color: #b5f1cd; background: #163e29; } +.earth-shield-effect { border: 1px solid #d2b66c; color: #fff0ae; background: #51451b; } +.spirit-link-effect { border: 1px solid #a28cff; color: #e2d8ff; background: #352759; box-shadow: 0 0 7px rgba(162, 140, 255, 0.5); } .barrier-effect { border: 1px solid #ebca60; color: #fff1a8; background: #554715; box-shadow: 0 0 7px rgba(235, 202, 96, 0.55); } .tank-aura-effect { border: 1px solid #66bdf2; color: #cceeff; background: #174764; box-shadow: 0 0 7px rgba(102, 189, 242, 0.55); } .debuff-effect { border: 1px solid #ff795e; color: white; background: #9a3528; box-shadow: 0 0 8px rgba(229, 80, 54, 0.66); animation: warning-pulse 0.6s infinite alternate; } @@ -822,6 +934,28 @@ button:focus-visible { background: rgba(15, 31, 27, 0.66); } +.ability-meta.is-hockey { grid-template-columns: 0.85fr 1.1fr 1fr; } + +.hockey-run-meta { + min-width: 0; + display: grid; + grid-template-columns: auto auto; + align-items: baseline; + gap: 0 5px; + padding-left: 7px; + border-left: 1px solid rgba(103, 232, 255, 0.2); +} + +.hockey-run-meta span { color: #70a8b1; font-size: 7px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; } +.hockey-run-meta strong { color: #8ff1ff; font-size: 13px; } +.hockey-run-meta small { grid-column: 1 / -1; overflow: hidden; color: #6d8b90; font-size: 6px; letter-spacing: 0.06em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.pvp-run-meta { border-left-color: rgba(239, 108, 159, 0.32); } +.pvp-run-meta strong { color: #ff9cc2; } +.blockbreaker-run-meta { border-left-color: rgba(169, 239, 100, 0.32); } +.blockbreaker-run-meta strong { color: #baf77e; } +.aether-run-meta { border-left-color: rgba(102, 241, 255, 0.35); } +.aether-run-meta strong { color: #9ef7ff; } + .target-chip, .mana-wrap { min-width: 0; @@ -830,6 +964,11 @@ button:focus-visible { gap: 6px; } +.resource-stack { min-width: 0; display: grid; gap: 2px; } +.class-resource { min-width: 0; display: flex; align-items: baseline; justify-content: space-between; gap: 6px; } +.class-resource span { overflow: hidden; color: #6fa984; font-size: 6px; font-weight: 700; letter-spacing: 0.09em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.class-resource b { color: #9be9ae; font-size: 8px; white-space: nowrap; } + .target-chip span, .mana-wrap span { color: #63786f; font-size: 7px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; } .target-chip strong { overflow: hidden; color: var(--gold-strong); font-size: 11px; letter-spacing: 0.07em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } @@ -1076,7 +1215,7 @@ button:focus-visible { .end-actions { display: flex; gap: 9px; margin-top: 18px; } .end-actions button { padding: 9px 18px; border: 1px solid var(--gold); color: #13170f; background: var(--gold); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; } .end-actions button.secondary { color: #9eafa8; background: transparent; border-color: #43564f; } -.end-actions button.is-controller-focused { outline: 2px solid #fff1b6; outline-offset: 2px; } +.end-actions button.is-controller-selected { outline: 2px solid #fff1b6; outline-offset: 2px; } .endless-choice-actions button:first-child { display: grid; gap: 3px; min-width: 155px; } .endless-choice-actions button:first-child small { font-size: 7px; font-weight: 600; letter-spacing: 0.06em; opacity: 0.72; } @@ -1140,9 +1279,9 @@ button:focus-visible { background: linear-gradient(155deg, color-mix(in srgb, var(--buff-accent), #081310 91%), rgba(5, 13, 11, 0.94)); text-align: left; } -.buff-choice-grid button.is-controller-focused { outline: 2px solid var(--gold-strong); outline-offset: 2px; transform: translateY(-2px); } +.buff-choice-grid button.is-controller-selected { outline: 2px solid var(--gold-strong); outline-offset: 2px; transform: translateY(-2px); } .buff-draft.is-input-locked .buff-choice-grid button { cursor: default; filter: saturate(0.55) brightness(0.78); } -.buff-draft.is-input-locked .buff-choice-grid button.is-controller-focused { outline-color: #60726b; transform: none; } +.buff-draft.is-input-locked .buff-choice-grid button.is-controller-selected { outline-color: #60726b; transform: none; } .buff-choice-grid button > i { grid-row: 1 / 3; width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid var(--buff-accent); color: var(--buff-accent); font-family: "Cinzel", serif; font-size: 16px; font-style: normal; } .buff-choice-grid button > span { min-width: 0; display: grid; } .buff-choice-grid button small { color: var(--buff-accent); font-size: 6px; letter-spacing: 0.1em; text-transform: uppercase; } @@ -1179,6 +1318,11 @@ button:focus-visible { .legend-party { background: var(--gold); } .legend-boss { background: var(--red); } .legend-exit { border: 1px solid var(--teal); } +.legend-paddle { width: 12px !important; border-radius: 2px !important; background: #ff9a61; box-shadow: 0 0 5px rgba(255, 118, 79, 0.7); } +.legend-opponent { background: #ef6c9f; box-shadow: 0 0 5px rgba(239, 108, 159, 0.65); } +.legend-brick { border-radius: 1px !important; background: linear-gradient(90deg, #36d9ef 0 25%, #f1b74f 25% 50%, #e85aa9 50% 75%, #93db54 75%); } +.legend-ship { border-radius: 1px !important; background: #66eaff; box-shadow: 0 0 5px rgba(102, 234, 255, .72); transform: rotate(45deg); } +.legend-shot { background: #ff2518; box-shadow: 0 0 5px rgba(255, 37, 24, .78); } .map-canvas { position: relative; @@ -1192,11 +1336,27 @@ button:focus-visible { } .map-canvas svg { width: auto; height: 94%; } +.hockey-map-room { stroke: rgba(111, 218, 235, 0.36); stroke-width: 2; } +.hockey-map-goal { fill: none; stroke-width: 5; stroke-linecap: round; } +.hockey-map-goal.is-npc { stroke: #e76a5b; filter: drop-shadow(0 0 4px rgba(231, 106, 91, 0.65)); } +.hockey-map-goal.is-healer { stroke: #67e8ff; filter: drop-shadow(0 0 4px rgba(103, 232, 255, 0.7)); } +.hockey-map-puck { fill: #ecfdff; stroke: #4cddff; stroke-width: 2; filter: drop-shadow(0 0 5px rgba(76, 221, 255, 0.9)); } +.hockey-map-paddle { fill: #ff8a5b; stroke: #ffe0a8; stroke-width: 1.4; filter: drop-shadow(0 0 5px rgba(255, 91, 61, 0.85)); } +.blockbreaker-map-danger { fill: none; stroke: #ff6d5d; stroke-width: 2; stroke-dasharray: 5 3; filter: drop-shadow(0 0 4px rgba(255, 83, 66, .75)); } +.blockbreaker-map-brick { stroke: rgba(244, 255, 245, .76); stroke-width: .8; filter: drop-shadow(0 0 2px rgba(190, 246, 220, .42)); } +.aether-map-lanes { stroke: rgba(67, 185, 205, .34); stroke-dasharray: 6 5; } +.aether-map-ship { fill: #34cee7; stroke: #e0fcff; stroke-width: 1; filter: drop-shadow(0 0 4px rgba(52, 206, 231, .8)); } +.aether-map-ship.is-armored { fill: #e8aa58; stroke: #fff0c8; filter: drop-shadow(0 0 4px rgba(232, 170, 88, .82)); } +.aether-map-warning { fill: rgba(255, 122, 78, .12); stroke: #ff7a4e; stroke-width: 1.5; stroke-dasharray: 3 2; animation: map-pulse 1s infinite; transform-box: fill-box; transform-origin: center; } +.aether-map-player-shot { fill: #f8ffff; stroke: #55e9ff; stroke-width: 1; } +.aether-map-enemy-shot { fill: #ff2518; stroke: #8f0b05; stroke-width: 1; filter: drop-shadow(0 0 3px rgba(255, 37, 24, .86)); } .map-room { stroke: #36554a; stroke-width: 2; } .map-ring { fill: none; stroke: #2b493f; stroke-width: 1; stroke-dasharray: 3 4; } .map-glyph { fill: none; stroke: #755f34; stroke-width: 1; opacity: 0.7; } .map-pillar { fill: #315247; stroke: #66877a; stroke-width: 1; } .map-boss { fill: #8c3929; stroke: #ff8a63; stroke-width: 2; filter: drop-shadow(0 0 5px #b74731); } +.map-boss.is-opponent { fill: #742c5c; stroke: #ff84bd; filter: drop-shadow(0 0 5px #b63f7b); } +.map-opponent { fill: #ef6c9f !important; stroke: #ffe2ee !important; } .map-boss-arrow { fill: #ff9f74; } .map-player-pulse { fill: rgba(237, 205, 116, 0.14); stroke: #e8c872; stroke-width: 1; animation: map-pulse 1.2s infinite; transform-box: fill-box; transform-origin: center; } .map-barrier { fill: rgba(237, 201, 83, 0.18); stroke: #f4d96f; stroke-width: 1.5; stroke-dasharray: 3 2; } @@ -1216,6 +1376,63 @@ button:focus-visible { padding: 4%; } +/* PVP opponent telemetry */ + +.pvp-panel { + height: 100%; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 3%; + padding: 3.2% 4%; + background: + radial-gradient(circle at 84% -8%, rgba(226, 73, 134, 0.12), transparent 38%), + linear-gradient(145deg, rgba(12, 28, 25, 0.3), rgba(13, 8, 18, 0.28)); +} + +.pvp-roster-header { + min-height: 58px; + display: grid; + grid-template-columns: minmax(150px, 1.8fr) repeat(3, minmax(72px, 0.72fr)); + align-items: stretch; + border: 1px solid rgba(239, 108, 159, 0.3); + border-left: 3px solid #ef6c9f; + background: linear-gradient(90deg, rgba(80, 22, 59, 0.42), rgba(10, 22, 20, 0.88)); + box-shadow: inset 0 0 22px rgba(239, 108, 159, 0.035); +} + +.pvp-roster-header > span, +.pvp-roster-header > div { min-width: 0; display: grid; align-content: center; gap: 3px; padding: 8px 12px; } +.pvp-roster-header > div { border-left: 1px solid rgba(239, 108, 159, 0.16); text-align: center; } +.pvp-roster-header small { color: #9b7086; font-size: clamp(6px, 1.3cqw, 8px); font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; } +.pvp-roster-header strong { overflow: hidden; color: #f6e9ef; font: 600 clamp(11px, 2.5cqw, 16px) "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; } +.pvp-roster-header > div strong { color: #ff9fc4; } + +.pvp-party-list { + min-height: 0; + display: grid; + grid-template-rows: 22px repeat(5, minmax(0, 1fr)); + gap: 1.5%; +} + +.pvp-party-frame { + min-height: 0; + display: grid; + grid-template-columns: 42px minmax(0, 1fr) 54px; + align-items: center; + gap: 9px; + padding: 4px 9px 4px 6px; + overflow: hidden; + border: 1px solid rgba(194, 124, 158, 0.18); + border-left: 2px solid rgba(239, 108, 159, 0.54); + border-radius: 4px; + background: linear-gradient(90deg, rgba(39, 17, 31, 0.9), rgba(9, 24, 20, 0.82)); +} + +.pvp-party-frame > b { color: #d697b4; font-size: clamp(8px, 1.7cqw, 10px); letter-spacing: 0.06em; text-align: right; } +.pvp-party-frame .health-bar > i { background: linear-gradient(90deg, #8e345f, #ef6c9f); } +.pvp-party-frame .health-bar > i.is-low { background: linear-gradient(90deg, #8d392d, #e45b45); } +.pvp-party-frame.is-down { filter: grayscale(0.85); opacity: 0.46; } + .item-list { min-width: 0; display: grid; @@ -1286,6 +1503,21 @@ button:focus-visible { to { transform: scale(1.5); opacity: 0; } } +@keyframes goal-popup-burst { + 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.55); } + 16% { opacity: 1; transform: translate(-50%, -50%) scale(1.08); } + 26% { transform: translate(-50%, -50%) scale(1); } + 76% { opacity: 1; transform: translate(-50%, -50%) scale(1); } + 100% { opacity: 0; transform: translate(-50%, -50%) scale(1.05); } +} + +@keyframes blockbreaker-score-burst { + 0% { opacity: 0; transform: translate(-50%, -42%) scale(.72); } + 20% { opacity: 1; transform: translate(-50%, -50%) scale(1.06); } + 72% { opacity: 1; transform: translate(-50%, -58%) scale(1); } + 100% { opacity: 0; transform: translate(-50%, -68%) scale(.98); } +} + @media (max-width: 760px) { .app-shell { padding: 10px 7px 45px; } .app-header { padding: 0 4px; } @@ -1311,6 +1543,9 @@ button:focus-visible { .encounter-callout { bottom: 8%; padding: 3px 5px; } .encounter-callout span { font-size: 5px; } .encounter-callout strong { font-size: 8px; } + .dampening-indicator { top: 15%; width: 106px; gap: 2px 5px; padding: 3px 4px 4px 6px; } + .dampening-indicator span { font-size: 5px; } + .dampening-indicator strong { font-size: 8px; } .casting-bar { bottom: 7.5%; min-width: 120px; padding: 3px 5px 4px; } .casting-bar strong, .casting-bar > b { font-size: 7px; } .casting-bar small { display: none; } @@ -1326,6 +1561,8 @@ button:focus-visible { animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; } + .goal-popup { animation: none !important; } + .blockbreaker-score-popup { animation: none !important; } } /* Capacitor single-display fallback. Thor dual-display routing will replace this @@ -1544,7 +1781,7 @@ button:focus-visible { opacity: 0.35; } -.front-surface button.is-controller-focused, +.front-surface button.is-controller-selected, .front-surface button:focus-visible { outline: 2px solid var(--gold-strong); outline-offset: 2px; @@ -1673,7 +1910,7 @@ button:focus-visible { .login-panel label { color: #81978e; font-size: 8px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; } .login-panel input { height: 36px; padding: 0 11px; border: 1px solid rgba(155,190,176,0.28); outline: 0; color: #e8f2ee; background: #071310; font: 600 12px "Rajdhani", sans-serif; } .login-panel input:focus, -.login-panel input.is-controller-focused { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(232,200,114,0.12); } +.login-panel input.is-controller-selected { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(232,200,114,0.12); } .login-panel .front-primary, .login-panel .front-secondary { min-height: 40px; padding-top: 6px; padding-bottom: 6px; } .login-surface > .front-notice { position: absolute; right: 44px; bottom: 83px; width: 300px; } @@ -1737,7 +1974,8 @@ button:focus-visible { .front-dialog form > span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; } .front-dialog form > label { justify-self: start; margin-top: 12px; color: #82978f; font-size: 8px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; } .front-dialog form > input { width: 100%; height: 38px; margin-top: 5px; padding: 0 11px; border: 1px solid rgba(155,190,176,0.32); outline: 0; color: #eef8f4; background: #06120f; font: 600 13px "Rajdhani", sans-serif; text-align: center; } -.front-dialog form > input:focus { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(232,200,114,0.14); } +.front-dialog form > input:focus, +.front-dialog form > input.is-controller-selected { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(232,200,114,0.14); } .front-dialog form > small { justify-self: end; margin-top: 3px; color: #647a71; font-size: 7px; } .front-dialog h2 { margin: 7px 0; font-family: "Cinzel", serif; font-size: 24px; font-weight: 500; } .front-dialog p { margin: 0; color: #8ca198; font-size: 11px; line-height: 1.4; } @@ -1797,9 +2035,13 @@ button:focus-visible { .home-header > span { margin-left: auto; color: #8fa39b; font-size: 10px; } .home-header > span b { color: #dce9e4; } .home-header > i { color: #6ecaa7; font-size: 8px; font-style: normal; font-weight: 700; letter-spacing: 0.08em; } -.mode-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, 78px); gap: 10px; margin-top: 18px; } +.mode-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, 78px); grid-auto-rows: 78px; gap: 10px; margin-top: 18px; } .mode-card { position: relative; display: grid; grid-template-columns: 54px 1fr 17px; align-items: center; gap: 12px; padding: 13px; overflow: hidden; text-align: left; } .mode-card::after { position: absolute; inset: 0; content: ""; background: linear-gradient(110deg, rgba(69,153,131,0.13), transparent 60%); pointer-events: none; } +.mode-card-blockbreaker::after { background: linear-gradient(110deg, rgba(54,217,239,.15), rgba(232,90,169,.1) 54%, rgba(147,219,84,.12)); } +.mode-card-blockbreaker > i { border-radius: 5px; color: #baf77e; box-shadow: inset 0 0 13px rgba(54,217,239,.09); } +.mode-card-aether-assault::after { background: linear-gradient(110deg, rgba(66, 222, 242, .18), rgba(100, 110, 225, .11) 62%, transparent); } +.mode-card-aether-assault > i { border-radius: 5px; color: #8cf5ff; box-shadow: inset 0 0 13px rgba(66, 222, 242, .1); } .mode-card.is-wide { grid-row: 1 / 3; } .mode-card > i { width: 48px; height: 48px; display: grid; place-items: center; border: 1px solid rgba(232,200,114,0.42); border-radius: 50%; color: var(--gold); background: rgba(2,9,7,0.45); font-family: "Cinzel", serif; font-size: 20px; font-style: normal; } .mode-card.is-wide > i { width: 64px; height: 64px; font-size: 27px; } @@ -1907,7 +2149,7 @@ button:focus-visible { .trophy-note { margin-top: 8px; } .boss-stats-heading { padding-top: 10px; } .boss-stats-layout { height: 331px; display: grid; grid-template-columns: minmax(235px, .78fr) minmax(0, 1.22fr); gap: 11px; } -.boss-stat-selector { min-width: 0; display: grid; align-content: start; gap: 5px; } +.boss-stat-selector { min-width: 0; display: grid; align-content: start; gap: 5px; overflow-y: auto; scrollbar-width: thin; } .boss-stat-selector button { width: 100%; min-height: 51px; display: grid; grid-template-columns: 30px minmax(0, 1fr) 32px; align-items: center; gap: 8px; padding: 6px 9px; text-align: left; } .boss-stat-selector button.is-selected { border-color: var(--gold); box-shadow: inset 3px 0 var(--gold); background: linear-gradient(90deg, rgba(93,73,23,.27), rgba(8,18,15,.88)); } .boss-stat-selector button > i { width: 27px; height: 27px; display: grid; place-items: center; border: 1px solid #496159; color: var(--gold); font-style: normal; } @@ -1920,9 +2162,11 @@ button:focus-visible { .leaderboard-panel > header span { min-width: 0; display: grid; } .leaderboard-panel > header small { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; } .leaderboard-panel > header strong { overflow: hidden; font: 500 13px "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; } -.leaderboard-panel > header > b { color: #88a79b; font-size: 8px; text-transform: uppercase; } +.leaderboard-header-actions { display: flex; align-items: center; gap: 8px; } +.leaderboard-header-actions > b { color: #88a79b; font-size: 8px; text-transform: uppercase; white-space: nowrap; } +.leaderboard-refresh { min-height: 27px; padding: 4px 7px; color: var(--gold-strong); font-size: 7px; font-weight: 700; text-transform: uppercase; } .leaderboard-rows { min-height: 218px; padding: 4px 8px; } -.leaderboard-rows > div, .leaderboard-self { min-height: 40px; display: grid; grid-template-columns: 34px minmax(0, 1fr) 44px; align-items: center; gap: 8px; padding: 4px 7px; border-bottom: 1px solid rgba(164,195,184,.1); } +.leaderboard-rows > div, .leaderboard-self { min-height: 40px; display: grid; grid-template-columns: 34px minmax(0, 1fr) 64px; align-items: center; gap: 8px; padding: 4px 7px; border-bottom: 1px solid rgba(164,195,184,.1); } .leaderboard-rows > div.is-you { background: rgba(102,81,24,.2); } .leaderboard-rows > div > b, .leaderboard-self > b { color: var(--gold); font: 600 12px "Cinzel", serif; } .leaderboard-rows span, .leaderboard-self span { min-width: 0; display: grid; } @@ -1934,11 +2178,16 @@ button:focus-visible { .leaderboard-empty { min-height: 200px !important; border: 0 !important; } .profile-context { padding: 0 5.5% 18px; } .profile-context .context-header { margin: 0 -5.8%; } -.profile-stats { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); border-bottom: 1px solid var(--line); } +.profile-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-bottom: 1px solid var(--line); } .profile-stats span { min-width: 0; display: grid; padding: 8px 6px; border-right: 1px solid var(--line); } .profile-stats span:last-child { border-right: 0; } .profile-stats small { color: #6d8179; font-size: clamp(7px, 1.5cqw, 9px); text-transform: uppercase; } .profile-stats strong { color: var(--gold-strong); font-family: "Cinzel", serif; font-size: clamp(14px, 2.8cqw, 17px); font-weight: 500; } +.blockbreaker-profile-records { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 7px 0; border: 1px solid rgba(147,219,84,.22); background: linear-gradient(90deg, rgba(54,217,239,.07), rgba(232,90,169,.06), rgba(147,219,84,.07)); } +.blockbreaker-profile-records > span { min-width: 0; display: grid; padding: 6px 9px; border-right: 1px solid rgba(147,219,84,.15); } +.blockbreaker-profile-records > span:last-child { border-right: 0; } +.blockbreaker-profile-records small { color: #78968b; font-size: clamp(6px, 1.25cqw, 8px); letter-spacing: .07em; text-transform: uppercase; } +.blockbreaker-profile-records strong { overflow: hidden; color: #baf77e; font: 500 clamp(12px, 2.4cqw, 15px) "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; } .boss-log { margin-top: 7px; } .boss-log > span { color: #6c8179; font-size: 8px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; } .boss-log button { width: 100%; min-height: 36px; display: grid; grid-template-columns: 28px 1fr 30px; align-items: center; gap: 7px; margin-top: 3px; padding: 3px 8px; text-align: left; } @@ -1949,6 +2198,77 @@ button:focus-visible { .boss-log small { color: #687d75; font-size: clamp(7px, 1.45cqw, 9px); text-transform: uppercase; } .boss-log button > b { color: #85a198; font-size: 10px; } +.boss-index-panel, +.profile-records-panel { min-width: 0; height: 100%; overflow: hidden; } +.boss-index-panel { border: 1px solid #435950; background: linear-gradient(180deg, rgba(11,25,21,.96), rgba(5,13,11,.96)); } +.boss-index-panel > header, +.profile-records-panel > header { height: 43px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px 10px; border: 1px solid #435950; background: linear-gradient(90deg, rgba(76,59,20,.25), rgba(8,20,17,.92)); } +.boss-index-panel > header { border-width: 0 0 1px; } +.boss-index-panel > header span, +.profile-records-panel > header span { min-width: 0; display: grid; } +.boss-index-panel > header small, +.profile-records-panel > header small { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; } +.boss-index-panel > header strong, +.profile-records-panel > header strong { overflow: hidden; font: 500 12px "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; } +.boss-index-panel > header > b, +.profile-records-panel > header > b { color: #8ca59c; font-size: 8px; letter-spacing: .06em; text-transform: uppercase; white-space: nowrap; } +.boss-pet-grid { height: calc(100% - 43px); display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); grid-template-rows: repeat(4, minmax(0, 1fr)); gap: 4px; padding: 5px; } +.boss-pet-card { --boss-accent: var(--gold); position: relative; min-width: 0; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto auto; align-items: end; gap: 1px; overflow: hidden; padding: 5px 5px 4px; border-color: color-mix(in srgb, var(--boss-accent) 30%, #40544d); background: radial-gradient(circle at 48% 35%, color-mix(in srgb, var(--boss-accent) 17%, transparent), transparent 48%), rgba(6,16,13,.92); text-align: left; } +.boss-pet-card::after { content: ""; position: absolute; inset: 2px; border: 1px solid rgba(220,237,229,.045); pointer-events: none; } +.boss-pet-card.is-selected { border-color: color-mix(in srgb, var(--boss-accent) 72%, var(--gold)); box-shadow: inset 0 0 14px color-mix(in srgb, var(--boss-accent) 13%, transparent), 0 0 0 1px color-mix(in srgb, var(--boss-accent) 28%, transparent); } +.boss-pet-icon { align-self: center; justify-self: center; color: color-mix(in srgb, var(--boss-accent) 74%, #fff2c9); font: 500 23px "Cinzel", serif; filter: drop-shadow(0 2px 5px rgba(0,0,0,.65)); } +.boss-pet-card.is-unowned .boss-pet-icon { color: #819089; filter: grayscale(1); opacity: .58; } +.boss-kill-badge { position: absolute; top: 4px; right: 4px; z-index: 2; min-width: 27px; display: grid; padding: 2px 4px; border: 1px solid color-mix(in srgb, var(--boss-accent) 52%, #786e50); color: #fff0bb; background: rgba(3,9,7,.9); text-align: center; } +.boss-kill-badge small { color: #9a9d8d; font-size: 4px; font-weight: 700; letter-spacing: .1em; line-height: 1; text-transform: uppercase; } +.boss-kill-badge b { font: 600 10px "Cinzel", serif; line-height: 1.1; } +.boss-pet-card > strong { position: relative; z-index: 1; overflow: hidden; font-size: 7px; line-height: 1.05; text-overflow: ellipsis; white-space: nowrap; } +.boss-pet-card > small { position: relative; z-index: 1; overflow: hidden; color: color-mix(in srgb, var(--boss-accent) 55%, #74877f); font-size: 5px; line-height: 1.05; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.boss-pet-card.is-unowned > small { color: #68766f; } +.profile-records-panel { display: grid; grid-template-rows: 43px 68px minmax(0, 1fr); gap: 5px; } +.profile-metric-grid { min-width: 0; display: grid; gap: 5px; } +.profile-metric-grid.metric-count-1 { grid-template-columns: 1fr; } +.profile-metric-grid.metric-count-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.profile-metric-grid.metric-count-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.profile-metric-grid button { min-width: 0; display: grid; align-content: center; padding: 6px 9px; border-color: #40584f; background: linear-gradient(145deg, rgba(14,31,26,.94), rgba(6,16,13,.94)); text-align: left; } +.profile-metric-grid button.is-selected { border-color: var(--gold); box-shadow: inset 3px 0 var(--gold); background: linear-gradient(110deg, rgba(91,72,25,.29), rgba(8,20,17,.94)); } +.profile-metric-grid button > small { color: var(--gold); font-size: 6px; font-weight: 700; letter-spacing: .11em; text-transform: uppercase; } +.profile-metric-grid button > strong { overflow: hidden; margin-top: 1px; color: #f0f5f2; font: 500 15px "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; } +.profile-metric-grid button > span { overflow: hidden; color: #758a82; font-size: 6px; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.profile-leaderboard { min-height: 0; display: grid; grid-template-rows: 39px minmax(0, 1fr) 35px; } +.profile-leaderboard > header { height: auto; min-height: 0; padding: 4px 8px; } +.profile-leaderboard .leaderboard-header-actions { gap: 5px; } +.profile-leaderboard .leaderboard-refresh { min-height: 22px; padding: 3px 6px; } +.profile-leaderboard .leaderboard-rows { min-height: 0; overflow: hidden; padding: 2px 6px; } +.profile-leaderboard .leaderboard-rows > div { min-height: 26px; grid-template-columns: 29px minmax(0, 1fr) 55px; gap: 6px; padding: 2px 5px; } +.profile-leaderboard .leaderboard-rows > div > b { font-size: 9px; } +.profile-leaderboard .leaderboard-rows strong { font-size: 7px; } +.profile-leaderboard .leaderboard-rows small { font-size: 5px; } +.profile-leaderboard .leaderboard-rows em { font-size: 11px; } +.profile-leaderboard .leaderboard-self { min-height: 0; margin: 0 6px 4px; grid-template-columns: 29px minmax(0, 1fr) 55px; gap: 6px; padding: 2px 5px; } +.profile-leaderboard .leaderboard-self > b { font-size: 9px; } +.profile-leaderboard .leaderboard-self strong { font-size: 7px; } +.profile-leaderboard .leaderboard-self small { font-size: 5px; } +.profile-leaderboard .leaderboard-self em { font-size: 11px; } +.profile-leaderboard .leaderboard-status, +.profile-leaderboard .leaderboard-empty { min-height: 0 !important; padding: 7px; font-size: 6px; } +.selected-boss-dossier { --boss-accent: var(--gold); min-height: 180px; display: grid; grid-template-columns: 145px minmax(0, 1fr); align-items: center; gap: 19px; margin-top: 17px; padding: 14px; border: 1px solid color-mix(in srgb, var(--boss-accent) 45%, #40564e); background: radial-gradient(circle at 13% 50%, color-mix(in srgb, var(--boss-accent) 18%, transparent), transparent 31%), linear-gradient(120deg, rgba(14,31,26,.96), rgba(6,15,12,.96)); } +.selected-boss-pet { height: 146px; display: grid; place-items: center; align-content: center; gap: 8px; border: 1px solid color-mix(in srgb, var(--boss-accent) 55%, #53645e); background: rgba(3,10,8,.62); } +.selected-boss-pet span { color: color-mix(in srgb, var(--boss-accent) 72%, #fff1bf); font: 500 55px "Cinzel", serif; filter: drop-shadow(0 5px 12px rgba(0,0,0,.7)); } +.selected-boss-pet small { color: color-mix(in srgb, var(--boss-accent) 60%, #82928c); font-size: 7px; letter-spacing: .13em; text-transform: uppercase; } +.selected-boss-pet.is-unowned span { color: #77857f; filter: grayscale(1); opacity: .56; } +.selected-boss-copy { min-width: 0; } +.selected-boss-copy > small { color: var(--boss-accent); font-size: 8px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } +.selected-boss-copy h2 { margin: 5px 0 8px; font: 500 clamp(20px, 4.4cqw, 27px) "Cinzel", serif; } +.selected-boss-copy p { max-width: 390px; margin: 0; color: #91a69e; font-size: clamp(10px, 2.1cqw, 13px); line-height: 1.35; } +.selected-boss-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin-top: 10px; } +.selected-boss-stats span { min-width: 0; display: grid; min-height: 67px; align-content: center; padding: 8px 10px; border: 1px solid var(--line); background: rgba(6,17,14,.82); } +.selected-boss-stats small { color: #748a81; font-size: 7px; letter-spacing: .08em; text-transform: uppercase; } +.selected-boss-stats strong { overflow: hidden; color: var(--gold-strong); font: 500 clamp(13px, 2.7cqw, 17px) "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; } +.selected-boss-mechanics { display: grid; gap: 3px; margin-top: 10px; padding: 10px 12px; border-left: 2px solid var(--gold); background: rgba(61,48,16,.15); } +.selected-boss-mechanics span { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } +.selected-boss-mechanics strong { font-size: clamp(9px, 2cqw, 12px); font-weight: 600; } +.selected-boss-mechanics small { color: #72867f; font-size: clamp(7px, 1.5cqw, 9px); } + /* Settings */ .settings-surface { padding: 0 30px; } @@ -2000,6 +2320,7 @@ button:focus-visible { .mode-surface::after { position: absolute; right: -90px; bottom: -160px; width: 560px; height: 560px; z-index: -1; border: 1px solid rgba(232,200,114,0.13); border-radius: 50%; content: ""; box-shadow: inset 0 0 0 70px rgba(61,142,120,0.025), inset 0 0 0 140px rgba(232,200,114,0.018); } .mode-roguelike-pvp::after { border-color: rgba(192,99,92,0.2); } .mode-stadium-pvp::after { border-color: rgba(91,150,212,0.2); } +.mode-blockbreaker::after { border-color: rgba(147,219,84,.24); box-shadow: inset 0 0 0 70px rgba(54,217,239,.025), inset 0 0 0 140px rgba(232,90,169,.018); } .mode-hero { width: 56%; margin: 52px 0 0 22px; } .mode-hero > span { color: var(--gold); font-size: 9px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; } .mode-hero h2 { margin: 8px 0; font-family: "Cinzel", serif; font-size: 35px; font-weight: 500; line-height: 1; } @@ -2033,6 +2354,8 @@ button:focus-visible { .mode-launch { position: absolute; right: 38px; bottom: 18px; width: 275px; min-height: 52px; display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-color: var(--gold) !important; color: #182019 !important; background: linear-gradient(110deg, #ffe499, #d1af54) !important; text-align: left; } .mode-launch span { font-family: "Cinzel", serif; font-size: 13px; font-weight: 600; } .mode-launch small { font-size: 8px; font-weight: 700; text-transform: uppercase; opacity: 0.65; } +.mode-launch.is-queueing { border-color: #ff8eb9 !important; color: #fbe8ef !important; background: linear-gradient(110deg, #6f2348, #351525) !important; box-shadow: 0 0 18px rgba(239, 108, 159, 0.28); } +.mode-hockey-healing-pvp > .front-notice { right: 330px; bottom: 24px; width: min(360px, calc(100% - 400px)); } .mode-surface > .front-notice { position: absolute; right: 38px; bottom: 20px; width: 360px; } .mode-context { padding: 0 5.5% 18px; } .mode-context .context-header { margin: 0 -5.8%; } @@ -2083,6 +2406,8 @@ button:focus-visible { .force-reduced-motion *, .force-reduced-motion *::before, .force-reduced-motion *::after { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; } +.force-reduced-motion .goal-popup { animation: none !important; } +.force-reduced-motion .blockbreaker-score-popup { animation: none !important; } @media (max-width: 760px) { .bottom-display { width: 100%; } @@ -2145,7 +2470,7 @@ button:focus-visible { .version-actions button small { font-size: 6px; } .home-header { height: 35px; } .home-header > span, .home-header > i { font-size: 5px; } - .mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; margin-top: 6px; } + .mode-grid { grid-template-rows: repeat(2, 43px); grid-auto-rows: 43px; gap: 5px; margin-top: 6px; } .mode-card { grid-template-columns: 25px 1fr 8px; gap: 4px; padding: 4px; } .mode-card > i, .mode-card.is-wide > i { width: 23px; height: 23px; font-size: 10px; } .mode-card strong, .mode-card.is-wide strong { font-size: 8px; } @@ -2202,7 +2527,9 @@ button:focus-visible { .leaderboard-panel > header { height: 35px; padding: 3px 6px; } .leaderboard-panel > header small { font-size: 4px; } .leaderboard-panel > header strong { font-size: 7px; } - .leaderboard-panel > header > b { font-size: 4px; } + .leaderboard-header-actions { gap: 3px; } + .leaderboard-header-actions > b { font-size: 4px; } + .leaderboard-refresh { min-height: 18px; padding: 2px 3px; font-size: 4px; } .leaderboard-rows { min-height: 139px; padding: 2px 4px; } .leaderboard-rows > div, .leaderboard-self { min-height: 25px; grid-template-columns: 20px minmax(0, 1fr) 24px; gap: 3px; padding: 2px 3px; } .leaderboard-rows > div > b, .leaderboard-self > b { font-size: 7px; } @@ -2212,6 +2539,45 @@ button:focus-visible { .leaderboard-self { min-height: 29px; margin: 0 4px 3px; } .leaderboard-status, .leaderboard-empty { min-height: 139px; padding: 7px; font-size: 5px; } .leaderboard-empty { min-height: 130px !important; } + .boss-index-panel > header, + .profile-records-panel > header { height: 26px; gap: 3px; padding: 2px 4px; } + .boss-index-panel > header small, + .profile-records-panel > header small { font-size: 3px; } + .boss-index-panel > header strong, + .profile-records-panel > header strong { font-size: 6px; } + .boss-index-panel > header > b, + .profile-records-panel > header > b { font-size: 3px; } + .boss-pet-grid { height: calc(100% - 26px); gap: 2px; padding: 2px; } + .boss-pet-card { gap: 0; padding: 2px; } + .boss-pet-card::after { inset: 1px; } + .boss-pet-icon { font-size: 12px; } + .boss-kill-badge { top: 2px; right: 2px; min-width: 15px; padding: 1px 2px; } + .boss-kill-badge small { font-size: 2px; } + .boss-kill-badge b { font-size: 5px; } + .boss-pet-card > strong { font-size: 4px; } + .boss-pet-card > small { font-size: 3px; } + .profile-records-panel { grid-template-rows: 26px 40px minmax(0, 1fr); gap: 2px; } + .profile-metric-grid { gap: 2px; } + .profile-metric-grid button { padding: 2px 4px; } + .profile-metric-grid button > small { font-size: 3px; } + .profile-metric-grid button > strong { font-size: 7px; } + .profile-metric-grid button > span { font-size: 3px; } + .profile-leaderboard { grid-template-rows: 25px minmax(0, 1fr) 25px; } + .profile-leaderboard > header { height: auto; padding: 2px 4px; } + .profile-leaderboard .leaderboard-refresh { min-height: 14px; padding: 1px 3px; } + .profile-leaderboard .leaderboard-rows { min-height: 0; padding: 1px 3px; } + .profile-leaderboard .leaderboard-rows > div { min-height: 18px; grid-template-columns: 16px minmax(0, 1fr) 27px; gap: 2px; padding: 1px 2px; } + .profile-leaderboard .leaderboard-rows > div > b, + .profile-leaderboard .leaderboard-self > b { font-size: 4px; } + .profile-leaderboard .leaderboard-rows strong, + .profile-leaderboard .leaderboard-self strong { font-size: 4px; } + .profile-leaderboard .leaderboard-rows small, + .profile-leaderboard .leaderboard-self small { font-size: 2px; } + .profile-leaderboard .leaderboard-rows em, + .profile-leaderboard .leaderboard-self em { font-size: 5px; } + .profile-leaderboard .leaderboard-self { min-height: 0; margin: 0 3px 2px; grid-template-columns: 16px minmax(0, 1fr) 27px; gap: 2px; padding: 1px 2px; } + .profile-leaderboard .leaderboard-status, + .profile-leaderboard .leaderboard-empty { min-height: 0 !important; padding: 3px; font-size: 3px; } .settings-layout { gap: 7px; padding-top: 7px; } .volume-setting { height: 98px; padding: 7px; } .volume-setting strong { font-size: 8px; } @@ -2249,12 +2615,13 @@ button:focus-visible { .mode-launch { right: 14px; bottom: 25px; width: 33%; min-height: 33px; padding: 5px 7px; } .mode-launch span { font-size: 7px; } .mode-launch small { font-size: 4px; } + .mode-hockey-healing-pvp > .front-notice { right: 37%; bottom: 27px; width: 36%; padding: 3px 5px; font-size: 5px; } .game-menu-button { padding: 3px 5px; font-size: 6px; } } /* Data-driven loot and gear workshop */ -.home-secondary-actions { grid-template-columns: repeat(3, 1fr); } +.home-secondary-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } .collection-grid { grid-template-columns: repeat(3, 1fr); gap: 9px; } .collection-drop { height: 154px; padding: 8px 9px; } .collection-drop.rarity-epic { border-top-color: #a071c1; } @@ -2392,13 +2759,131 @@ button:focus-visible { .gear-passive-ability-filter button { min-height: 14px; padding: 1px 2px; font-size: 3px; } } +/* Appearance Lab */ + +.appearance-surface { padding: 0 24px 15px; } +.appearance-header { height: 58px; } +.appearance-preview-stage { + position: relative; + height: calc(100% - 91px); + margin-top: 8px; + overflow: hidden; + border: 1px solid rgba(151, 194, 178, .28); + background: #07100e; + box-shadow: inset 0 0 70px rgba(0, 0, 0, .4); +} +.appearance-preview-stage::after { + position: absolute; + inset: 7px; + z-index: 2; + border: 1px solid rgba(230, 207, 137, .1); + content: ""; + pointer-events: none; +} +.appearance-preview-stage > div:first-child, +.appearance-preview-stage canvas { width: 100% !important; height: 100% !important; } +.appearance-preview-loading { height: 100%; display: grid; place-content: center; gap: 6px; color: #7e948b; text-align: center; } +.appearance-preview-loading span { color: var(--gold); font-size: 25px; } +.appearance-preview-loading strong { font: 500 12px "Cinzel", serif; } +.appearance-preview-mode { + position: absolute; + top: 16px; + left: 17px; + z-index: 3; + display: grid; + gap: 2px; + padding: 7px 10px; + border-left: 2px solid #67c89e; + background: rgba(3, 12, 10, .84); + pointer-events: none; +} +.appearance-preview-mode span { color: #aee8d0; font-size: 8px; font-weight: 800; letter-spacing: .13em; } +.appearance-preview-mode small { color: #71877e; font-size: 7px; text-transform: uppercase; } +.appearance-preview-mode.is-legacy { border-color: #db9b65; } +.appearance-preview-mode.is-legacy span { color: #f0b47d; } +.appearance-preview-name { + position: absolute; + right: 18px; + bottom: 16px; + z-index: 3; + min-width: 195px; + display: grid; + justify-items: end; + padding: 9px 12px; + border-right: 2px solid var(--gold); + background: linear-gradient(90deg, transparent, rgba(3, 11, 9, .9) 28%); + pointer-events: none; +} +.appearance-preview-name small { color: #81968e; font-size: 7px; letter-spacing: .12em; text-transform: uppercase; } +.appearance-preview-name strong { font: 500 22px "Cinzel", serif; } +.appearance-preview-name span { margin-top: 2px; color: var(--gold); font-size: 7px; font-weight: 800; letter-spacing: .12em; } +.appearance-top-hint { height: 25px; display: flex; align-items: end; justify-content: space-between; color: #71867e; font-size: 7px; text-transform: uppercase; } +.appearance-top-hint b { color: var(--gold-strong); } + +.appearance-context { padding: 0 12px 9px; } +.appearance-context .context-header { height: 40px; margin: 0 -12px; padding: 0 12px; } +.appearance-context .context-header b { color: var(--gold-strong); } +.appearance-class-tabs { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 4px; margin-top: 6px; } +.appearance-class-tabs button { min-width: 0; min-height: 38px; display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 4px; padding: 4px 6px; color: #81978e; font-size: 8px; text-align: left; } +.appearance-class-tabs button i { display: grid; place-items: center; font-size: 10px; font-style: normal; } +.appearance-class-tabs button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.appearance-class-tabs button.is-selected { border-color: var(--gold); color: #edf8f3; background: rgba(73, 59, 21, .3); } +.appearance-animation-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; margin-top: 5px; } +.appearance-animation-tabs button { min-height: 29px; padding: 4px; color: #778c84; font-size: 8px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.appearance-animation-tabs button.is-selected { border-color: #60b997; color: #9ae0c2; background: rgba(39, 101, 78, .2); } +.appearance-slot-list { display: grid; gap: 3px; margin-top: 5px; } +.appearance-slot-row { + min-height: 36px; + display: grid; + grid-template-columns: 132px 31px minmax(0, 1fr) 31px; + align-items: center; + gap: 4px; + padding: 3px 4px 3px 8px; + border: 1px solid rgba(139, 178, 164, .15); + background: rgba(5, 16, 13, .72); +} +.appearance-slot-row.is-controller-active { border-color: rgba(232, 200, 114, .42); background: linear-gradient(90deg, rgba(77, 62, 22, .22), rgba(5, 16, 13, .78)); } +.appearance-slot-row > span { min-width: 0; display: grid; } +.appearance-slot-row > span strong { color: #dce9e4; font-size: 9px; } +.appearance-slot-row > span small { overflow: hidden; color: #647a71; font-size: 6px; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.appearance-slot-row > b { overflow: hidden; color: var(--gold-strong); font: 500 9px "Cinzel", serif; text-align: center; text-overflow: ellipsis; white-space: nowrap; } +.appearance-slot-row button { min-width: 0; height: 28px; padding: 0; color: #c9d8d2; font-size: 18px; line-height: 1; } +.appearance-slot-row.is-disabled { opacity: .55; } +.appearance-slot-row.is-disabled > b { color: #8f9c97; } +.appearance-actions { display: grid; grid-template-columns: 1.25fr .75fr 1fr .75fr; gap: 4px; margin-top: 6px; } +.appearance-actions button { min-width: 0; min-height: 42px; display: grid; align-content: center; padding: 5px 7px; text-align: left; } +.appearance-actions strong { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.appearance-actions small { color: #687d75; font-size: 6px; text-transform: uppercase; } +.appearance-actions button.is-primary { border-color: var(--gold); color: #172019; background: linear-gradient(110deg, #ffe499, #cfaa4c); } +.appearance-actions button.is-primary small { color: #615129; } +.appearance-save-state { margin-top: 5px; padding: 5px 8px; border-left: 2px solid #5fc197; color: #81978e; background: rgba(5, 16, 13, .72); font-size: 7px; } +.appearance-save-state.is-dirty { border-color: var(--gold); color: #d8bd72; } + +@media (max-width: 760px) { + .home-secondary-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .appearance-surface { padding: 0 12px 8px; } + .appearance-header { height: 44px; } + .appearance-preview-stage { height: calc(100% - 68px); margin-top: 5px; } + .appearance-preview-name { right: 10px; bottom: 10px; min-width: 145px; padding: 6px 8px; } + .appearance-preview-name strong { font-size: 15px; } + .appearance-preview-mode { top: 10px; left: 10px; padding: 5px 7px; } + .appearance-top-hint { height: 18px; font-size: 5px; } + .appearance-context { padding: 0 8px 6px; } + .appearance-context .context-header { margin: 0 -8px; padding: 0 8px; } + .appearance-class-tabs button { padding: 3px 4px; font-size: 7px; } + .appearance-slot-row { grid-template-columns: 122px 29px minmax(0, 1fr) 29px; min-height: 34px; padding: 2px 3px 2px 7px; } + .appearance-slot-row button { height: 27px; } + .appearance-slot-row > span strong { font-size: 8px; } + .appearance-slot-row > b { font-size: 8px; } +} + .reward-summary { display: grid; gap: 4px; margin: 8px 0; } .reward-summary > span { padding: 5px 7px; border: 1px solid rgba(232,200,114,.25); color: #dbe8e3; background: rgba(68,54,18,.17); font-size: 8px; } .reward-summary b { margin-right: 6px; color: var(--gold); } .reward-summary i { color: #d79b42; font-style: normal; } @media (max-width: 760px) { - .home-secondary-actions { grid-template-columns: repeat(3, 1fr); } + .home-secondary-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } .collection-grid { grid-template-columns: repeat(3, 1fr); } .collection-drop { height: 96px; padding: 4px 5px; } .collection-drop .drop-icon { width: 25px; height: 25px; margin: 1px auto 2px; font-size: 10px; } @@ -2407,6 +2892,10 @@ button:focus-visible { .collection-note { margin-top: 3px; padding: 2px 4px; } .profile-stats span { padding: 5px 7px; } .profile-stats strong { font-size: 12px; } + .blockbreaker-profile-records { margin: 3px 0; } + .blockbreaker-profile-records > span { padding: 3px 5px; } + .blockbreaker-profile-records small { font-size: 4px; } + .blockbreaker-profile-records strong { font-size: 8px; } .boss-log { margin-top: 5px; } .boss-log button { min-height: 40px; gap: 5px; margin-top: 3px; padding: 3px 6px; } .boss-log button > i { width: 23px; height: 23px; }