Skip to content

Commit e945b47

Browse files
committed
Persist fight state and stabilize tests
1 parent 73cc130 commit e945b47

8 files changed

Lines changed: 360 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Vitest (unit tests) and Playwright (e2e tests) validate that core sections rende
5858
- **Categorized attack logging with undo/redo:** Nail strikes, spell casts, and advanced techniques each expose context-aware damage values that respect build modifiers like Unbreakable Strength or Shaman Stone, while new undo/redo controls make correcting mistakes effortless.
5959
- **Keyboard shortcuts and finishing guidance:** Each attack button surfaces the remaining hits required to reach zero HP if you relied solely on that move, and keyboard shortcuts (number row followed by QWERTY order) allow spectators to log attacks or press <kbd>Esc</kbd> for a quick reset without leaving the action.
6060
- **Live combat analytics:** Remaining HP, DPS, average damage, and actions per minute update instantly as attacks are logged, giving immediate feedback on fight pacing.
61+
- **Automatic session persistence:** Build selections, logged attacks, and boss progress are stored locally so the tracker survives accidental refreshes or browser restarts.
6162

6263
Automated workflows in `.github/workflows/` run linting, unit tests, end-to-end tests, and GitHub Pages deployments on every push.
6364

src/app/App.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ describe('App', () => {
2121
render(<App />);
2222

2323
expect(
24-
screen.getByText(/hollow knight damage tracker/i, { selector: 'p' }),
24+
screen.getByRole('heading', { name: /hollow knight damage tracker/i, level: 1 }),
2525
).toBeInTheDocument();
2626
expect(screen.getByText(/plan your build/i)).toBeVisible();
2727
});

src/app/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const App: FC = () => {
3232
<FightStateProvider>
3333
<PageLayout sections={SECTIONS}>
3434
<div>
35-
<p className="page__title">Hollow Knight Damage Tracker</p>
35+
<h1 className="page__title">Hollow Knight Damage Tracker</h1>
3636
<p className="page__subtitle">
3737
Plan your build, record every strike, and monitor fight-ending damage stats in
3838
real time. This prototype now tracks damage totals with configurable builds

src/features/attack-log/AttackLogPanel.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ import { CombatStatsPanel } from '../combat-stats/CombatStatsPanel';
77
import { renderWithFightProvider } from '../../test-utils/renderWithFightProvider';
88

99
describe('AttackLogPanel', () => {
10+
beforeEach(() => {
11+
window.localStorage.clear();
12+
});
13+
14+
afterEach(() => {
15+
window.localStorage.clear();
16+
});
17+
1018
it('updates nail damage when upgrading the nail and activating strength charms', async () => {
1119
const user = userEvent.setup();
1220

src/features/build-config/BuildConfigPanel.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import { CombatStatsPanel } from '../combat-stats/CombatStatsPanel';
66
import { renderWithFightProvider } from '../../test-utils/renderWithFightProvider';
77

88
describe('BuildConfigPanel', () => {
9+
beforeEach(() => {
10+
window.localStorage.clear();
11+
});
12+
13+
afterEach(() => {
14+
window.localStorage.clear();
15+
});
16+
917
it('allows selecting a custom boss target and updates stats', async () => {
1018
const user = userEvent.setup();
1119

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4+
5+
import { CUSTOM_BOSS_ID, FightStateProvider, useFightState } from './FightStateContext';
6+
7+
const STORAGE_KEY = 'hollow-knight-damage-tracker:fight-state';
8+
9+
describe('FightStateProvider persistence', () => {
10+
beforeEach(() => {
11+
window.localStorage.clear();
12+
});
13+
14+
afterEach(() => {
15+
window.localStorage.clear();
16+
});
17+
18+
it('hydrates state from localStorage when data is available', () => {
19+
const persistedState = {
20+
version: 1,
21+
state: {
22+
selectedBossId: CUSTOM_BOSS_ID,
23+
customTargetHp: 3333.7,
24+
build: {
25+
nailUpgradeId: 'pure-nail',
26+
activeCharmIds: ['shaman-stone', 'quick-slash'],
27+
spellLevels: {
28+
'vengeful-spirit': 'upgrade',
29+
},
30+
},
31+
damageLog: [
32+
{
33+
id: 'spell-vengeful-1',
34+
label: 'Vengeful Spirit',
35+
damage: 45,
36+
category: 'spell',
37+
timestamp: 1700000000000,
38+
soulCost: 33,
39+
},
40+
],
41+
redoStack: [],
42+
},
43+
} satisfies Record<string, unknown>;
44+
45+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(persistedState));
46+
47+
const Consumer = () => {
48+
const { state } = useFightState();
49+
return (
50+
<div>
51+
<span data-testid="selected-boss">{state.selectedBossId}</span>
52+
<span data-testid="custom-hp">{state.customTargetHp}</span>
53+
<span data-testid="nail-upgrade">{state.build.nailUpgradeId}</span>
54+
<span data-testid="charms">{state.build.activeCharmIds.join(',')}</span>
55+
<span data-testid="spell-level">
56+
{state.build.spellLevels['vengeful-spirit']}
57+
</span>
58+
<span data-testid="logged-attacks">{state.damageLog.length}</span>
59+
</div>
60+
);
61+
};
62+
63+
render(
64+
<FightStateProvider>
65+
<Consumer />
66+
</FightStateProvider>,
67+
);
68+
69+
expect(screen.getByTestId('selected-boss').textContent).toBe(CUSTOM_BOSS_ID);
70+
expect(screen.getByTestId('custom-hp').textContent).toBe('3334');
71+
expect(screen.getByTestId('nail-upgrade').textContent).toBe('pure-nail');
72+
expect(screen.getByTestId('charms').textContent).toBe('shaman-stone,quick-slash');
73+
expect(screen.getByTestId('spell-level').textContent).toBe('upgrade');
74+
expect(screen.getByTestId('logged-attacks').textContent).toBe('1');
75+
});
76+
77+
it('persists updates to localStorage whenever state changes', async () => {
78+
const user = userEvent.setup();
79+
80+
const Consumer = () => {
81+
const { actions, state } = useFightState();
82+
return (
83+
<button type="button" onClick={() => actions.setCustomTargetHp(4321)}>
84+
{state.customTargetHp}
85+
</button>
86+
);
87+
};
88+
89+
render(
90+
<FightStateProvider>
91+
<Consumer />
92+
</FightStateProvider>,
93+
);
94+
95+
await user.click(screen.getByRole('button'));
96+
97+
await waitFor(() => {
98+
const stored = window.localStorage.getItem(STORAGE_KEY);
99+
expect(stored).not.toBeNull();
100+
if (!stored) {
101+
throw new Error('Expected persisted fight state');
102+
}
103+
104+
const parsed = JSON.parse(stored) as {
105+
version: number;
106+
state: { selectedBossId: string; customTargetHp: number };
107+
};
108+
expect(parsed.version).toBe(1);
109+
expect(parsed.state.selectedBossId).toBe(CUSTOM_BOSS_ID);
110+
expect(parsed.state.customTargetHp).toBe(4321);
111+
});
112+
});
113+
});

src/features/fight-state/FightStateContext.tsx

Lines changed: 201 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FC, PropsWithChildren } from 'react';
2-
import { createContext, useContext, useMemo, useReducer } from 'react';
2+
import { createContext, useContext, useEffect, useMemo, useReducer } from 'react';
33

44
import {
55
DEFAULT_BOSS_ID,
@@ -10,6 +10,110 @@ import {
1010
strengthCharmIds,
1111
} from '../../data';
1212

13+
const STORAGE_KEY = 'hollow-knight-damage-tracker:fight-state';
14+
const STORAGE_VERSION = 1;
15+
16+
const isRecord = (value: unknown): value is Record<string, unknown> =>
17+
typeof value === 'object' && value !== null;
18+
19+
const toFiniteNumber = (value: unknown): number | null => {
20+
if (typeof value === 'number' && Number.isFinite(value)) {
21+
return value;
22+
}
23+
24+
if (typeof value === 'string' && value.trim() !== '') {
25+
const parsed = Number(value);
26+
return Number.isFinite(parsed) ? parsed : null;
27+
}
28+
29+
return null;
30+
};
31+
32+
const sanitizePositiveInteger = (value: unknown, fallback: number): number => {
33+
const numeric = toFiniteNumber(value);
34+
if (numeric === null) {
35+
return fallback;
36+
}
37+
return Math.max(1, Math.round(numeric));
38+
};
39+
40+
const sanitizeStringArray = (value: unknown, fallback: string[]): string[] => {
41+
if (!Array.isArray(value)) {
42+
return [...fallback];
43+
}
44+
45+
const seen = new Set<string>();
46+
const sanitized: string[] = [];
47+
for (const item of value) {
48+
if (typeof item === 'string' && !seen.has(item)) {
49+
seen.add(item);
50+
sanitized.push(item);
51+
}
52+
}
53+
54+
return sanitized;
55+
};
56+
57+
const sanitizeSpellLevels = (
58+
value: unknown,
59+
fallback: Record<string, SpellLevel>,
60+
): Record<string, SpellLevel> => {
61+
if (!isRecord(value)) {
62+
return { ...fallback };
63+
}
64+
65+
const sanitized: Record<string, SpellLevel> = { ...fallback };
66+
for (const [spellId, level] of Object.entries(value)) {
67+
if (level === 'base' || level === 'upgrade') {
68+
sanitized[spellId] = level;
69+
}
70+
}
71+
72+
return sanitized;
73+
};
74+
75+
const sanitizeAttackEvents = (value: unknown, fallback: AttackEvent[]): AttackEvent[] => {
76+
if (!Array.isArray(value)) {
77+
return [...fallback];
78+
}
79+
80+
const events: AttackEvent[] = [];
81+
for (const item of value) {
82+
if (!isRecord(item)) {
83+
continue;
84+
}
85+
86+
const id = typeof item.id === 'string' ? item.id : null;
87+
const label = typeof item.label === 'string' ? item.label : null;
88+
const damage = toFiniteNumber(item.damage);
89+
const timestamp = toFiniteNumber(item.timestamp);
90+
const category = item.category;
91+
92+
if (!id || !label || damage === null || timestamp === null) {
93+
continue;
94+
}
95+
96+
if (category !== 'nail' && category !== 'spell' && category !== 'advanced') {
97+
continue;
98+
}
99+
100+
const rawSoulCost = item.soulCost;
101+
const soulCost =
102+
rawSoulCost === undefined ? undefined : (toFiniteNumber(rawSoulCost) ?? undefined);
103+
104+
events.push({
105+
id,
106+
label,
107+
damage,
108+
category,
109+
timestamp,
110+
soulCost,
111+
});
112+
}
113+
114+
return events;
115+
};
116+
13117
export type AttackCategory = 'nail' | 'spell' | 'advanced';
14118
export type SpellLevel = 'base' | 'upgrade';
15119

@@ -249,11 +353,106 @@ const ensureSpellLevels = (state: FightState): FightState => {
249353
};
250354
};
251355

356+
const mergePersistedState = (
357+
persisted: Record<string, unknown>,
358+
fallback: FightState,
359+
): FightState => {
360+
const selectedBossId =
361+
typeof persisted.selectedBossId === 'string'
362+
? persisted.selectedBossId
363+
: fallback.selectedBossId;
364+
const customTargetHp = sanitizePositiveInteger(
365+
persisted.customTargetHp,
366+
fallback.customTargetHp,
367+
);
368+
369+
const persistedBuild = isRecord(persisted.build) ? persisted.build : {};
370+
const nailUpgradeId =
371+
typeof persistedBuild.nailUpgradeId === 'string'
372+
? persistedBuild.nailUpgradeId
373+
: fallback.build.nailUpgradeId;
374+
const activeCharmIds = sanitizeStringArray(
375+
persistedBuild.activeCharmIds,
376+
fallback.build.activeCharmIds,
377+
);
378+
const spellLevels = sanitizeSpellLevels(
379+
persistedBuild.spellLevels,
380+
fallback.build.spellLevels,
381+
);
382+
383+
const damageLog = sanitizeAttackEvents(persisted.damageLog, fallback.damageLog);
384+
const redoStack = sanitizeAttackEvents(persisted.redoStack, fallback.redoStack);
385+
386+
return ensureSpellLevels({
387+
selectedBossId,
388+
customTargetHp,
389+
build: {
390+
nailUpgradeId,
391+
activeCharmIds,
392+
spellLevels,
393+
},
394+
damageLog,
395+
redoStack,
396+
});
397+
};
398+
399+
const restorePersistedState = (fallback: FightState): FightState => {
400+
if (typeof window === 'undefined') {
401+
return fallback;
402+
}
403+
404+
try {
405+
const serialized = window.localStorage.getItem(STORAGE_KEY);
406+
if (!serialized) {
407+
return fallback;
408+
}
409+
410+
const parsed = JSON.parse(serialized);
411+
if (!isRecord(parsed)) {
412+
return fallback;
413+
}
414+
415+
const { version, state } = parsed as {
416+
version?: unknown;
417+
state?: unknown;
418+
};
419+
420+
if (typeof version !== 'number' || version !== STORAGE_VERSION) {
421+
return fallback;
422+
}
423+
424+
if (!isRecord(state)) {
425+
return fallback;
426+
}
427+
428+
return mergePersistedState(state, fallback);
429+
} catch {
430+
return fallback;
431+
}
432+
};
433+
434+
const persistStateToStorage = (state: FightState) => {
435+
if (typeof window === 'undefined') {
436+
return;
437+
}
438+
439+
try {
440+
const payload = JSON.stringify({ version: STORAGE_VERSION, state });
441+
window.localStorage.setItem(STORAGE_KEY, payload);
442+
} catch {
443+
// Silently ignore storage errors so the tracker keeps functioning.
444+
}
445+
};
446+
252447
export const FightStateProvider: FC<PropsWithChildren> = ({ children }) => {
253448
const [state, dispatch] = useReducer(fightReducer, undefined, () =>
254-
ensureSpellLevels(createInitialState()),
449+
restorePersistedState(ensureSpellLevels(createInitialState())),
255450
);
256451

452+
useEffect(() => {
453+
persistStateToStorage(state);
454+
}, [state]);
455+
257456
const derived = useMemo(() => calculateDerivedStats(state), [state]);
258457

259458
const actions = useMemo<FightContextValue['actions']>(

0 commit comments

Comments
 (0)