Release v0.1.19 2026-07-19
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.18",
|
"version": "0.1.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "node scripts/sync_basis_transcoder.mjs",
|
"predev": "node scripts/sync_basis_transcoder.mjs",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
|
|||||||
const MAX_JSON_BYTES = 1024 * 1024;
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
const AUTH_WINDOW_MS = 15 * 60 * 1000;
|
const AUTH_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const AUTH_ATTEMPTS_PER_WINDOW = 20;
|
const AUTH_ATTEMPTS_PER_WINDOW = 20;
|
||||||
|
const HOCKEY_PVP_COUNTDOWN_MS = 5_000;
|
||||||
const authAttempts = new Map();
|
const authAttempts = new Map();
|
||||||
|
|
||||||
function apiError(message, status = 400) {
|
function apiError(message, status = 400) {
|
||||||
@@ -650,6 +651,7 @@ export function createGameApiHandler(options = {}) {
|
|||||||
match: {
|
match: {
|
||||||
id: match.id,
|
id: match.id,
|
||||||
seed: match.seed,
|
seed: match.seed,
|
||||||
|
countdownEndsAtMs: match.countdownEndsAtMs,
|
||||||
opponentName: opponent.hunterName,
|
opponentName: opponent.hunterName,
|
||||||
role: ticket.side,
|
role: ticket.side,
|
||||||
},
|
},
|
||||||
@@ -693,6 +695,7 @@ export function createGameApiHandler(options = {}) {
|
|||||||
id: matchId,
|
id: matchId,
|
||||||
seed: randomBytes(4).readUInt32BE(0) || 1,
|
seed: randomBytes(4).readUInt32BE(0) || 1,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
countdownEndsAtMs: now + HOCKEY_PVP_COUNTDOWN_MS,
|
||||||
players: { host: opponent, guest: ticket },
|
players: { host: opponent, guest: ticket },
|
||||||
snapshots: { host: null, guest: null },
|
snapshots: { host: null, guest: null },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
|
|||||||
assert.equal(betaQueue.body.status, "matched");
|
assert.equal(betaQueue.body.status, "matched");
|
||||||
assert.equal(betaQueue.body.match.role, "guest");
|
assert.equal(betaQueue.body.match.role, "guest");
|
||||||
assert.equal(betaQueue.body.match.opponentName, "Alpha");
|
assert.equal(betaQueue.body.match.opponentName, "Alpha");
|
||||||
|
assert.ok(betaQueue.body.match.countdownEndsAtMs > Date.now());
|
||||||
|
|
||||||
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
|
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
|
||||||
headers: { Authorization: `Bearer ${alphaToken}` },
|
headers: { Authorization: `Bearer ${alphaToken}` },
|
||||||
@@ -236,6 +237,7 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
|
|||||||
assert.equal(alphaMatched.body.match.role, "host");
|
assert.equal(alphaMatched.body.match.role, "host");
|
||||||
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
|
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
|
||||||
assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed);
|
assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed);
|
||||||
|
assert.equal(alphaMatched.body.match.countdownEndsAtMs, betaQueue.body.match.countdownEndsAtMs);
|
||||||
|
|
||||||
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
|
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
|
||||||
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||||
|
|||||||
+19
@@ -151,6 +151,25 @@ function MainApp() {
|
|||||||
};
|
};
|
||||||
}, [screen]);
|
}, [screen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (screen !== "game") return;
|
||||||
|
let timer: number | undefined;
|
||||||
|
const autoStart = () => {
|
||||||
|
const state = useGameStore.getState();
|
||||||
|
if (state.runMode !== "hockey-healing-pvp" || state.phase !== "briefing") return;
|
||||||
|
const remaining = state.hockeyPvp.countdownEndsAtMs - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
state.startEncounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = window.setTimeout(autoStart, remaining + 16);
|
||||||
|
};
|
||||||
|
autoStart();
|
||||||
|
return () => {
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [screen]);
|
||||||
|
|
||||||
useActionBindings(screen === "game", leaveGame);
|
useActionBindings(screen === "game", leaveGame);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
HOCKEY_PVP_GOAL_Z,
|
HOCKEY_PVP_GOAL_Z,
|
||||||
HOCKEY_PVP_SIDE_OFFSET_Z,
|
HOCKEY_PVP_SIDE_OFFSET_Z,
|
||||||
} from "../game/hockeyHealingPvp";
|
} from "../game/hockeyHealingPvp";
|
||||||
|
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
|
||||||
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
|
import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs";
|
||||||
import {
|
import {
|
||||||
BLOCKBREAKER_BREACH_DAMAGE,
|
BLOCKBREAKER_BREACH_DAMAGE,
|
||||||
@@ -251,6 +252,8 @@ function BriefingPanel() {
|
|||||||
const blockbreakerMode = activityMode === "blockbreaker";
|
const blockbreakerMode = activityMode === "blockbreaker";
|
||||||
const aetherMode = activityMode === "aether-assault";
|
const aetherMode = activityMode === "aether-assault";
|
||||||
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
|
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
|
||||||
|
const countdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs);
|
||||||
|
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(pvpMode, countdownEndsAtMs);
|
||||||
return (
|
return (
|
||||||
<div className="briefing-panel">
|
<div className="briefing-panel">
|
||||||
<div className="briefing-class">
|
<div className="briefing-class">
|
||||||
@@ -258,7 +261,7 @@ function BriefingPanel() {
|
|||||||
<span>Chosen discipline</span>
|
<span>Chosen discipline</span>
|
||||||
<h2>{healer.specialization}</h2>
|
<h2>{healer.specialization}</h2>
|
||||||
<p>{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(" ")}</>}</p>
|
<p>{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(" ")}</>}</p>
|
||||||
<button className="start-button" onClick={startEncounter}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? `Face ${opponentName}` : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
|
<button className="start-button" onClick={startEncounter} disabled={pvpMode}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? pvpCountdownSeconds > 0 ? `Match starts in ${pvpCountdownSeconds}` : "Match starting now" : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{pvpMode ? "Automatic start" : `${DEFAULT_CONTROLLER_GLYPHS.start} / ENTER`}</small></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="briefing-kit">
|
<div className="briefing-kit">
|
||||||
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { DualDisplayFrame } from "./DualDisplayFrame";
|
|||||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
import {
|
import {
|
||||||
HOCKEY_PVP_GOAL_DAMAGE,
|
HOCKEY_PVP_GOAL_DAMAGE,
|
||||||
|
HOCKEY_PVP_COUNTDOWN_MS,
|
||||||
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
|
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
|
||||||
hockeyPvpBossAt,
|
hockeyPvpBossAt,
|
||||||
randomHockeyPvpCpuName,
|
randomHockeyPvpCpuName,
|
||||||
@@ -1593,6 +1594,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)),
|
seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)),
|
||||||
opponentName: randomHockeyPvpCpuName(),
|
opponentName: randomHockeyPvpCpuName(),
|
||||||
role: "cpu",
|
role: "cpu",
|
||||||
|
countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const cancelPvpQueue = () => {
|
const cancelPvpQueue = () => {
|
||||||
@@ -1624,6 +1626,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
completePvpQueue({
|
completePvpQueue({
|
||||||
matchId: joined.match.id,
|
matchId: joined.match.id,
|
||||||
seed: joined.match.seed,
|
seed: joined.match.seed,
|
||||||
|
countdownEndsAtMs: joined.match.countdownEndsAtMs,
|
||||||
opponentName: joined.match.opponentName,
|
opponentName: joined.match.opponentName,
|
||||||
role: joined.match.role,
|
role: joined.match.role,
|
||||||
});
|
});
|
||||||
@@ -1638,6 +1641,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
|||||||
completePvpQueue({
|
completePvpQueue({
|
||||||
matchId: result.match.id,
|
matchId: result.match.id,
|
||||||
seed: result.match.seed,
|
seed: result.match.seed,
|
||||||
|
countdownEndsAtMs: result.match.countdownEndsAtMs,
|
||||||
opponentName: result.match.opponentName,
|
opponentName: result.match.opponentName,
|
||||||
role: result.match.role,
|
role: result.match.role,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { tankAuraProtects } from "../game/partyCombat";
|
|||||||
import { BuffDraftPanel } from "./BuffDraftPanel";
|
import { BuffDraftPanel } from "./BuffDraftPanel";
|
||||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
|
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
|
||||||
|
import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown";
|
||||||
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
||||||
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
||||||
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
||||||
@@ -168,6 +169,10 @@ function PhaseOverlay() {
|
|||||||
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
const blockbreaker = useGameStore((state) => state.blockbreaker);
|
||||||
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
const aetherAssault = useGameStore((state) => state.aetherAssault);
|
||||||
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
|
||||||
|
const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(
|
||||||
|
activityMode === "hockey-healing-pvp" && phase === "briefing",
|
||||||
|
hockeyPvp.countdownEndsAtMs,
|
||||||
|
);
|
||||||
const singleScreen = isSingleScreenLayout();
|
const singleScreen = isSingleScreenLayout();
|
||||||
if (runMode === "rpg-roguelike") return null;
|
if (runMode === "rpg-roguelike") return null;
|
||||||
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
||||||
@@ -223,15 +228,24 @@ function PhaseOverlay() {
|
|||||||
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
|
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
|
||||||
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
|
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
|
||||||
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
|
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
|
||||||
|
const briefingPrompt = pvpMode
|
||||||
|
? pvpCountdownSeconds > 0
|
||||||
|
? `Match starts automatically in ${pvpCountdownSeconds}`
|
||||||
|
: "Match starting now"
|
||||||
|
: singleScreen
|
||||||
|
? "Press Start / Enter to begin"
|
||||||
|
: "Begin from lower display";
|
||||||
return (
|
return (
|
||||||
<div className={`phase-overlay phase-${phase}`}>
|
<div className={`phase-overlay phase-${phase}`}>
|
||||||
<div className="phase-sigil">✦</div>
|
<div className="phase-sigil">✦</div>
|
||||||
<span>{eyebrow}</span>
|
<span>{eyebrow}</span>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{copy}</p>
|
<p>{copy}</p>
|
||||||
<small>{singleScreen
|
<small>{phase === "briefing"
|
||||||
? phase === "briefing" ? "Press Start / Enter to begin" : showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "Press Start for the next match" : "Press Start / Enter to restart"
|
? briefingPrompt
|
||||||
: 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"}</small>
|
: singleScreen
|
||||||
|
? showEndlessChoice ? "Choose Endless Mode or Quit" : pvpMode ? "Press Start for the next match" : "Press Start / Enter to restart"
|
||||||
|
: showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface HockeyPvpQueueResult {
|
|||||||
match?: {
|
match?: {
|
||||||
id: string;
|
id: string;
|
||||||
seed: number;
|
seed: number;
|
||||||
|
countdownEndsAtMs: number;
|
||||||
opponentName: string;
|
opponentName: string;
|
||||||
role: Exclude<HockeyPvpRole, "cpu">;
|
role: Exclude<HockeyPvpRole, "cpu">;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
advanceHockeyPvpPuck,
|
advanceHockeyPvpPuck,
|
||||||
createHockeyPvpState,
|
createHockeyPvpState,
|
||||||
hockeyPvpBossAt,
|
hockeyPvpBossAt,
|
||||||
|
hockeyPvpCountdownSeconds,
|
||||||
hockeyPvpDampeningPercent,
|
hockeyPvpDampeningPercent,
|
||||||
hockeyPvpHealingEffectiveness,
|
hockeyPvpHealingEffectiveness,
|
||||||
hockeyPvpPuckSpeed,
|
hockeyPvpPuckSpeed,
|
||||||
@@ -20,6 +21,13 @@ describe("Healing Hockey PVP", () => {
|
|||||||
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.8);
|
expect(Math.hypot(...state.puckVelocity)).toBeCloseTo(7.8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts down five whole seconds and never returns a negative value", () => {
|
||||||
|
expect(hockeyPvpCountdownSeconds(10_000, 5_000)).toBe(5);
|
||||||
|
expect(hockeyPvpCountdownSeconds(10_000, 9_001)).toBe(1);
|
||||||
|
expect(hockeyPvpCountdownSeconds(10_000, 10_000)).toBe(0);
|
||||||
|
expect(hockeyPvpCountdownSeconds(10_000, 12_000)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("adds five percent global dampening for every boss killed by either party", () => {
|
it("adds five percent global dampening for every boss killed by either party", () => {
|
||||||
expect(hockeyPvpDampeningPercent(0, 0)).toBe(0);
|
expect(hockeyPvpDampeningPercent(0, 0)).toBe(0);
|
||||||
expect(hockeyPvpDampeningPercent(1, 0)).toBe(5);
|
expect(hockeyPvpDampeningPercent(1, 0)).toBe(5);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface HockeyPvpMatchConfig {
|
|||||||
seed: number;
|
seed: number;
|
||||||
opponentName: string;
|
opponentName: string;
|
||||||
role: HockeyPvpRole;
|
role: HockeyPvpRole;
|
||||||
|
countdownEndsAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HockeyPvpPuckState {
|
export interface HockeyPvpPuckState {
|
||||||
@@ -25,6 +26,7 @@ export interface HockeyPvpPuckState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState {
|
export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState {
|
||||||
|
countdownEndsAtMs: number;
|
||||||
status: "inactive" | "live" | "won" | "lost";
|
status: "inactive" | "live" | "won" | "lost";
|
||||||
aimDirection: WorldPosition;
|
aimDirection: WorldPosition;
|
||||||
opponentBossKills: number;
|
opponentBossKills: number;
|
||||||
@@ -61,6 +63,7 @@ export const HOCKEY_PVP_INTERCEPT_RADIUS = 1.05;
|
|||||||
export const HOCKEY_PVP_GOAL_DAMAGE = 45;
|
export const HOCKEY_PVP_GOAL_DAMAGE = 45;
|
||||||
export const HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER = 1.5;
|
export const HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER = 1.5;
|
||||||
export const HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT = 5;
|
export const HOCKEY_PVP_DAMPENING_PER_BOSS_PERCENT = 5;
|
||||||
|
export const HOCKEY_PVP_COUNTDOWN_MS = 5_000;
|
||||||
export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000;
|
export const HOCKEY_PVP_QUEUE_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
const STARTING_SPEED = 7.8;
|
const STARTING_SPEED = 7.8;
|
||||||
@@ -98,6 +101,10 @@ export function hockeyPvpPuckSpeed(totalReturns: number) {
|
|||||||
return Math.min(MAX_SPEED, STARTING_SPEED + Math.max(0, totalReturns) * 0.16);
|
return Math.min(MAX_SPEED, STARTING_SPEED + Math.max(0, totalReturns) * 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hockeyPvpCountdownSeconds(countdownEndsAtMs: number, nowMs = Date.now()) {
|
||||||
|
return Math.max(0, Math.ceil((countdownEndsAtMs - nowMs) / 1_000));
|
||||||
|
}
|
||||||
|
|
||||||
function serveVelocity(side: HockeyPvpGoalSide, serveIndex: number, totalReturns: number): WorldPosition {
|
function serveVelocity(side: HockeyPvpGoalSide, serveIndex: number, totalReturns: number): WorldPosition {
|
||||||
const x = SERVE_LANES[serveIndex % SERVE_LANES.length] * HOCKEY_PVP_GOAL_HALF_WIDTH;
|
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 z = side === "local" ? HOCKEY_PVP_GOAL_Z : -HOCKEY_PVP_GOAL_Z;
|
||||||
@@ -110,6 +117,7 @@ export function createHockeyPvpState(config?: HockeyPvpMatchConfig): HockeyPvpSt
|
|||||||
const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const };
|
const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const };
|
||||||
return {
|
return {
|
||||||
...match,
|
...match,
|
||||||
|
countdownEndsAtMs: config?.countdownEndsAtMs ?? 0,
|
||||||
status: config ? "live" : "inactive",
|
status: config ? "live" : "inactive",
|
||||||
puckPosition: [0, 0],
|
puckPosition: [0, 0],
|
||||||
puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0],
|
puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createClassInventory } from "./healers";
|
import { createClassInventory } from "./healers";
|
||||||
import { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } from "./hockeyHealingPvp";
|
import { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } from "./hockeyHealingPvp";
|
||||||
import { upcomingEncounterMechanic, useGameStore } from "./store";
|
import { upcomingEncounterMechanic, useGameStore } from "./store";
|
||||||
@@ -23,6 +23,8 @@ function createMaxedGear() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Healing Hockey PVP encounter integration", () => {
|
describe("Healing Hockey PVP encounter integration", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useGameStore.getState().configureHealer(
|
useGameStore.getState().configureHealer(
|
||||||
"priest",
|
"priest",
|
||||||
@@ -45,6 +47,27 @@ describe("Healing Hockey PVP encounter integration", () => {
|
|||||||
expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER);
|
expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores start input until shared five-second countdown ends", () => {
|
||||||
|
const now = vi.spyOn(Date, "now").mockReturnValue(5_000);
|
||||||
|
useGameStore.getState().configureHealer(
|
||||||
|
"priest",
|
||||||
|
"Aelia",
|
||||||
|
createClassInventory("priest"),
|
||||||
|
[hockeyPvpBossAt(MATCH.seed, 0)],
|
||||||
|
"hockey-healing-pvp",
|
||||||
|
undefined,
|
||||||
|
"initiate",
|
||||||
|
{ ...MATCH, countdownEndsAtMs: 10_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
useGameStore.getState().startEncounter();
|
||||||
|
expect(useGameStore.getState().phase).toBe("briefing");
|
||||||
|
|
||||||
|
now.mockReturnValue(10_000);
|
||||||
|
useGameStore.getState().startEncounter();
|
||||||
|
expect(useGameStore.getState().phase).toBe("combat");
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes both parties to default base gear without changing saved upgrades", () => {
|
it("normalizes both parties to default base gear without changing saved upgrades", () => {
|
||||||
const maxedGear = createMaxedGear();
|
const maxedGear = createMaxedGear();
|
||||||
const baseHealth = freshParty("priest", "Aelia").map((member) => member.maxHp);
|
const baseHealth = freshParty("priest", "Aelia").map((member) => member.maxHp);
|
||||||
|
|||||||
+5
-2
@@ -815,6 +815,9 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
|
|
||||||
startEncounter: () => {
|
startEncounter: () => {
|
||||||
const current = get();
|
const current = get();
|
||||||
|
if (current.runMode === "hockey-healing-pvp"
|
||||||
|
&& current.phase === "briefing"
|
||||||
|
&& Date.now() < current.hockeyPvp.countdownEndsAtMs) return;
|
||||||
if (current.runMode === "rpg-roguelike" && current.rpgRun) {
|
if (current.runMode === "rpg-roguelike" && current.rpgRun) {
|
||||||
if (current.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" });
|
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-briefing") current.dispatchRpgAction({ type: "boss-start" });
|
||||||
@@ -836,7 +839,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
difficultySlug,
|
difficultySlug,
|
||||||
seenBossIds,
|
seenBossIds,
|
||||||
runMode === "hockey-healing-pvp"
|
runMode === "hockey-healing-pvp"
|
||||||
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
|
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
|
||||||
: undefined,
|
: undefined,
|
||||||
abilityLoadout,
|
abilityLoadout,
|
||||||
),
|
),
|
||||||
@@ -889,7 +892,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
difficultySlug,
|
difficultySlug,
|
||||||
[],
|
[],
|
||||||
runMode === "hockey-healing-pvp"
|
runMode === "hockey-healing-pvp"
|
||||||
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role }
|
? { matchId: hockeyPvp.matchId, seed: hockeyPvp.seed, opponentName: hockeyPvp.opponentName, role: hockeyPvp.role, countdownEndsAtMs: hockeyPvp.countdownEndsAtMs }
|
||||||
: undefined,
|
: undefined,
|
||||||
abilityLoadout,
|
abilityLoadout,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { hockeyPvpCountdownSeconds } from "./hockeyHealingPvp";
|
||||||
|
|
||||||
|
export function useHockeyPvpCountdownSeconds(active: boolean, countdownEndsAtMs: number) {
|
||||||
|
const [seconds, setSeconds] = useState(() =>
|
||||||
|
active ? hockeyPvpCountdownSeconds(countdownEndsAtMs) : 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setSeconds(active ? hockeyPvpCountdownSeconds(countdownEndsAtMs) : 0);
|
||||||
|
update();
|
||||||
|
if (!active) return;
|
||||||
|
const timer = window.setInterval(update, 100);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [active, countdownEndsAtMs]);
|
||||||
|
|
||||||
|
return active ? seconds : 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user