Compare commits

..
2 Commits
Author SHA1 Message Date
Warren H de7b59ce77 Release v0.1.14 2026-07-13 2026-07-13 23:19:40 -04:00
Warren H 4b31480bec Release v0.1.13 2026-07-13 2026-07-13 22:50:51 -04:00
10 changed files with 263 additions and 42 deletions
+3 -3
View File
@@ -1,15 +1,15 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.12",
"version": "0.1.14",
"type": "module",
"scripts": {
"predev": "pnpm assets:sync-basis-transcoder",
"predev": "node scripts/sync_basis_transcoder.mjs",
"dev": "vite --host 0.0.0.0",
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
"db:backup": "node scripts/backup-db.mjs",
"db:init": "node scripts/init-db.mjs",
"prebuild": "pnpm assets:sync-basis-transcoder",
"prebuild": "node scripts/sync_basis_transcoder.mjs",
"build": "tsc -b && vite build",
"android:sync": "pnpm run build && cap sync android",
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync",
+39 -20
View File
@@ -8,7 +8,6 @@ import hashlib
import json
import os
import re
import secrets
import shutil
import subprocess
import sys
@@ -339,17 +338,6 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str,
return result
def multipart_asset(path: Path, media_type: str) -> tuple[bytes, str]:
boundary = f"----iwanttoheal{secrets.token_hex(12)}"
prefix = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
f"Content-Type: {media_type}\r\n\r\n"
).encode()
body = prefix + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
return body, f"multipart/form-data; boundary={boundary}"
def upload_release_asset(
release_id: int,
path: Path,
@@ -357,14 +345,45 @@ def upload_release_asset(
media_type: str,
token: str,
) -> None:
body, content_type = multipart_asset(path, media_type)
gitea_request(
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
f"?name={urllib.parse.quote(path.name)}",
token=token,
method="POST",
body=body,
content_type=content_type,
curl = shutil.which("curl")
if not curl:
raise SystemExit("curl is required to upload Gitea release assets")
url = (
f"{GITEA_API}/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
f"?name={urllib.parse.quote(path.name)}"
)
headers = (
"Accept: application/json\n"
f"Authorization: token {token}\n"
"Expect: 100-continue\n"
)
result = subprocess.run(
[
curl,
"--silent",
"--show-error",
"--fail-with-body",
"--connect-timeout",
"30",
"--max-time",
"300",
"--header",
"@-",
"--form",
f"attachment=@{path};type={media_type}",
url,
],
cwd=REPO_ROOT,
input=headers,
capture_output=True,
text=True,
check=False,
)
if result.returncode:
details = result.stdout.strip() or result.stderr.strip()
raise SystemExit(
f"Gitea release asset upload failed (curl {result.returncode}): {details}"
)
print(f"Release asset uploaded: {path.name}")
+6 -2
View File
@@ -5,7 +5,7 @@ import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEnc
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types";
import { useFrontendStore } from "../frontend/store";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
function RewardSummary() {
@@ -192,6 +192,7 @@ function BriefingPanel() {
}
function EndPanel({ onExit }: { onExit?: () => void }) {
const hunter = useActiveHunter();
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const round = useGameStore((state) => state.round);
@@ -208,6 +209,8 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
return (
<div className={`end-panel end-${phase}`}>
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
@@ -225,7 +228,8 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
onFocus={() => setEndlessChoiceSelection("continue")}
onPointerEnter={() => setEndlessChoiceSelection("continue")}
onClick={startRogueTrialsEndless}
>Continue Endless</button>
aria-label={`Endless Mode. Current high score: ${endlessHighScoreLabel}.`}
><span>Endless Mode</span><small>High score · {endlessHighScoreLabel}</small></button>
<button
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
onFocus={() => setEndlessChoiceSelection("quit")}
+1 -1
View File
@@ -147,7 +147,7 @@ function PhaseOverlay() {
<span>{eyebrow}</span>
<h1>{title}</h1>
<p>{copy}</p>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Continue or Quit on lower display" : "Restart from lower display"}</small>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : "Restart from lower display"}</small>
</div>
);
}
+1 -1
View File
@@ -88,7 +88,7 @@ export const BULL_CHARGE = {
export const BULL_POUNCE = {
stackDuration: 5,
stackRadius: 2.2,
stackRadius: 3.2,
sharedDamage: 200,
leapDuration: 0.55,
cooldown: 4,
+53 -1
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { ARENA_CENTER } from "./arena";
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared";
import { BULL_POUNCE } from "./bosses/mechanicPool";
import { createBaseMotion, createCircleHazard, returnBossToArenaCenter } from "./bosses/shared";
import { freshParty } from "./data";
import { distance, pointToSegmentDistance } from "./geometry";
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import type { MemberId, WorldPosition } from "./types";
@@ -36,4 +38,54 @@ describe("party boss positioning", () => {
expect(next.brann[1]).toBeGreaterThan(sharedStart[1]);
expect(next.vale[1]).toBeLessThan(sharedStart[1]);
});
it("keeps AI allies inside the enlarged pounce stack while avoiding another boss's danger", () => {
expect(BULL_POUNCE.stackRadius).toBe(3.2);
const initialStackCenter: WorldPosition = [0, 0];
let positions: Record<MemberId, WorldPosition> = {
aelia: [...initialStackCenter],
brann: [...initialStackCenter],
nia: [...initialStackCenter],
orin: [...initialStackCenter],
vale: [...initialStackCenter],
};
const dangerMotion = {
...createBaseMotion("crownshard-golem"),
mode: "mantis_line_telegraph" as const,
hazards: [createCircleHazard({
id: "overlapping-impact",
kind: "crownfall",
center: initialStackCenter,
radius: 1.2,
activatesAt: 2,
duration: 4,
damage: 30,
})],
slashLanes: [{
id: "overlapping-lane",
start: [0, -5] as WorldPosition,
end: [0, 5] as WorldPosition,
width: 1.2,
damage: 25,
}],
};
const pounceMotion = {
...createBaseMotion("bulldrome"),
mode: "stacking" as const,
pounceTargetId: "brann" as const,
pounceCenter: initialStackCenter,
};
for (let step = 0; step < 20; step += 1) {
positions = updatePartyPositions(positions, [dangerMotion, pounceMotion], freshParty(), 1 + step * 0.1, 0.1);
pounceMotion.pounceCenter = [...positions.brann];
}
for (const memberId of ["brann", "nia", "orin", "vale"] as const) {
expect(distance(positions[memberId], pounceMotion.pounceCenter)).toBeLessThan(BULL_POUNCE.stackRadius);
expect(distance(positions[memberId], initialStackCenter)).toBeGreaterThanOrEqual(1.2 + 0.55);
expect(pointToSegmentDistance(positions[memberId], dangerMotion.slashLanes[0].start, dangerMotion.slashLanes[0].end))
.toBeGreaterThanOrEqual(1.2 * 0.5 + 0.7);
}
});
});
+132 -8
View File
@@ -1,6 +1,6 @@
import { clampToArena } from "./arena";
import { BULL_CHARGE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool";
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
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";
export type AiMemberId = Exclude<MemberId, "aelia">;
@@ -10,6 +10,7 @@ export interface PartyBehaviorContext {
current: WorldPosition;
formationTarget: WorldPosition;
bossMotion: BossMotionState;
bossMotions: readonly BossMotionState[];
partyPositions: Record<MemberId, WorldPosition>;
time: number;
}
@@ -27,6 +28,13 @@ export interface PartyBehavior {
const AI_MEMBER_IDS: readonly AiMemberId[] = ["brann", "nia", "orin", "vale"];
const MOVE_SPEEDS: Record<AiMemberId, number> = { brann: 1.45, nia: 1.2, orin: 1.1, vale: 2.2 };
const EVADE_SIDES: Record<AiMemberId, -1 | 1> = { brann: -1, nia: -1, orin: 1, vale: 1 };
const HAZARD_LOOKAHEAD = 2.2;
const CIRCLE_HAZARD_CLEARANCE = 0.55;
const LANE_HAZARD_CLEARANCE = 0.7;
const STACK_EDGE_PADDING = 0.35;
const STACK_SAMPLE_RINGS = 3;
const STACK_SAMPLES_PER_RING = 24;
const STACK_CENTER_OFFSET: WorldPosition = [0, 0];
const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
brann: [-0.55, 0],
nia: [0.45, 0.4],
@@ -59,15 +67,130 @@ export function combatFormation(boss: WorldPosition): Record<AiMemberId, WorldPo
};
}
function circleDanger(
x: number,
z: number,
center: WorldPosition,
radius: number,
innerRadius = 0,
) {
const distance = Math.hypot(x - center[0], z - center[1]);
const unsafeInnerRadius = Math.max(0, innerRadius - CIRCLE_HAZARD_CLEARANCE);
const unsafeOuterRadius = radius + CIRCLE_HAZARD_CLEARANCE;
if (distance >= unsafeOuterRadius || (unsafeInnerRadius > 0 && distance <= unsafeInnerRadius)) return 0;
const penetration = unsafeInnerRadius > 0
? Math.min(distance - unsafeInnerRadius, unsafeOuterRadius - distance)
: unsafeOuterRadius - distance;
return 1 + Math.max(0, penetration);
}
function laneDanger(x: number, z: number, start: WorldPosition, end: WorldPosition, width: number) {
const clearance = width * 0.5 + LANE_HAZARD_CLEARANCE;
const laneX = end[0] - start[0];
const laneZ = end[1] - start[1];
const lengthSquared = laneX * laneX + laneZ * laneZ;
const projection = lengthSquared < 0.0001
? 0
: Math.max(0, Math.min(1, ((x - start[0]) * laneX + (z - start[1]) * laneZ) / lengthSquared));
const distance = Math.hypot(x - (start[0] + projection * laneX), z - (start[1] + projection * laneZ));
return distance < clearance ? 1 + clearance - distance : 0;
}
function bossDangerAt(x: number, z: number, bossMotions: readonly BossMotionState[], time: number) {
let danger = 0;
for (const motion of bossMotions) {
for (const hazard of motion.hazards) {
if (hazard.resolved || hazard.expiresAt <= time || hazard.activatesAt - time > HAZARD_LOOKAHEAD) continue;
danger += circleDanger(x, z, hazard.center, hazard.radius, hazard.innerRadius);
}
for (const telegraph of motion.poolTelegraphs) {
if (telegraph.resolved || telegraph.expiresAt <= time || telegraph.activatesAt - time > HAZARD_LOOKAHEAD) continue;
if (telegraph.kind === "soak" || telegraph.kind === "memory" || telegraph.kind === "soul-siphon") continue;
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
danger += laneDanger(x, z, telegraph.start, telegraph.end, telegraph.width ?? 0);
} else {
danger += circleDanger(x, z, telegraph.center, telegraph.radius, telegraph.innerRadius);
}
}
for (const lane of motion.slashLanes) {
danger += laneDanger(x, z, lane.start, lane.end, lane.width);
}
if (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping") {
const dx = x - motion.position[0];
const dz = z - motion.position[1];
const distance = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
const breathAngle = motion.mode === "breath_telegraph" ? motion.breathStartAngle : motion.breathAngle;
const radialPenetration = SKY_SWEEPER_BREATH.range + CIRCLE_HAZARD_CLEARANCE - distance;
const angularPenetration = SKY_SWEEPER_BREATH.halfAngle + 0.08 - angularDistance(angle, breathAngle);
if (radialPenetration > 0 && angularPenetration > 0) {
danger += 1 + Math.min(radialPenetration, angularPenetration * Math.max(1, distance));
}
}
}
return danger;
}
function safeStackTarget(
memberId: AiMemberId,
current: WorldPosition,
center: WorldPosition,
preferredOffset: WorldPosition,
stackRadius: number,
bossMotions: readonly BossMotionState[],
time: number,
) {
const preferred = clampToArena([center[0] + preferredOffset[0], center[1] + preferredOffset[1]]);
const preferredDanger = bossDangerAt(preferred[0], preferred[1], bossMotions, time);
if (preferredDanger === 0) return preferred;
const insideRadius = Math.max(0, stackRadius - STACK_EDGE_PADDING);
let bestX = preferred[0];
let bestZ = preferred[1];
let bestScore = preferredDanger * 1_000
+ Math.hypot(bestX - current[0], bestZ - current[1]) * 0.05;
const angleStep = Math.PI * 2 / STACK_SAMPLES_PER_RING;
const memberPhase = AI_MEMBER_IDS.indexOf(memberId) * angleStep * 0.5;
for (let ring = 1; ring <= STACK_SAMPLE_RINGS; ring += 1) {
const sampleRadius = insideRadius * ring / STACK_SAMPLE_RINGS;
for (let sample = 0; sample < STACK_SAMPLES_PER_RING; sample += 1) {
const angle = memberPhase + sample * angleStep;
let candidateX = center[0] + Math.sin(angle) * sampleRadius;
let candidateZ = center[1] + Math.cos(angle) * sampleRadius;
const arenaX = candidateX - ARENA_CENTER[0];
const arenaZ = candidateZ - ARENA_CENTER[1];
const arenaDistance = Math.hypot(arenaX, arenaZ);
if (arenaDistance > ARENA_RADIUS) {
const scale = ARENA_RADIUS / arenaDistance;
candidateX = ARENA_CENTER[0] + arenaX * scale;
candidateZ = ARENA_CENTER[1] + arenaZ * scale;
}
const danger = bossDangerAt(candidateX, candidateZ, bossMotions, time);
const preferredDistance = Math.hypot(candidateX - preferred[0], candidateZ - preferred[1]);
const travelDistance = Math.hypot(candidateX - current[0], candidateZ - current[1]);
const score = danger * 1_000 + preferredDistance + travelDistance * 0.05;
if (score >= bestScore) continue;
bestX = candidateX;
bestZ = candidateZ;
bestScore = score;
}
}
return [bestX, bestZ] as WorldPosition;
}
export const stackForPounceBehavior: PartyBehavior = {
id: "stack-for-pounce",
decide: ({ memberId, bossMotion }) => {
decide: ({ memberId, current, bossMotion, bossMotions, time }) => {
if (bossMotion.mode !== "stacking") return null;
const offset = STACK_OFFSETS[memberId];
const offset = memberId === bossMotion.pounceTargetId ? STACK_CENTER_OFFSET : STACK_OFFSETS[memberId];
return {
target: memberId === bossMotion.pounceTargetId
? [bossMotion.pounceCenter[0], bossMotion.pounceCenter[1]]
: [bossMotion.pounceCenter[0] + offset[0], bossMotion.pounceCenter[1] + offset[1]],
target: safeStackTarget(memberId, current, bossMotion.pounceCenter, offset, BULL_POUNCE.stackRadius, bossMotions, time),
speed: 3.4,
};
},
@@ -311,6 +434,7 @@ export function updatePartyPositions(
current: next[memberId],
formationTarget: formation[memberId],
bossMotion,
bossMotions: activeMotions,
partyPositions: next,
time,
});
+19
View File
@@ -850,10 +850,29 @@ describe("Rogue Trials", () => {
expect(replaced.phase).toBe("combat");
expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
expect(replaced.bossInstanceId).not.toBe(defeatedInstanceId);
expect(replaced.boss.nextMeleeAt).toBeGreaterThan(replaced.time);
expect(replaced.bossMotion.nextMechanicAt).toBeGreaterThan(replaced.time);
expect(replaced.endlessBossKills).toBe(1);
expect(new Set([replaced.boss.id, ...replaced.additionalBosses.map((entry) => entry.boss.id)])).toHaveLength(3);
});
it("schedules endless attacks from the transition time instead of replaying overdue attacks", () => {
useGameStore.setState({ round: 5, phase: "victory", time: 180 });
expect(useGameStore.getState().startRogueTrialsEndless()).toBe(true);
const started = useGameStore.getState();
const bosses = [
{ boss: started.boss, motion: started.bossMotion },
...started.additionalBosses,
];
expect(bosses.every((entry) => entry.boss.nextMeleeAt > started.time)).toBe(true);
expect(bosses.every((entry) => entry.motion.nextMechanicAt > started.time)).toBe(true);
const startingTankHp = started.party.find((member) => member.id === "brann")!.hp;
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().party.find((member) => member.id === "brann")!.hp).toBe(startingTankHp);
});
it("keeps endless mode running until the whole party falls", () => {
useGameStore.setState((state) => ({
round: 5,
+7 -6
View File
@@ -156,7 +156,7 @@ const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): Bo
return normalizeEncounterBossIds(requested);
};
function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState {
function createEncounterMotion(bossId: BossId, index: number, count: number, startsAt = 0): 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;
@@ -165,17 +165,17 @@ function createEncounterMotion(bossId: BossId, index: number, count: number): Bo
motion.chargeEnd[0] += offset;
motion.pounceCenter[0] += offset;
const stagger = index * 2.4;
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += stagger;
if (Number.isFinite(motion.nextMechanicAt)) motion.nextMechanicAt += startsAt + stagger;
return constrainBossMotion(motion);
}
function createEncounterBoss(bossId: BossId, index: number, count: number, healthMultiplier: number): AdditionalBossState {
function createEncounterBoss(bossId: BossId, index: number, count: number, healthMultiplier: number, startsAt = 0): 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 += stagger;
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count) };
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += startsAt + stagger;
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count, startsAt) };
}
const freshPartyPositions = (bossIds: readonly BossId[]): Record<MemberId, WorldPosition> => {
@@ -466,7 +466,7 @@ export const useGameStore = create<GameState>((set, get) => ({
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const healthMultiplier = bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier;
const encounterBosses = bossIds.map((bossId, index) => {
const entry = createEncounterBoss(bossId, index, bossIds.length, healthMultiplier);
const entry = createEncounterBoss(bossId, index, bossIds.length, healthMultiplier, state.time);
return { ...entry, instanceId: `endless-${index + 1}-${bossId}` };
});
const primary = encounterBosses[0];
@@ -698,6 +698,7 @@ export const useGameStore = create<GameState>((set, get) => ({
index,
slots.length,
bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier,
time,
);
slots[index] = { ...replacement, instanceId: `endless-${endlessSpawnSequence}-${replacementId}` };
combatLog = addLog(combatLog, time, `${replacement.boss.name} replaces the fallen boss.`, "danger");
+2
View File
@@ -1077,6 +1077,8 @@ button:focus-visible {
.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; }
.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; }
.buff-draft {
height: 100%;