@rpg-engine/long-bow 0.8.235 → 0.8.237

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ import React from 'react';
2
+ import { fireEvent, render, screen, cleanup } from '@testing-library/react';
3
+ import { BasicAttribute, IRawSpell, ISpell, ShortcutType, SpellsBlueprint } from '@rpg-engine/shared';
4
+ import { Spellbook, ISpellbookProps } from '../Spellbook';
5
+ import { Spell } from '../Spell';
6
+ import { mockSpells } from '../mockSpells';
7
+
8
+ jest.mock('../../shared/SpriteFromAtlas', () => ({ SpriteFromAtlas: () => null }));
9
+ jest.mock('../cards/SpellInfoWrapper', () => ({ SpellInfoWrapper: ({ children }: any) => children }));
10
+ afterEach(cleanup);
11
+
12
+ const spells: ISpell[] = Array.from({ length: 9 }, (_, i) => ({
13
+ ...mockSpells[0], key: `spell-${i}` as SpellsBlueprint,
14
+ name: `Spell ${i}`, magicWords: `words ${i}`, minMagicLevelRequired: 9 - i,
15
+ }));
16
+ const props: ISpellbookProps = {
17
+ spells, mana: 200, magicLevel: 100, shortcuts: [], atlasIMG: '', atlasJSON: {},
18
+ onSpellClick: jest.fn(), setSpellShortcut: jest.fn(), removeShortcut: jest.fn(),
19
+ };
20
+
21
+ it('paginates without mutating the caller array and resets search to the first page', () => {
22
+ const order = spells.map(spell => spell.key);
23
+ render(<Spellbook {...props} />);
24
+ expect(spells.map(spell => spell.key)).toEqual(order);
25
+ expect(screen.getAllByRole('button', { name: /^Cast Spell/ })).toHaveLength(8);
26
+ fireEvent.click(screen.getByRole('button', { name: 'Next page' }));
27
+ expect(screen.getAllByRole('button', { name: /^Cast Spell/ })).toHaveLength(1);
28
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'words 8' } });
29
+ expect(screen.getByRole('button', { name: 'Cast Spell 8' })).toBeTruthy();
30
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'missing' } });
31
+ expect(screen.getByText('No matching spells')).toBeTruthy();
32
+ fireEvent.click(screen.getByRole('button', { name: 'Clear search' }));
33
+ expect(screen.getAllByRole('button', { name: /^Cast Spell/ })).toHaveLength(8);
34
+ });
35
+
36
+ it('keeps a valid page when the learned spell list shrinks', () => {
37
+ const { rerender } = render(<Spellbook {...props} />);
38
+ fireEvent.click(screen.getByRole('button', { name: 'Next page' }));
39
+ rerender(<Spellbook {...props} spells={spells.slice(0, 2)} />);
40
+ expect(screen.getAllByRole('button', { name: /^Cast Spell/ })).toHaveLength(2);
41
+ });
42
+
43
+ it('assigns a shortcut without casting and supports cancellation', () => {
44
+ const setSpellShortcut = jest.fn();
45
+ const onSpellClick = jest.fn();
46
+ render(<Spellbook {...props} onSpellClick={onSpellClick} setSpellShortcut={setSpellShortcut} />);
47
+ fireEvent.click(screen.getByRole('button', { name: 'Set shortcut 1' }));
48
+ fireEvent.click(screen.getByRole('button', { name: 'Assign Spell 8' }));
49
+ expect(setSpellShortcut).toHaveBeenCalledWith('spell-8', 0);
50
+ expect(onSpellClick).not.toHaveBeenCalled();
51
+ fireEvent.click(screen.getByRole('button', { name: 'Set shortcut 1' }));
52
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel assignment' }));
53
+ expect(screen.getByRole('button', { name: 'Cast Spell 8' })).toBeTruthy();
54
+ });
55
+
56
+ it('blocks cast on low skills, mana and cooldown but permits assignment without mana', () => {
57
+ const onClick = jest.fn();
58
+ const spell = { ...spells[0], attribute: BasicAttribute.Strength, minSkillLevelRequired: 10 };
59
+ const card = { atlasIMG: '', atlasJSON: {}, spellKey: spell.key, spell, charMagicLevel: 100, charMana: 200, onClick };
60
+ const { rerender } = render(<Spell {...card} charSkillLevels={{ strength: 2 }} />);
61
+ fireEvent.click(screen.getByRole('button'));
62
+ expect(onClick).not.toHaveBeenCalled();
63
+ rerender(<Spell {...card} charSkillLevels={{ strength: 20 }} activeCooldown={2} />);
64
+ fireEvent.click(screen.getByRole('button'));
65
+ expect(onClick).not.toHaveBeenCalled();
66
+ rerender(<Spell {...card} charSkillLevels={{ strength: 20 }} charMana={0} />);
67
+ fireEvent.click(screen.getByRole('button'));
68
+ expect(onClick).not.toHaveBeenCalled();
69
+ rerender(<Spell {...card} charSkillLevels={{ strength: 20 }} charMana={0} isSettingShortcut />);
70
+ fireEvent.click(screen.getByRole('button'));
71
+ expect(onClick).toHaveBeenCalledWith(spell.key);
72
+ });
73
+
74
+ it('clears an occupied shortcut without starting assignment', () => {
75
+ const shortcuts = [{ type: ShortcutType.Spell, payload: spells[0] as IRawSpell }];
76
+ const removeShortcut = (index: number) => { shortcuts.splice(index, 1); };
77
+ const view = render(<Spellbook {...props} shortcuts={shortcuts} removeShortcut={removeShortcut} />);
78
+
79
+ fireEvent.click(screen.getByRole('button', { name: 'Clear shortcut 1' }));
80
+ expect(screen.queryByText('Choose a spell for shortcut 1')).toBeNull();
81
+
82
+ view.rerender(<Spellbook {...props} shortcuts={shortcuts} removeShortcut={removeShortcut} />);
83
+ fireEvent.click(screen.getByRole('button', { name: 'Set shortcut 1' }));
84
+ expect(screen.getByText('Choose a spell for shortcut 1')).toBeTruthy();
85
+ });
86
+
87
+ it('spells out requirement, mana cost and cooldown and only prompts when assigning', () => {
88
+ const spell = { ...spells[0], attribute: BasicAttribute.MagicResistance, minSkillLevelRequired: 4, manaCost: 13, cooldown: 5 };
89
+ const card = { atlasIMG: '', atlasJSON: {}, spellKey: spell.key, spell, charMagicLevel: 100, charMana: 200, onClick: jest.fn() };
90
+ const ready = { ...card, charSkillLevels: { magicResistance: 9 } };
91
+ const { rerender } = render(<Spell {...ready} />);
92
+ expect(screen.getByText('Requires Magic Resistance 4')).toBeTruthy();
93
+ expect(screen.getByText('Mana cost')).toBeTruthy();
94
+ expect(screen.getByText('Cooldown')).toBeTruthy();
95
+ expect(screen.queryByText(/Select to/)).toBeNull();
96
+ const accessibleDescription = () => screen.getByRole('button').getAttribute('aria-describedby')!
97
+ .split(' ').map(id => document.getElementById(id)?.textContent).join(' ');
98
+ expect(accessibleDescription()).toContain(spell.description);
99
+ expect(accessibleDescription()).toContain('Requires Magic Resistance 4');
100
+ expect(accessibleDescription()).toContain('Mana cost');
101
+
102
+ rerender(<Spell {...ready} isSettingShortcut />);
103
+ expect(screen.getByText('Select to assign')).toBeTruthy();
104
+ rerender(<Spell {...ready} charMana={0} />);
105
+ expect(screen.getByText('Not enough mana')).toBeTruthy();
106
+ expect(accessibleDescription()).toContain('Not enough mana');
107
+ rerender(<Spell {...ready} activeCooldown={2} />);
108
+ expect(screen.getByText('Ready in 2.0s')).toBeTruthy();
109
+ expect(accessibleDescription()).toContain('Ready in 2.0s');
110
+ rerender(<Spell {...card} charSkillLevels={{ magicResistance: 1 }} />);
111
+ expect(screen.getAllByText('Requires Magic Resistance 4')).toHaveLength(1);
112
+ expect((screen.getByRole('button') as HTMLButtonElement).disabled).toBe(true);
113
+ });
@@ -0,0 +1 @@
1
+ module.exports = 'book.png';
@@ -9,6 +9,13 @@ interface ISpellInfoProps {
9
9
  spell: ISpell;
10
10
  }
11
11
 
12
+ // "magicResistance" -> "Magic Resistance", "fire" -> "Fire"
13
+ const humanize = (value: string): string =>
14
+ formatSpellCastingType(value.replace(/([a-z])([A-Z])/g, '$1 $2'));
15
+
16
+ const humanizeList = (values: string[]): string =>
17
+ values.map(humanize).join(', ');
18
+
12
19
  export const SpellInfo: React.FC<ISpellInfoProps> = ({ spell }) => {
13
20
  const {
14
21
  magicWords,
@@ -19,7 +26,46 @@ export const SpellInfo: React.FC<ISpellInfoProps> = ({ spell }) => {
19
26
  castingType,
20
27
  cooldown,
21
28
  maxDistanceGrid,
29
+ minLevelRequired,
30
+ minMagicLevelRequired,
31
+ minSkillLevelRequired,
32
+ attribute,
33
+ elementalType,
34
+ canSelfTarget,
35
+ isPVPOnly,
36
+ characterClass,
37
+ characterRace,
38
+ onlyPremiumAccountType,
22
39
  } = spell;
40
+
41
+ // Same gate as the card and the API: a skill attribute replaces magic level.
42
+ const skill =
43
+ attribute && minSkillLevelRequired !== undefined
44
+ ? { label: humanize(attribute), level: minSkillLevelRequired }
45
+ : { label: 'Magic', level: minMagicLevelRequired };
46
+
47
+ // Only fields present in the payload are listed. Nothing is defaulted, and an
48
+ // empty restriction list is not shown as a restriction.
49
+ const details: Array<[string, string | number]> = [];
50
+ if (elementalType) details.push(['Element:', humanize(elementalType)]);
51
+ if (canSelfTarget !== undefined) {
52
+ details.push(['Can target self:', canSelfTarget ? 'Yes' : 'No']);
53
+ }
54
+ if (isPVPOnly) details.push(['PvP only:', 'Yes']);
55
+ if (minLevelRequired > 0) details.push(['Requires Level:', minLevelRequired]);
56
+ if (skill.level > 0) {
57
+ details.push([`Requires ${skill.label} Level:`, skill.level]);
58
+ }
59
+ if (characterClass?.length) {
60
+ details.push(['Requires Class:', humanizeList(characterClass)]);
61
+ }
62
+ if (characterRace?.length) {
63
+ details.push(['Requires Race:', humanizeList(characterRace)]);
64
+ }
65
+ if (onlyPremiumAccountType?.length) {
66
+ details.push(['Eligible account types:', humanizeList(onlyPremiumAccountType)]);
67
+ }
68
+
23
69
  return (
24
70
  <Container>
25
71
  <Header>
@@ -50,6 +96,12 @@ export const SpellInfo: React.FC<ISpellInfoProps> = ({ spell }) => {
50
96
  <div className="value">{maxDistanceGrid}</div>
51
97
  </Statistic>
52
98
  )}
99
+ {details.map(([label, value]) => (
100
+ <Statistic key={label}>
101
+ <div className="label">{label}</div>
102
+ <div className="value">{value}</div>
103
+ </Statistic>
104
+ ))}
53
105
  <Statistic>
54
106
  {requiredItem && (
55
107
  <>
@@ -64,62 +116,65 @@ export const SpellInfo: React.FC<ISpellInfoProps> = ({ spell }) => {
64
116
  };
65
117
 
66
118
  const Container = styled.div`
67
- color: white;
68
- background-color: #222;
69
- border-radius: 5px;
70
- padding: 0.5rem;
71
- font-size: ${uiFonts.size.small};
72
- border: 3px solid ${uiColors.lightGray};
119
+ box-sizing: border-box;
73
120
  height: max-content;
74
- width: 30rem;
75
-
76
- @media (max-width: 580px) {
77
- width: 80vw;
78
- }
121
+ width: min(30rem, 86vw);
122
+ padding: 12px 14px;
123
+ color: #f4e2bb;
124
+ background: linear-gradient(#3f2815, #2c1b0d);
125
+ border: 3px double #c99a52;
126
+ border-radius: 4px;
127
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.55);
128
+ font-family: 'Press Start 2P', monospace;
129
+ font-size: ${uiFonts.size.small};
130
+ line-height: 1.7;
79
131
  `;
80
132
 
81
133
  const Title = styled.div`
82
- font-size: ${uiFonts.size.medium};
83
- font-weight: bold;
84
- margin-bottom: 0.5rem;
85
- display: flex;
86
- align-items: center;
87
134
  margin: 0;
135
+ font-size: ${uiFonts.size.medium};
136
+ color: #ffe3a3;
88
137
  `;
89
138
 
90
139
  const Type = styled.div`
140
+ margin-top: 4px;
91
141
  font-size: ${uiFonts.size.small};
92
- margin-top: 0.2rem;
93
- color: ${uiColors.lightGray};
142
+ color: #d9b878;
94
143
  `;
95
144
 
96
145
  const Description = styled.div`
97
- margin-top: 1.5rem;
146
+ margin-top: 12px;
147
+ padding-top: 10px;
148
+ border-top: 1px solid rgba(244, 226, 187, 0.3);
98
149
  font-size: ${uiFonts.size.small};
99
- color: ${uiColors.lightGray};
100
- font-style: italic;
150
+ color: #e8d5ab;
101
151
  `;
102
152
 
103
153
  const Header = styled.div`
104
154
  display: flex;
105
155
  align-items: center;
106
156
  justify-content: space-between;
107
- margin-bottom: 0.5rem;
157
+ margin-bottom: 10px;
108
158
  `;
109
159
 
110
160
  const Statistic = styled.div`
111
- margin-bottom: 0.4rem;
112
- width: max-content;
161
+ display: flex;
162
+ justify-content: space-between;
163
+ gap: 16px;
164
+ padding: 3px 0;
165
+ border-bottom: 1px dotted rgba(244, 226, 187, 0.22);
166
+
167
+ &:empty {
168
+ display: none;
169
+ }
113
170
 
114
171
  .label {
115
- display: inline-block;
116
- margin-right: 0.5rem;
117
- color: inherit;
172
+ color: #cdb48a;
118
173
  }
119
174
 
120
175
  .value {
121
- display: inline-block;
122
- color: inherit;
176
+ color: #f4e2bb;
177
+ text-align: right;
123
178
  }
124
179
 
125
180
  &.better,
@@ -1,5 +1,5 @@
1
1
  import { ISpell } from '@rpg-engine/shared';
2
- import React, { useRef, useState } from 'react';
2
+ import React, { useEffect, useRef, useState } from 'react';
3
3
  import { MobileSpellTooltip } from './MobileSpellTooltip';
4
4
  import { MagicTooltip } from './SpellTooltip';
5
5
 
@@ -19,40 +19,75 @@ export const SpellInfoWrapper: React.FC<ISpellInfoWrapperProps> = ({
19
19
  const [isTooltipVisible, setIsTooltipVisible] = useState(false);
20
20
  const [isTooltipMobileVisible, setIsTooltipMobileVisible] = useState(false);
21
21
  const touchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
22
+ const wrapperRef = useRef<HTMLDivElement>(null);
22
23
 
23
- const handleTouchStart = () => {
24
+ const suppressClickRef = useRef(false);
25
+ const clearHold = (): void => {
26
+ if (touchTimeoutRef.current) clearTimeout(touchTimeoutRef.current);
27
+ touchTimeoutRef.current = null;
28
+ };
29
+ useEffect(() => clearHold, []);
30
+
31
+ const handleTouchStart = (): void => {
32
+ clearHold();
33
+ suppressClickRef.current = false;
24
34
  touchTimeoutRef.current = setTimeout(() => {
35
+ touchTimeoutRef.current = null;
36
+ suppressClickRef.current = true;
25
37
  setIsTooltipMobileVisible(true);
26
38
  setIsTooltipVisible(false);
27
39
  }, holdTouchDuration);
28
40
  };
29
41
 
30
- const handleTouchEnd = () => {
31
- if (touchTimeoutRef.current) {
32
- clearTimeout(touchTimeoutRef.current);
33
- touchTimeoutRef.current = null;
34
- }
35
- };
42
+
36
43
 
37
44
  return (
38
45
  <div
46
+ ref={wrapperRef}
47
+ onFocus={event => {
48
+ if (!isTooltipMobileVisible && (event.target as HTMLElement).matches(':focus-visible')) {
49
+ setIsTooltipVisible(true);
50
+ }
51
+ }}
52
+ onBlur={event => {
53
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
54
+ setIsTooltipVisible(false);
55
+ }
56
+ }}
57
+ onKeyDown={event => {
58
+ if (event.key === 'Escape' && isTooltipVisible) {
59
+ event.preventDefault();
60
+ event.stopPropagation();
61
+ setIsTooltipVisible(false);
62
+ }
63
+ }}
39
64
  onMouseEnter={() => {
40
65
  if (!isTooltipMobileVisible) setIsTooltipVisible(true);
41
66
  }}
42
- onMouseLeave={setIsTooltipVisible.bind(null, false)}
67
+ onMouseLeave={event => {
68
+ if (!event.currentTarget.contains(document.activeElement)) setIsTooltipVisible(false);
69
+ }}
43
70
  onTouchStart={handleTouchStart}
44
- onTouchEnd={handleTouchEnd}
71
+ onTouchEnd={clearHold}
72
+ onTouchCancel={clearHold}
73
+ onTouchMove={() => { clearHold(); suppressClickRef.current = true; }}
74
+ onClickCapture={event => {
75
+ if (suppressClickRef.current && event.currentTarget.contains(event.target as Node)) {
76
+ event.preventDefault();
77
+ event.stopPropagation();
78
+ suppressClickRef.current = false;
79
+ }
80
+ }}
45
81
  >
46
82
  {children}
47
83
 
48
84
  {isTooltipVisible && !isTooltipMobileVisible && (
49
- <MagicTooltip spell={spell} />
85
+ <MagicTooltip spell={spell} anchor={wrapperRef.current} />
50
86
  )}
51
87
  {isTooltipMobileVisible && (
52
88
  <MobileSpellTooltip
53
89
  closeTooltip={() => {
54
90
  setIsTooltipMobileVisible(false);
55
- console.log('close');
56
91
  }}
57
92
  spell={spell}
58
93
  scale={scale}
@@ -60,4 +95,4 @@ export const SpellInfoWrapper: React.FC<ISpellInfoWrapperProps> = ({
60
95
  )}
61
96
  </div>
62
97
  );
63
- };
98
+ };
@@ -6,19 +6,19 @@ import { SpellInfoDisplay } from './SpellInfoDisplay';
6
6
 
7
7
  export interface IMagicTooltipProps {
8
8
  spell: ISpell;
9
+ anchor?: HTMLElement | null;
9
10
  }
10
11
 
11
12
  const offset = 20;
12
13
 
13
- export const MagicTooltip: React.FC<IMagicTooltipProps> = ({ spell }) => {
14
+ export const MagicTooltip: React.FC<IMagicTooltipProps> = ({ spell, anchor }) => {
14
15
  const ref = useRef<HTMLDivElement>(null);
15
16
 
16
17
  useEffect(() => {
17
18
  const { current } = ref;
18
19
 
19
20
  if (current) {
20
- const handleMouseMove = (event: MouseEvent) => {
21
- const { clientX, clientY } = event;
21
+ const position = (clientX: number, clientY: number): void => {
22
22
 
23
23
  // Adjust the position of the tooltip based on the mouse position
24
24
  const rect = current.getBoundingClientRect();
@@ -36,10 +36,17 @@ export const MagicTooltip: React.FC<IMagicTooltipProps> = ({ spell }) => {
36
36
  ? clientY - tooltipHeight - offset
37
37
  : clientY + offset;
38
38
 
39
- current.style.transform = `translate(${x}px, ${y}px)`;
39
+ const boundedX = Math.max(8, Math.min(x, window.innerWidth - tooltipWidth - 8));
40
+ const boundedY = Math.max(8, Math.min(y, window.innerHeight - tooltipHeight - 8));
41
+ current.style.transform = `translate(${boundedX}px, ${boundedY}px)`;
40
42
  current.style.opacity = '1';
41
43
  };
42
44
 
45
+ const handleMouseMove = (event: MouseEvent): void => position(event.clientX, event.clientY);
46
+ if (anchor) {
47
+ const rect = anchor.getBoundingClientRect();
48
+ position(rect.right, rect.top);
49
+ }
43
50
  window.addEventListener('mousemove', handleMouseMove);
44
51
 
45
52
  return () => {
@@ -48,11 +55,11 @@ export const MagicTooltip: React.FC<IMagicTooltipProps> = ({ spell }) => {
48
55
  }
49
56
 
50
57
  return;
51
- }, []);
58
+ }, [anchor]);
52
59
 
53
60
  return (
54
61
  <ModalPortal>
55
- <Container ref={ref}>
62
+ <Container ref={ref} role="tooltip" id={`spell-details-${spell.key}`}>
56
63
  <SpellInfoDisplay spell={spell} />
57
64
  </Container>
58
65
  </ModalPortal>