From 437e70fc587062258371f87792d4ef18e12dbece Mon Sep 17 00:00:00 2001 From: Warren H Date: Sun, 19 Jul 2026 14:15:30 -0400 Subject: [PATCH] Release v0.1.19 2026-07-19 --- package.json | 2 +- server/game-api.mjs | 3 +++ server/game-api.test.mjs | 2 ++ src/App.tsx | 19 +++++++++++++++++++ src/components/BottomScreen.tsx | 5 ++++- src/components/FrontEnd.tsx | 4 ++++ src/components/TopScreen.tsx | 20 +++++++++++++++++--- src/frontend/onlineRepository.ts | 1 + src/game/hockeyHealingPvp.test.ts | 8 ++++++++ src/game/hockeyHealingPvp.ts | 8 ++++++++ src/game/hockeyHealingPvpStore.test.ts | 25 ++++++++++++++++++++++++- src/game/store.ts | 7 +++++-- src/game/useHockeyPvpCountdown.ts | 17 +++++++++++++++++ 13 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 src/game/useHockeyPvpCountdown.ts diff --git a/package.json b/package.json index 80cda72..08d08b3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "i-want-to-heal", "private": true, - "version": "0.1.18", + "version": "0.1.19", "type": "module", "scripts": { "predev": "node scripts/sync_basis_transcoder.mjs", diff --git a/server/game-api.mjs b/server/game-api.mjs index 542f22d..ba667d3 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -7,6 +7,7 @@ const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; const MAX_JSON_BYTES = 1024 * 1024; const AUTH_WINDOW_MS = 15 * 60 * 1000; const AUTH_ATTEMPTS_PER_WINDOW = 20; +const HOCKEY_PVP_COUNTDOWN_MS = 5_000; const authAttempts = new Map(); function apiError(message, status = 400) { @@ -650,6 +651,7 @@ export function createGameApiHandler(options = {}) { match: { id: match.id, seed: match.seed, + countdownEndsAtMs: match.countdownEndsAtMs, opponentName: opponent.hunterName, role: ticket.side, }, @@ -693,6 +695,7 @@ export function createGameApiHandler(options = {}) { id: matchId, seed: randomBytes(4).readUInt32BE(0) || 1, createdAt: now, + countdownEndsAtMs: now + HOCKEY_PVP_COUNTDOWN_MS, players: { host: opponent, guest: ticket }, snapshots: { host: null, guest: null }, }; diff --git a/server/game-api.test.mjs b/server/game-api.test.mjs index afefb26..8c88d9f 100644 --- a/server/game-api.test.mjs +++ b/server/game-api.test.mjs @@ -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.match.role, "guest"); 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}`, { 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.id, betaQueue.body.match.id); 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 } }; await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, { diff --git a/src/App.tsx b/src/App.tsx index b5b2d07..b1816a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -151,6 +151,25 @@ function MainApp() { }; }, [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); useEffect(() => { diff --git a/src/components/BottomScreen.tsx b/src/components/BottomScreen.tsx index 650d53b..b76b294 100644 --- a/src/components/BottomScreen.tsx +++ b/src/components/BottomScreen.tsx @@ -21,6 +21,7 @@ import { HOCKEY_PVP_GOAL_Z, HOCKEY_PVP_SIDE_OFFSET_Z, } from "../game/hockeyHealingPvp"; +import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown"; import { bottomTabsFor, cycleBottomTab } from "../game/bottomTabs"; import { BLOCKBREAKER_BREACH_DAMAGE, @@ -251,6 +252,8 @@ function BriefingPanel() { const blockbreakerMode = activityMode === "blockbreaker"; const aetherMode = activityMode === "aether-assault"; const opponentName = useGameStore((state) => state.hockeyPvp.opponentName); + const countdownEndsAtMs = useGameStore((state) => state.hockeyPvp.countdownEndsAtMs); + const pvpCountdownSeconds = useHockeyPvpCountdownSeconds(pvpMode, countdownEndsAtMs); return (
@@ -258,7 +261,7 @@ function BriefingPanel() { Chosen discipline

{healer.specialization}

{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
diff --git a/src/components/FrontEnd.tsx b/src/components/FrontEnd.tsx index 4bdb86b..e5ddf7b 100644 --- a/src/components/FrontEnd.tsx +++ b/src/components/FrontEnd.tsx @@ -51,6 +51,7 @@ import { DualDisplayFrame } from "./DualDisplayFrame"; import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; import { HOCKEY_PVP_GOAL_DAMAGE, + HOCKEY_PVP_COUNTDOWN_MS, HOCKEY_PVP_QUEUE_TIMEOUT_MS, hockeyPvpBossAt, randomHockeyPvpCpuName, @@ -1593,6 +1594,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)), opponentName: randomHockeyPvpCpuName(), role: "cpu", + countdownEndsAtMs: Date.now() + HOCKEY_PVP_COUNTDOWN_MS, }); }; const cancelPvpQueue = () => { @@ -1624,6 +1626,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi completePvpQueue({ matchId: joined.match.id, seed: joined.match.seed, + countdownEndsAtMs: joined.match.countdownEndsAtMs, opponentName: joined.match.opponentName, role: joined.match.role, }); @@ -1638,6 +1641,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi completePvpQueue({ matchId: result.match.id, seed: result.match.seed, + countdownEndsAtMs: result.match.countdownEndsAtMs, opponentName: result.match.opponentName, role: result.match.role, }); diff --git a/src/components/TopScreen.tsx b/src/components/TopScreen.tsx index cce9862..a3d8e5c 100644 --- a/src/components/TopScreen.tsx +++ b/src/components/TopScreen.tsx @@ -7,6 +7,7 @@ import { tankAuraProtects } from "../game/partyCombat"; import { BuffDraftPanel } from "./BuffDraftPanel"; import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs"; import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp"; +import { useHockeyPvpCountdownSeconds } from "../game/useHockeyPvpCountdown"; import { blockbreakerTimeMultiplier } from "../game/blockbreaker"; import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay"; import type { CharacterAppearanceV1 } from "../game/characterAppearance"; @@ -168,6 +169,10 @@ function PhaseOverlay() { const blockbreaker = useGameStore((state) => state.blockbreaker); const aetherAssault = useGameStore((state) => state.aetherAssault); const hockeyPvp = useGameStore((state) => state.hockeyPvp); + const pvpCountdownSeconds = useHockeyPvpCountdownSeconds( + activityMode === "hockey-healing-pvp" && phase === "briefing", + hockeyPvp.countdownEndsAtMs, + ); const singleScreen = isSingleScreenLayout(); if (runMode === "rpg-roguelike") return null; if (phase === "intermission") return ; @@ -223,15 +228,24 @@ function PhaseOverlay() { ? `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(" "); + const briefingPrompt = pvpMode + ? pvpCountdownSeconds > 0 + ? `Match starts automatically in ${pvpCountdownSeconds}` + : "Match starting now" + : singleScreen + ? "Press Start / Enter to begin" + : "Begin from lower display"; return (
{eyebrow}

{title}

{copy}

- {singleScreen - ? 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" - : 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"} + {phase === "briefing" + ? briefingPrompt + : 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"}
); } diff --git a/src/frontend/onlineRepository.ts b/src/frontend/onlineRepository.ts index e07e168..79c9259 100644 --- a/src/frontend/onlineRepository.ts +++ b/src/frontend/onlineRepository.ts @@ -36,6 +36,7 @@ export interface HockeyPvpQueueResult { match?: { id: string; seed: number; + countdownEndsAtMs: number; opponentName: string; role: Exclude; }; diff --git a/src/game/hockeyHealingPvp.test.ts b/src/game/hockeyHealingPvp.test.ts index 10360aa..9968bd5 100644 --- a/src/game/hockeyHealingPvp.test.ts +++ b/src/game/hockeyHealingPvp.test.ts @@ -5,6 +5,7 @@ import { advanceHockeyPvpPuck, createHockeyPvpState, hockeyPvpBossAt, + hockeyPvpCountdownSeconds, hockeyPvpDampeningPercent, hockeyPvpHealingEffectiveness, hockeyPvpPuckSpeed, @@ -20,6 +21,13 @@ describe("Healing Hockey PVP", () => { 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", () => { expect(hockeyPvpDampeningPercent(0, 0)).toBe(0); expect(hockeyPvpDampeningPercent(1, 0)).toBe(5); diff --git a/src/game/hockeyHealingPvp.ts b/src/game/hockeyHealingPvp.ts index 8942861..f325efc 100644 --- a/src/game/hockeyHealingPvp.ts +++ b/src/game/hockeyHealingPvp.ts @@ -10,6 +10,7 @@ export interface HockeyPvpMatchConfig { seed: number; opponentName: string; role: HockeyPvpRole; + countdownEndsAtMs?: number; } export interface HockeyPvpPuckState { @@ -25,6 +26,7 @@ export interface HockeyPvpPuckState { } export interface HockeyPvpState extends HockeyPvpMatchConfig, HockeyPvpPuckState { + countdownEndsAtMs: number; status: "inactive" | "live" | "won" | "lost"; aimDirection: WorldPosition; 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_BOSS_DAMAGE_MULTIPLIER = 1.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; 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); } +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 { 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; @@ -110,6 +117,7 @@ export function createHockeyPvpState(config?: HockeyPvpMatchConfig): HockeyPvpSt const match = config ?? { matchId: null, seed: 1, opponentName: "CPU Willow", role: "cpu" as const }; return { ...match, + countdownEndsAtMs: config?.countdownEndsAtMs ?? 0, status: config ? "live" : "inactive", puckPosition: [0, 0], puckVelocity: config ? serveVelocity("local", 0, 0) : [0, 0], diff --git a/src/game/hockeyHealingPvpStore.test.ts b/src/game/hockeyHealingPvpStore.test.ts index 86ba2b1..fe81d46 100644 --- a/src/game/hockeyHealingPvpStore.test.ts +++ b/src/game/hockeyHealingPvpStore.test.ts @@ -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 { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } from "./hockeyHealingPvp"; import { upcomingEncounterMechanic, useGameStore } from "./store"; @@ -23,6 +23,8 @@ function createMaxedGear() { } describe("Healing Hockey PVP encounter integration", () => { + afterEach(() => vi.restoreAllMocks()); + beforeEach(() => { useGameStore.getState().configureHealer( "priest", @@ -45,6 +47,27 @@ describe("Healing Hockey PVP encounter integration", () => { 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", () => { const maxedGear = createMaxedGear(); const baseHealth = freshParty("priest", "Aelia").map((member) => member.maxHp); diff --git a/src/game/store.ts b/src/game/store.ts index a7dfef5..3624bb1 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -815,6 +815,9 @@ export const useGameStore = create((set, get) => ({ startEncounter: () => { 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.rpgRun.phase === "challenge-briefing") current.dispatchRpgAction({ type: "challenge-start" }); else if (current.rpgRun.phase === "boss-briefing") current.dispatchRpgAction({ type: "boss-start" }); @@ -836,7 +839,7 @@ export const useGameStore = create((set, get) => ({ difficultySlug, seenBossIds, 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, abilityLoadout, ), @@ -889,7 +892,7 @@ export const useGameStore = create((set, get) => ({ difficultySlug, [], 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, abilityLoadout, )); diff --git a/src/game/useHockeyPvpCountdown.ts b/src/game/useHockeyPvpCountdown.ts new file mode 100644 index 0000000..f7543fa --- /dev/null +++ b/src/game/useHockeyPvpCountdown.ts @@ -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; +}