Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de7b59ce77 | ||
|
|
4b31480bec |
+3
-3
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.12",
|
"version": "0.1.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "pnpm assets:sync-basis-transcoder",
|
"predev": "node scripts/sync_basis_transcoder.mjs",
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
|
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
|
||||||
"db:backup": "node scripts/backup-db.mjs",
|
"db:backup": "node scripts/backup-db.mjs",
|
||||||
"db:init": "node scripts/init-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",
|
"build": "tsc -b && vite build",
|
||||||
"android:sync": "pnpm run build && cap sync android",
|
"android:sync": "pnpm run build && cap sync android",
|
||||||
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync",
|
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync",
|
||||||
|
|||||||
+39
-20
@@ -8,7 +8,6 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -339,17 +338,6 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str,
|
|||||||
return result
|
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(
|
def upload_release_asset(
|
||||||
release_id: int,
|
release_id: int,
|
||||||
path: Path,
|
path: Path,
|
||||||
@@ -357,15 +345,46 @@ def upload_release_asset(
|
|||||||
media_type: str,
|
media_type: str,
|
||||||
token: str,
|
token: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
body, content_type = multipart_asset(path, media_type)
|
curl = shutil.which("curl")
|
||||||
gitea_request(
|
if not curl:
|
||||||
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
|
raise SystemExit("curl is required to upload Gitea release assets")
|
||||||
f"?name={urllib.parse.quote(path.name)}",
|
|
||||||
token=token,
|
url = (
|
||||||
method="POST",
|
f"{GITEA_API}/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
|
||||||
body=body,
|
f"?name={urllib.parse.quote(path.name)}"
|
||||||
content_type=content_type,
|
|
||||||
)
|
)
|
||||||
|
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}")
|
print(f"Release asset uploaded: {path.name}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEnc
|
|||||||
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
||||||
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
||||||
import type { BottomTab, PartyMember } from "../game/types";
|
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";
|
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||||||
|
|
||||||
function RewardSummary() {
|
function RewardSummary() {
|
||||||
@@ -192,6 +192,7 @@ function BriefingPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function EndPanel({ onExit }: { onExit?: () => void }) {
|
function EndPanel({ onExit }: { onExit?: () => void }) {
|
||||||
|
const hunter = useActiveHunter();
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
const runMode = useGameStore((state) => state.runMode);
|
const runMode = useGameStore((state) => state.runMode);
|
||||||
const round = useGameStore((state) => state.round);
|
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 totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
|
||||||
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
|
||||||
const endlessDefeat = phase === "defeat" && endlessMode;
|
const endlessDefeat = phase === "defeat" && endlessMode;
|
||||||
|
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
|
||||||
|
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
|
||||||
return (
|
return (
|
||||||
<div className={`end-panel end-${phase}`}>
|
<div className={`end-panel end-${phase}`}>
|
||||||
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
|
||||||
@@ -225,7 +228,8 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
|
|||||||
onFocus={() => setEndlessChoiceSelection("continue")}
|
onFocus={() => setEndlessChoiceSelection("continue")}
|
||||||
onPointerEnter={() => setEndlessChoiceSelection("continue")}
|
onPointerEnter={() => setEndlessChoiceSelection("continue")}
|
||||||
onClick={startRogueTrialsEndless}
|
onClick={startRogueTrialsEndless}
|
||||||
>Continue Endless</button>
|
aria-label={`Endless Mode. Current high score: ${endlessHighScoreLabel}.`}
|
||||||
|
><span>Endless Mode</span><small>High score · {endlessHighScoreLabel}</small></button>
|
||||||
<button
|
<button
|
||||||
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
|
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
|
||||||
onFocus={() => setEndlessChoiceSelection("quit")}
|
onFocus={() => setEndlessChoiceSelection("quit")}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ function PhaseOverlay() {
|
|||||||
<span>{eyebrow}</span>
|
<span>{eyebrow}</span>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{copy}</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const BULL_CHARGE = {
|
|||||||
|
|
||||||
export const BULL_POUNCE = {
|
export const BULL_POUNCE = {
|
||||||
stackDuration: 5,
|
stackDuration: 5,
|
||||||
stackRadius: 2.2,
|
stackRadius: 3.2,
|
||||||
sharedDamage: 200,
|
sharedDamage: 200,
|
||||||
leapDuration: 0.55,
|
leapDuration: 0.55,
|
||||||
cooldown: 4,
|
cooldown: 4,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { ARENA_CENTER } from "./arena";
|
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 { freshParty } from "./data";
|
||||||
|
import { distance, pointToSegmentDistance } from "./geometry";
|
||||||
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
|
||||||
import type { MemberId, WorldPosition } from "./types";
|
import type { MemberId, WorldPosition } from "./types";
|
||||||
|
|
||||||
@@ -36,4 +38,54 @@ describe("party boss positioning", () => {
|
|||||||
expect(next.brann[1]).toBeGreaterThan(sharedStart[1]);
|
expect(next.brann[1]).toBeGreaterThan(sharedStart[1]);
|
||||||
expect(next.vale[1]).toBeLessThan(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
@@ -1,6 +1,6 @@
|
|||||||
import { clampToArena } from "./arena";
|
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "./arena";
|
||||||
import { BULL_CHARGE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool";
|
import { BULL_CHARGE, BULL_POUNCE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool";
|
||||||
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
import { angularDistance, moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
||||||
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
|
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
|
||||||
|
|
||||||
export type AiMemberId = Exclude<MemberId, "aelia">;
|
export type AiMemberId = Exclude<MemberId, "aelia">;
|
||||||
@@ -10,6 +10,7 @@ export interface PartyBehaviorContext {
|
|||||||
current: WorldPosition;
|
current: WorldPosition;
|
||||||
formationTarget: WorldPosition;
|
formationTarget: WorldPosition;
|
||||||
bossMotion: BossMotionState;
|
bossMotion: BossMotionState;
|
||||||
|
bossMotions: readonly BossMotionState[];
|
||||||
partyPositions: Record<MemberId, WorldPosition>;
|
partyPositions: Record<MemberId, WorldPosition>;
|
||||||
time: number;
|
time: number;
|
||||||
}
|
}
|
||||||
@@ -27,6 +28,13 @@ export interface PartyBehavior {
|
|||||||
const AI_MEMBER_IDS: readonly AiMemberId[] = ["brann", "nia", "orin", "vale"];
|
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 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 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> = {
|
const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
|
||||||
brann: [-0.55, 0],
|
brann: [-0.55, 0],
|
||||||
nia: [0.45, 0.4],
|
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 = {
|
export const stackForPounceBehavior: PartyBehavior = {
|
||||||
id: "stack-for-pounce",
|
id: "stack-for-pounce",
|
||||||
decide: ({ memberId, bossMotion }) => {
|
decide: ({ memberId, current, bossMotion, bossMotions, time }) => {
|
||||||
if (bossMotion.mode !== "stacking") return null;
|
if (bossMotion.mode !== "stacking") return null;
|
||||||
const offset = STACK_OFFSETS[memberId];
|
const offset = memberId === bossMotion.pounceTargetId ? STACK_CENTER_OFFSET : STACK_OFFSETS[memberId];
|
||||||
return {
|
return {
|
||||||
target: memberId === bossMotion.pounceTargetId
|
target: safeStackTarget(memberId, current, bossMotion.pounceCenter, offset, BULL_POUNCE.stackRadius, bossMotions, time),
|
||||||
? [bossMotion.pounceCenter[0], bossMotion.pounceCenter[1]]
|
|
||||||
: [bossMotion.pounceCenter[0] + offset[0], bossMotion.pounceCenter[1] + offset[1]],
|
|
||||||
speed: 3.4,
|
speed: 3.4,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -311,6 +434,7 @@ export function updatePartyPositions(
|
|||||||
current: next[memberId],
|
current: next[memberId],
|
||||||
formationTarget: formation[memberId],
|
formationTarget: formation[memberId],
|
||||||
bossMotion,
|
bossMotion,
|
||||||
|
bossMotions: activeMotions,
|
||||||
partyPositions: next,
|
partyPositions: next,
|
||||||
time,
|
time,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -850,10 +850,29 @@ describe("Rogue Trials", () => {
|
|||||||
expect(replaced.phase).toBe("combat");
|
expect(replaced.phase).toBe("combat");
|
||||||
expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
|
expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
|
||||||
expect(replaced.bossInstanceId).not.toBe(defeatedInstanceId);
|
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(replaced.endlessBossKills).toBe(1);
|
||||||
expect(new Set([replaced.boss.id, ...replaced.additionalBosses.map((entry) => entry.boss.id)])).toHaveLength(3);
|
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", () => {
|
it("keeps endless mode running until the whole party falls", () => {
|
||||||
useGameStore.setState((state) => ({
|
useGameStore.setState((state) => ({
|
||||||
round: 5,
|
round: 5,
|
||||||
|
|||||||
+7
-6
@@ -156,7 +156,7 @@ const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): Bo
|
|||||||
return normalizeEncounterBossIds(requested);
|
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 motion = cloneMotion(createBossMotionState(bossId));
|
||||||
const offset = count === 3 ? [-3.7, 0, 3.7][index] : count === 2 ? [-2.65, 2.65][index] : 0;
|
const offset = count === 3 ? [-3.7, 0, 3.7][index] : count === 2 ? [-2.65, 2.65][index] : 0;
|
||||||
motion.formationOffsetX = offset;
|
motion.formationOffsetX = offset;
|
||||||
@@ -165,17 +165,17 @@ function createEncounterMotion(bossId: BossId, index: number, count: number): Bo
|
|||||||
motion.chargeEnd[0] += offset;
|
motion.chargeEnd[0] += offset;
|
||||||
motion.pounceCenter[0] += offset;
|
motion.pounceCenter[0] += offset;
|
||||||
const stagger = index * 2.4;
|
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);
|
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);
|
const boss = createBossState(bossId);
|
||||||
boss.maxHp = Math.round(boss.maxHp * healthMultiplier);
|
boss.maxHp = Math.round(boss.maxHp * healthMultiplier);
|
||||||
boss.hp = boss.maxHp;
|
boss.hp = boss.maxHp;
|
||||||
const stagger = index * 0.8;
|
const stagger = index * 0.8;
|
||||||
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += stagger;
|
if (Number.isFinite(boss.nextMeleeAt)) boss.nextMeleeAt += startsAt + stagger;
|
||||||
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count) };
|
return { instanceId: `boss-${index}-${bossId}`, boss, motion: createEncounterMotion(bossId, index, count, startsAt) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const freshPartyPositions = (bossIds: readonly BossId[]): Record<MemberId, WorldPosition> => {
|
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 difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
|
||||||
const healthMultiplier = bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier;
|
const healthMultiplier = bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier;
|
||||||
const encounterBosses = bossIds.map((bossId, index) => {
|
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}` };
|
return { ...entry, instanceId: `endless-${index + 1}-${bossId}` };
|
||||||
});
|
});
|
||||||
const primary = encounterBosses[0];
|
const primary = encounterBosses[0];
|
||||||
@@ -698,6 +698,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
index,
|
index,
|
||||||
slots.length,
|
slots.length,
|
||||||
bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier,
|
bossHealthMultiplier(ROGUE_TRIALS_TRIO_ROUND) * difficulty.healthMultiplier,
|
||||||
|
time,
|
||||||
);
|
);
|
||||||
slots[index] = { ...replacement, instanceId: `endless-${endlessSpawnSequence}-${replacementId}` };
|
slots[index] = { ...replacement, instanceId: `endless-${endlessSpawnSequence}-${replacementId}` };
|
||||||
combatLog = addLog(combatLog, time, `${replacement.boss.name} replaces the fallen boss.`, "danger");
|
combatLog = addLog(combatLog, time, `${replacement.boss.name} replaces the fallen boss.`, "danger");
|
||||||
|
|||||||
@@ -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 { 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.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-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 {
|
.buff-draft {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user