@gustavobrunodev/ai-tools 1.0.1 → 1.1.0

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.
Files changed (106) hide show
  1. package/CHANGELOG.md +0 -0
  2. package/jest.config.ts +26 -0
  3. package/package.json +1 -1
  4. package/project.json +74 -0
  5. package/src/app.tsx +56 -0
  6. package/src/atoms/deprecatedSkills.ts +11 -0
  7. package/src/atoms/environmentCheck.ts +51 -0
  8. package/src/atoms/installedSkills.ts +36 -0
  9. package/src/atoms/wizard.ts +6 -0
  10. package/src/cli/audit.ts +21 -0
  11. package/src/cli/cache.ts +28 -0
  12. package/src/cli/install.ts +93 -0
  13. package/src/cli/list.ts +41 -0
  14. package/src/cli/remove.ts +70 -0
  15. package/src/cli/update.ts +107 -0
  16. package/src/components/AnimatedTransition.tsx +42 -0
  17. package/src/components/AuditLogViewer.tsx +85 -0
  18. package/src/components/CategoryHeader.tsx +39 -0
  19. package/src/components/ConfirmPrompt.tsx +34 -0
  20. package/src/components/FooterBar.tsx +36 -0
  21. package/src/components/Header.tsx +97 -0
  22. package/src/components/InstallResults.tsx +110 -0
  23. package/src/components/KeyboardShortcutsOverlay.tsx +112 -0
  24. package/src/components/MultiSelectPrompt.tsx +219 -0
  25. package/src/components/SearchInput.tsx +36 -0
  26. package/src/components/SelectPrompt.tsx +108 -0
  27. package/src/components/SkillCard.tsx +74 -0
  28. package/src/components/SkillDetailPanel.tsx +233 -0
  29. package/src/components/StatusBadge.tsx +45 -0
  30. package/src/components/__tests__/AnimatedTransition.pbt.test.tsx +51 -0
  31. package/src/components/__tests__/CategoryHeader.pbt.test.tsx +107 -0
  32. package/src/components/__tests__/CategoryHeader.test.tsx +105 -0
  33. package/src/components/__tests__/KeyboardShortcutsOverlay.pbt.test.tsx +155 -0
  34. package/src/components/__tests__/KeyboardShortcutsOverlay.test.tsx +136 -0
  35. package/src/components/__tests__/SkillDetailPanel.test.tsx +273 -0
  36. package/src/components/index.ts +12 -0
  37. package/src/hooks/__tests__/useConfig.test.ts +242 -0
  38. package/src/hooks/index.ts +9 -0
  39. package/src/hooks/useAgents.ts +28 -0
  40. package/src/hooks/useConfig.ts +114 -0
  41. package/src/hooks/useFilter.ts +31 -0
  42. package/src/hooks/useInstaller.ts +39 -0
  43. package/src/hooks/useKeyboardNav.ts +39 -0
  44. package/src/hooks/useKonamiCode.ts +48 -0
  45. package/src/hooks/useRemover.ts +59 -0
  46. package/src/hooks/useSkillContent.ts +67 -0
  47. package/src/hooks/useSkills.ts +38 -0
  48. package/src/hooks/useWizardStep.ts +19 -0
  49. package/src/index.ts +129 -0
  50. package/src/services/__tests__/audit-log.spec.ts +220 -0
  51. package/src/services/__tests__/badge-format.test.ts +102 -0
  52. package/src/services/__tests__/category-colors.test.ts +253 -0
  53. package/src/services/__tests__/config.test.ts +184 -0
  54. package/src/services/__tests__/installer.security.spec.ts +151 -0
  55. package/src/services/__tests__/lockfile.security.spec.ts +132 -0
  56. package/src/services/__tests__/markdown-parser.spec.ts +185 -0
  57. package/src/services/__tests__/terminal-dimensions.pbt.test.ts +246 -0
  58. package/src/services/__tests__/terminal-dimensions.test.ts +109 -0
  59. package/src/services/__tests__/update-cache.pbt.test.ts +214 -0
  60. package/src/services/__tests__/update-cache.test.ts +215 -0
  61. package/src/services/agents.ts +42 -0
  62. package/src/services/audio-player.ts +55 -0
  63. package/src/services/audit-log.ts +69 -0
  64. package/src/services/badge-format.ts +4 -0
  65. package/src/services/categories.ts +176 -0
  66. package/src/services/category-colors.ts +19 -0
  67. package/src/services/config.ts +84 -0
  68. package/src/services/github-contributors.ts +56 -0
  69. package/src/services/global-path.ts +20 -0
  70. package/src/services/index.ts +21 -0
  71. package/src/services/installer.ts +371 -0
  72. package/src/services/lockfile.ts +177 -0
  73. package/src/services/markdown-parser.ts +108 -0
  74. package/src/services/package-info.ts +19 -0
  75. package/src/services/project-root.ts +18 -0
  76. package/src/services/registry.ts +382 -0
  77. package/src/services/skills-provider.ts +169 -0
  78. package/src/services/terminal-dimensions.ts +18 -0
  79. package/src/services/update-cache.ts +65 -0
  80. package/src/services/update-check.ts +26 -0
  81. package/src/theme/colors.ts +24 -0
  82. package/src/theme/index.ts +2 -0
  83. package/src/theme/symbols.ts +22 -0
  84. package/src/types.ts +38 -0
  85. package/src/utils/constants.ts +49 -0
  86. package/src/utils/paths.ts +52 -0
  87. package/src/views/ActionSelector.tsx +45 -0
  88. package/src/views/AgentSelector.tsx +105 -0
  89. package/src/views/CreditsView.tsx +332 -0
  90. package/src/views/InstallConfig.tsx +162 -0
  91. package/src/views/InstallWizard.tsx +181 -0
  92. package/src/views/ListView.tsx +41 -0
  93. package/src/views/RemoveWizard.tsx +237 -0
  94. package/src/views/SkillBrowser.tsx +504 -0
  95. package/src/views/UpdateView.tsx +272 -0
  96. package/src/views/arcade/ArcadeMenu.tsx +89 -0
  97. package/src/views/arcade/VibeInvaders.tsx +339 -0
  98. package/src/views/arcade/index.ts +2 -0
  99. package/src/views/index.ts +11 -0
  100. package/tsconfig.json +19 -0
  101. package/tsconfig.spec.json +25 -0
  102. package/LICENSE +0 -26
  103. package/README.md +0 -257
  104. package/index.js +0 -12
  105. package/index.js.map +0 -7
  106. /package/{assets → src/assets}/chiptune.mp3 +0 -0
@@ -0,0 +1,272 @@
1
+ import { Box, Text, useInput } from 'ink'
2
+ import Spinner from 'ink-spinner'
3
+ import { useAtomValue } from 'jotai'
4
+ import { useEffect, useMemo, useState } from 'react'
5
+
6
+ import { deprecatedSkillsAtom } from '../atoms/deprecatedSkills'
7
+ import { installedSkillsAtom } from '../atoms/installedSkills'
8
+ import { Header } from '../components/Header'
9
+ import { InstallResults } from '../components/InstallResults'
10
+ import { MultiSelectPrompt } from '../components/MultiSelectPrompt'
11
+ import { useInstaller } from '../hooks/useInstaller'
12
+ import { useSkills } from '../hooks/useSkills'
13
+ import { getUpdatableSkills } from '../services/registry'
14
+ import { colors, symbols } from '../theme'
15
+ import type { AgentType, DeprecatedEntry, SkillInfo } from '../types'
16
+ import { AgentSelector } from './AgentSelector'
17
+
18
+ export function UpdateView({ selectedAgents, onExit }: { selectedAgents?: AgentType[]; onExit: () => void }) {
19
+ const [checkingUpdates, setCheckingUpdates] = useState(false)
20
+ const [updateCheckComplete, setUpdateCheckComplete] = useState(false)
21
+ const [installComplete, setInstallComplete] = useState(false)
22
+ const [internalAgents, setInternalAgents] = useState<AgentType[]>(selectedAgents || [])
23
+ const [showAgentSelect, setShowAgentSelect] = useState(!selectedAgents)
24
+ const [updatableSkills, setUpdatableSkills] = useState<SkillInfo[]>([])
25
+ const { install, progress, results, installing } = useInstaller()
26
+ const installedSkills = useAtomValue(installedSkillsAtom)
27
+ const deprecatedMap = useAtomValue(deprecatedSkillsAtom)
28
+ const { skills, loading: loadingSkills } = useSkills()
29
+
30
+ const activeAgents = selectedAgents || internalAgents
31
+
32
+ const installedList = useMemo(() => {
33
+ if (loadingSkills) return []
34
+ const installedNames = new Set(Object.keys(installedSkills))
35
+
36
+ return skills.filter((s) => {
37
+ if (!installedNames.has(s.name)) return false
38
+ const agents = installedSkills[s.name] || []
39
+ return agents.some((a: AgentType) => activeAgents.includes(a))
40
+ })
41
+ }, [installedSkills, skills, loadingSkills, activeAgents])
42
+
43
+ const deprecatedInstalled = useMemo(() => {
44
+ if (loadingSkills || !(deprecatedMap instanceof Map) || deprecatedMap.size === 0) return []
45
+ const installedNames = new Set(Object.keys(installedSkills))
46
+ const registryNames = new Set(skills.map((s) => s.name))
47
+
48
+ const result: { name: string; entry?: DeprecatedEntry }[] = []
49
+
50
+ for (const name of installedNames) {
51
+ if (deprecatedMap.has(name)) result.push({ name, entry: deprecatedMap.get(name) })
52
+ else if (!registryNames.has(name)) result.push({ name })
53
+ }
54
+
55
+ return result
56
+ }, [installedSkills, skills, loadingSkills, deprecatedMap])
57
+
58
+ useEffect(() => {
59
+ if (installedList.length === 0) {
60
+ setCheckingUpdates(false)
61
+ setUpdateCheckComplete(true)
62
+ return
63
+ }
64
+
65
+ setCheckingUpdates(true)
66
+ const checkUpdates = async () => {
67
+ const installedNames = installedList.map((s) => s.name)
68
+ const { toUpdate } = await getUpdatableSkills(installedNames)
69
+ const skillsToUpdate = installedList.filter((s) => toUpdate.includes(s.name))
70
+ setUpdatableSkills(skillsToUpdate)
71
+ setCheckingUpdates(false)
72
+ setUpdateCheckComplete(true)
73
+ }
74
+
75
+ checkUpdates()
76
+ }, [installedList])
77
+
78
+ useInput((_, key) => {
79
+ if (
80
+ key.escape &&
81
+ !installing &&
82
+ !installComplete &&
83
+ !checkingUpdates &&
84
+ updateCheckComplete &&
85
+ updatableSkills.length === 0
86
+ ) {
87
+ onExit()
88
+ }
89
+ })
90
+
91
+ if (showAgentSelect) {
92
+ return (
93
+ <AgentSelector
94
+ onSelect={(agents) => {
95
+ setInternalAgents(agents)
96
+ setShowAgentSelect(false)
97
+ }}
98
+ onBack={onExit}
99
+ />
100
+ )
101
+ }
102
+
103
+ const handleUpdate = async (selectedSkills: SkillInfo[]) => {
104
+ if (selectedSkills.length === 0) return
105
+
106
+ const involvedAgents = new Set<AgentType>()
107
+ selectedSkills.forEach((s) => {
108
+ const agents = installedSkills[s.name] || []
109
+ agents.forEach((a: AgentType) => {
110
+ if (activeAgents.includes(a)) involvedAgents.add(a)
111
+ })
112
+ })
113
+
114
+ await install(selectedSkills, {
115
+ agents: Array.from(involvedAgents),
116
+ method: 'copy',
117
+ global: false,
118
+ skills: selectedSkills.map((s) => s.name),
119
+ isUpdate: true,
120
+ })
121
+ setInstallComplete(true)
122
+ }
123
+
124
+ if (installComplete) {
125
+ return (
126
+ <InstallResults results={results} onExit={onExit} title="Skills Updated Successfully" successLabel="updated" />
127
+ )
128
+ }
129
+
130
+ if (installing) {
131
+ return (
132
+ <Box flexDirection="column" paddingX={1}>
133
+ <Header />
134
+ <Box marginTop={1}>
135
+ <Text color={colors.accent}>
136
+ <Spinner type="dots" />{' '}
137
+ </Text>
138
+ <Text>Updating skills...</Text>
139
+ </Box>
140
+ <Box marginTop={1} paddingX={2}>
141
+ <Text color={colors.textDim}>
142
+ {symbols.arrow} {progress.skill} ({progress.current}/{progress.total})
143
+ </Text>
144
+ </Box>
145
+ </Box>
146
+ )
147
+ }
148
+
149
+ if (loadingSkills || checkingUpdates) {
150
+ return (
151
+ <Box flexDirection="column" paddingX={1}>
152
+ <Header />
153
+ <Box marginTop={1}>
154
+ <Text color={colors.accent}>
155
+ <Spinner type="dots" /> {checkingUpdates ? 'Checking for updates...' : 'Loading...'}
156
+ </Text>
157
+ </Box>
158
+ </Box>
159
+ )
160
+ }
161
+
162
+ if (updateCheckComplete && updatableSkills.length === 0) {
163
+ return (
164
+ <Box flexDirection="column" paddingX={1}>
165
+ <Header />
166
+ <Box borderStyle="round" borderColor={colors.success} paddingX={2} paddingY={1}>
167
+ <Text color={colors.success}>{symbols.check} All installed skills are up to date!</Text>
168
+ </Box>
169
+
170
+ {deprecatedInstalled.length > 0 && (
171
+ <Box
172
+ flexDirection="column"
173
+ marginTop={1}
174
+ borderStyle="round"
175
+ borderColor={colors.warning}
176
+ paddingX={2}
177
+ paddingY={1}
178
+ >
179
+ <Box marginBottom={1}>
180
+ <Text color={colors.warning} bold>
181
+ {symbols.warning} {deprecatedInstalled.length} deprecated skill
182
+ {deprecatedInstalled.length > 1 ? 's' : ''} detected:
183
+ </Text>
184
+ </Box>
185
+ {deprecatedInstalled.map((d) => (
186
+ <Box key={d.name} flexDirection="column" paddingX={1} marginBottom={1}>
187
+ <Text color={colors.warning}>
188
+ {symbols.arrow} {d.name}
189
+ </Text>
190
+ {d.entry?.message && <Text color={colors.textDim}> {d.entry.message}</Text>}
191
+ {!d.entry && <Text color={colors.textDim}> No longer available in the registry</Text>}
192
+ {d.entry?.alternatives && d.entry.alternatives.length > 0 && (
193
+ <Text color={colors.textDim}>
194
+ {' '}Try: ai-tools install --skill {d.entry.alternatives.join(', ')}
195
+ </Text>
196
+ )}
197
+ </Box>
198
+ ))}
199
+ <Text color={colors.textMuted}>Run: ai-tools remove --skill {'<name>'} to clean up</Text>
200
+ </Box>
201
+ )}
202
+
203
+ <Box marginTop={1} borderStyle="round" borderColor={colors.border} paddingX={1}>
204
+ <Text>
205
+ <Text color={colors.warning} bold>
206
+ esc
207
+ </Text>
208
+ <Text color={colors.textDim}> exit</Text>
209
+ </Text>
210
+ </Box>
211
+ </Box>
212
+ )
213
+ }
214
+
215
+ return (
216
+ <UpdateSelector
217
+ skills={updatableSkills}
218
+ installedSkills={installedSkills}
219
+ selectedAgents={activeAgents}
220
+ onUpdate={handleUpdate}
221
+ onExit={onExit}
222
+ />
223
+ )
224
+ }
225
+
226
+ function UpdateSelector({
227
+ skills,
228
+ installedSkills,
229
+ selectedAgents,
230
+ onUpdate,
231
+ onExit,
232
+ }: {
233
+ skills: SkillInfo[]
234
+ installedSkills: Record<string, AgentType[]>
235
+ selectedAgents: AgentType[]
236
+ onUpdate: (skills: SkillInfo[]) => void
237
+ onExit: () => void
238
+ }) {
239
+ const items = skills.map((s) => {
240
+ const allAgents = installedSkills[s.name] || []
241
+ const filteredAgents = allAgents.filter((a) => selectedAgents.includes(a))
242
+ return {
243
+ label: s.name,
244
+ value: s.name,
245
+ hint: `${filteredAgents.length} agent${filteredAgents.length > 1 ? 's' : ''}: ${filteredAgents.join(', ')}`,
246
+ }
247
+ })
248
+
249
+ const allValues = skills.map((s) => s.name)
250
+
251
+ const handleSubmit = (selectedNames: string[]) => {
252
+ const selectedSkills = skills.filter((s) => selectedNames.includes(s.name))
253
+ onUpdate(selectedSkills)
254
+ }
255
+
256
+ return (
257
+ <Box flexDirection="column" paddingX={1}>
258
+ <Header />
259
+ <Box marginBottom={1}>
260
+ <Text bold color={colors.primary}>
261
+ {symbols.diamond} Select skills to update:
262
+ </Text>
263
+ </Box>
264
+ <Box marginBottom={1}>
265
+ <Text color={colors.textDim}>
266
+ {skills.length} installed skill{skills.length > 1 ? 's' : ''} found {symbols.dot} all pre-selected
267
+ </Text>
268
+ </Box>
269
+ <MultiSelectPrompt items={items} initialSelected={allValues} onSubmit={handleSubmit} onCancel={onExit} />
270
+ </Box>
271
+ )
272
+ }
@@ -0,0 +1,89 @@
1
+ import { Box, Text } from 'ink'
2
+ import BigText from 'ink-big-text'
3
+ import Gradient from 'ink-gradient'
4
+ import { useEffect, useState } from 'react'
5
+
6
+ import { FooterBar } from '../../components/FooterBar'
7
+ import { SelectPrompt } from '../../components/SelectPrompt'
8
+ import { colors, symbols } from '../../theme'
9
+ import { VibeInvaders } from './VibeInvaders'
10
+
11
+ type ArcadeScreen = 'menu' | 'invaders'
12
+
13
+ interface ArcadeMenuProps {
14
+ onExit: () => void
15
+ }
16
+
17
+ const menuItems = [
18
+ { label: 'Vibe Invaders', value: 'invaders' as const, hint: 'Fight vibe-coding!' },
19
+ { label: 'Back', value: 'back' as const, hint: 'Return to CLI' },
20
+ ]
21
+
22
+ const SCANLINE = '\u2591'.repeat(56)
23
+
24
+ export function ArcadeMenu({ onExit }: ArcadeMenuProps) {
25
+ const [screen, setScreen] = useState<ArcadeScreen>('menu')
26
+ const [blinkVisible, setBlinkVisible] = useState(true)
27
+
28
+ useEffect(() => {
29
+ const interval = setInterval(() => setBlinkVisible((v) => !v), 600)
30
+ return () => clearInterval(interval)
31
+ }, [])
32
+
33
+ const handleSelect = (value: 'invaders' | 'back') => {
34
+ if (value === 'back') {
35
+ onExit()
36
+ return
37
+ }
38
+
39
+ setScreen(value)
40
+ }
41
+
42
+ if (screen === 'invaders') return <VibeInvaders onExit={() => setScreen('menu')} />
43
+
44
+ return (
45
+ <Box flexDirection="column" alignItems="center">
46
+ <Box marginBottom={0}>
47
+ <Gradient name="cristal">
48
+ <Text>{SCANLINE}</Text>
49
+ </Gradient>
50
+ </Box>
51
+
52
+ <Box marginBottom={0}>
53
+ <Gradient name="pastel">
54
+ <BigText text="ARCADE" font="chrome" />
55
+ </Gradient>
56
+ </Box>
57
+
58
+ <Box marginBottom={0}>
59
+ <Gradient name="cristal">
60
+ <Text>{SCANLINE}</Text>
61
+ </Gradient>
62
+ </Box>
63
+
64
+ <Box marginBottom={1} marginTop={1}>
65
+ <Text color={blinkVisible ? colors.warning : colors.bg} bold>
66
+ {symbols.sparkle} SECRET UNLOCKED {symbols.sparkle}
67
+ </Text>
68
+ </Box>
69
+
70
+ <Box marginBottom={1}>
71
+ <Text color={colors.textDim}>
72
+ {symbols.diamond} Choose your adventure {symbols.diamond}
73
+ </Text>
74
+ </Box>
75
+
76
+ <Box width={50}>
77
+ <SelectPrompt items={menuItems} onSelect={handleSelect} onCancel={onExit} hideFooter />
78
+ </Box>
79
+
80
+ <FooterBar
81
+ hints={[
82
+ { key: '\u2191\u2193', label: 'navigate' },
83
+ { key: '\u23CE', label: 'select' },
84
+ { key: 'esc', label: 'back', color: colors.warning },
85
+ ]}
86
+ />
87
+ </Box>
88
+ )
89
+ }
@@ -0,0 +1,339 @@
1
+ import { Box, Text, useInput, useStdout } from 'ink'
2
+ import { useCallback, useEffect, useRef, useState } from 'react'
3
+
4
+ import { colors } from '../../theme'
5
+
6
+ const MAX_WIDTH = 100
7
+ const GAME_HEIGHT = 16
8
+ const TICK_MS = 50
9
+ const BASE_MOVE_EVERY = 10
10
+ const RATE_LIMIT_PENALTY = 30
11
+ const SNIPER_MULTIPLIER = 5
12
+
13
+ const LOSE_MESSAGES = [
14
+ 'BANKRUPT. Your burn rate was too high.',
15
+ 'DOWN ROUND. Valuation dropped to zero.',
16
+ 'RUNWAY EXPIRED. Back to living with parents.',
17
+ 'SERVER COSTS > REVENUE. You are cooked.',
18
+ 'AUDIT FAILED. Too much tech debt.',
19
+ ]
20
+
21
+ const WIN_MESSAGES = [
22
+ 'ACQUIRED BY BIG TECH. Golden handcuffs on.',
23
+ 'SERIES B SECURED. Keep burning cash!',
24
+ 'IPO SUCCESSFUL. Time to buy a yacht.',
25
+ 'PROFITABLE? No, but the vibes are great.',
26
+ ]
27
+
28
+ const INVADER_ROWS: string[][] = [
29
+ ['AGI_SOON', '100x_DEV', 'LOVABLE', 'HYPE'],
30
+ ['DEEPSEEK', 'GEMINI', 'GPT', 'V0_DEV'],
31
+ ['TAB_SPAM', 'NO_READ', 'TRUST_ME', 'YOLO'],
32
+ ['SLOP', 'SPAGHETTI', 'ANY_TYPE', 'BUG'],
33
+ ]
34
+
35
+ interface Position {
36
+ x: number
37
+ y: number
38
+ }
39
+
40
+ interface Invader extends Position {
41
+ label: string
42
+ alive: boolean
43
+ width: number
44
+ }
45
+
46
+ interface GameState {
47
+ player: Position
48
+ playerBullets: Position[]
49
+ enemyBullets: Position[]
50
+ invaders: Invader[]
51
+ score: number
52
+ lives: number
53
+ gameOver: boolean
54
+ won: boolean
55
+ invaderDirection: 1 | -1
56
+ tickCount: number
57
+ flash: boolean
58
+ glitch: boolean
59
+ rateLimited: number
60
+ }
61
+
62
+ function createInvaders(cols: number): Invader[] {
63
+ const invaders: Invader[] = []
64
+ const longestWord = Math.max(...INVADER_ROWS.flat().map((w) => w.length))
65
+ const colSpacing = longestWord + 4
66
+ const totalWidth = INVADER_ROWS[0].length * colSpacing
67
+ const startX = Math.floor((cols - totalWidth) / 2)
68
+
69
+ for (let row = 0; row < INVADER_ROWS.length; row++) {
70
+ for (let col = 0; col < INVADER_ROWS[row].length; col++) {
71
+ const label = INVADER_ROWS[row][col]
72
+ invaders.push({
73
+ x: Math.max(0, startX + col * colSpacing),
74
+ y: 1 + row * 2,
75
+ label,
76
+ width: label.length,
77
+ alive: true,
78
+ })
79
+ }
80
+ }
81
+
82
+ return invaders
83
+ }
84
+
85
+ interface VibeInvadersProps {
86
+ onExit: () => void
87
+ }
88
+
89
+ export function VibeInvaders({ onExit }: VibeInvadersProps) {
90
+ const { stdout } = useStdout()
91
+
92
+ const terminalCols = stdout?.columns ?? 80
93
+ const gameWidth = Math.max(60, Math.min(terminalCols - 4, MAX_WIDTH))
94
+ const finalMessageRef = useRef<string>('')
95
+
96
+ const [state, setState] = useState<GameState>(() => ({
97
+ player: { x: Math.floor(gameWidth / 2), y: GAME_HEIGHT - 1 },
98
+ playerBullets: [],
99
+ enemyBullets: [],
100
+ invaders: createInvaders(gameWidth),
101
+ score: 0,
102
+ lives: 5,
103
+ gameOver: false,
104
+ won: false,
105
+ invaderDirection: 1,
106
+ tickCount: 0,
107
+ flash: false,
108
+ glitch: false,
109
+ rateLimited: 0,
110
+ }))
111
+
112
+ if ((state.gameOver || state.won) && !finalMessageRef.current) {
113
+ const pool = state.won ? WIN_MESSAGES : LOSE_MESSAGES
114
+ finalMessageRef.current = pool[Math.floor(Math.random() * pool.length)]
115
+ }
116
+
117
+ useInput((input, key) => {
118
+ if (state.gameOver || state.won) {
119
+ if (key.return || key.escape) onExit()
120
+ return
121
+ }
122
+
123
+ if (key.escape) {
124
+ onExit()
125
+ return
126
+ }
127
+
128
+ setState((prev) => {
129
+ let newX = prev.player.x
130
+ if (key.leftArrow) newX = Math.max(0, prev.player.x - 2)
131
+ if (key.rightArrow) newX = Math.min(gameWidth - 1, prev.player.x + 2)
132
+
133
+ let newBullets = prev.playerBullets
134
+ let currentRateLimit = prev.rateLimited
135
+
136
+ if (input === ' ' && currentRateLimit === 0) {
137
+ if (prev.playerBullets.length >= 2) {
138
+ currentRateLimit = RATE_LIMIT_PENALTY
139
+ newBullets = []
140
+ } else {
141
+ newBullets = [...prev.playerBullets, { x: newX, y: prev.player.y - 1 }]
142
+ }
143
+ }
144
+
145
+ return { ...prev, player: { ...prev.player, x: newX }, playerBullets: newBullets, rateLimited: currentRateLimit }
146
+ })
147
+ })
148
+
149
+ const tick = useCallback(() => {
150
+ setState((prev) => {
151
+ if (prev.gameOver || prev.won) return prev
152
+
153
+ const tick = prev.tickCount + 1
154
+ let score = prev.score
155
+ let lives = prev.lives
156
+ let gameOver: boolean = prev.gameOver
157
+ let flash = false
158
+ let glitch = false
159
+ const rateLimited = prev.rateLimited > 0 ? prev.rateLimited - 1 : 0
160
+
161
+ // Logic
162
+ const aliveInvaders = prev.invaders.filter((i) => i.alive)
163
+ const totalInvaders = INVADER_ROWS.flat().length
164
+ const survivalRatio = aliveInvaders.length / totalInvaders
165
+ const moveEvery = Math.max(2, Math.floor(BASE_MOVE_EVERY * survivalRatio) + 1)
166
+ const shootChance = 0.02 + 0.08 * (1 - survivalRatio)
167
+
168
+ // Bullets
169
+ let pBullets = prev.playerBullets.map((b) => ({ ...b, y: b.y - 1 })).filter((b) => b.y >= 0)
170
+ let eBullets = prev.enemyBullets.map((b) => ({ ...b, y: b.y + 1 })).filter((b) => b.y < GAME_HEIGHT)
171
+
172
+ const invaders = prev.invaders.map((i) => ({ ...i }))
173
+
174
+ // Collisions: Player -> Invader
175
+ for (const b of pBullets) {
176
+ if (b.y === -1) continue
177
+ for (const inv of invaders) {
178
+ if (!inv.alive) continue
179
+ if (b.y === inv.y && b.x >= inv.x && b.x < inv.x + inv.width) {
180
+ inv.alive = false
181
+ b.y = -1
182
+ score += 100
183
+ flash = true
184
+ break
185
+ }
186
+ }
187
+ }
188
+
189
+ pBullets = pBullets.filter((b) => b.y !== -1)
190
+
191
+ // Collisions: Enemy -> Player
192
+ if (eBullets.some((b) => b.x === prev.player.x && b.y === prev.player.y)) {
193
+ lives -= 1
194
+ flash = true
195
+ glitch = true
196
+ eBullets = []
197
+ if (lives <= 0) gameOver = true
198
+ }
199
+
200
+ // Win?
201
+ if (invaders.every((i) => !i.alive)) return { ...prev, won: true, score: score + lives * 1000, invaders }
202
+
203
+ // Shoot
204
+ if (aliveInvaders.length > 0) {
205
+ const shooters = aliveInvaders.filter((inv) => {
206
+ const inSight = prev.player.x >= inv.x - 1 && prev.player.x <= inv.x + inv.width + 1
207
+ return Math.random() < (inSight ? shootChance * SNIPER_MULTIPLIER : shootChance)
208
+ })
209
+
210
+ if (shooters.length > 0) {
211
+ const s = shooters[Math.floor(Math.random() * shooters.length)]
212
+ eBullets.push({ x: s.x + Math.floor(s.width / 2), y: s.y + 1 })
213
+ }
214
+ }
215
+
216
+ // Move
217
+ let dir = prev.invaderDirection
218
+
219
+ if (tick % moveEvery === 0) {
220
+ const xs = invaders.filter((i) => i.alive).map((i) => i.x)
221
+ const minX = Math.min(...xs)
222
+ const maxX = Math.max(...invaders.filter((i) => i.alive).map((i) => i.x + i.width))
223
+ if (maxX >= gameWidth - 2 && dir === 1) dir = -1
224
+ if (minX <= 0 && dir === -1) dir = 1
225
+ invaders.forEach((i) => i.alive && (i.x += dir))
226
+ }
227
+
228
+ // Drop
229
+ if (tick % 45 === 0) {
230
+ invaders.forEach((i) => i.alive && (i.y += 1))
231
+ if (invaders.some((i) => i.alive && i.y >= GAME_HEIGHT - 1)) gameOver = true
232
+ }
233
+
234
+ return {
235
+ ...prev,
236
+ playerBullets: pBullets,
237
+ enemyBullets: eBullets,
238
+ invaders,
239
+ score,
240
+ lives,
241
+ gameOver,
242
+ invaderDirection: dir,
243
+ tickCount: tick,
244
+ flash,
245
+ glitch,
246
+ rateLimited,
247
+ }
248
+ })
249
+ }, [gameWidth])
250
+
251
+ useEffect(() => {
252
+ const t = setInterval(tick, TICK_MS)
253
+ return () => clearInterval(t)
254
+ }, [tick])
255
+
256
+ const renderGrid = () => {
257
+ const rows = Array.from({ length: GAME_HEIGHT }, () => Array(gameWidth).fill(' '))
258
+
259
+ state.invaders.forEach((inv) => {
260
+ if (inv.alive) {
261
+ for (let i = 0; i < inv.width; i++) {
262
+ if (inv.x + i < gameWidth && inv.y < GAME_HEIGHT) rows[inv.y][inv.x + i] = state.glitch ? '?' : inv.label[i]
263
+ }
264
+ }
265
+ })
266
+
267
+ state.playerBullets.forEach((b) => {
268
+ if (b.x < gameWidth && b.y < GAME_HEIGHT) rows[b.y][b.x] = '$'
269
+ })
270
+
271
+ state.enemyBullets.forEach((b) => {
272
+ if (b.x < gameWidth && b.y < GAME_HEIGHT) rows[b.y][b.x] = '*'
273
+ })
274
+
275
+ const { x, y } = state.player
276
+ if (x < gameWidth && y < GAME_HEIGHT) rows[y][x] = state.rateLimited > 0 ? 'X' : '^'
277
+ return rows.map((r) => r.join('')).join('\n')
278
+ }
279
+
280
+ const statusColor = state.rateLimited > 0 ? colors.error : colors.accent
281
+ const gridColor = state.glitch ? colors.warning : state.flash ? colors.error : colors.success
282
+
283
+ return (
284
+ <Box width="100%" alignItems="center" flexDirection="column">
285
+ <Box width={gameWidth + 4} flexDirection="column" alignItems="center">
286
+ <Box width={gameWidth} flexDirection="row" paddingX={1}>
287
+ <Box width="35%">
288
+ <Text color={statusColor} bold>
289
+ {state.rateLimited > 0 ? `LIMIT (${state.rateLimited})` : `$${state.score}k`}
290
+ </Text>
291
+ </Box>
292
+ <Box width="30%" justifyContent="center">
293
+ <Text color={colors.warning} bold>
294
+ VIBE INVADERS
295
+ </Text>
296
+ </Box>
297
+ <Box width="35%" justifyContent="flex-end">
298
+ <Text color={colors.success} bold>
299
+ RUNWAY: {'$'.repeat(state.lives)}
300
+ </Text>
301
+ </Box>
302
+ </Box>
303
+
304
+ <Box
305
+ borderStyle="round"
306
+ borderColor={state.rateLimited > 0 ? colors.error : colors.border}
307
+ flexDirection="column"
308
+ width={gameWidth + 2}
309
+ height={GAME_HEIGHT + 2}
310
+ >
311
+ <Text color={state.gameOver ? colors.error : gridColor}>{renderGrid()}</Text>
312
+ </Box>
313
+
314
+ <Box
315
+ marginTop={0}
316
+ width={gameWidth + 2}
317
+ justifyContent="center"
318
+ borderStyle="round"
319
+ borderColor={colors.accent}
320
+ >
321
+ {state.gameOver || state.won ? (
322
+ <Text color={state.won ? colors.success : colors.error} bold>
323
+ {finalMessageRef.current} Val: ${state.score}k
324
+ </Text>
325
+ ) : (
326
+ <Box gap={1}>
327
+ <Text color={colors.accent}>←→</Text>
328
+ <Text> move </Text>
329
+ <Text color={colors.accent}>spc</Text>
330
+ <Text> shoot </Text>
331
+ <Text color={colors.accent}>esc</Text>
332
+ <Text> quit</Text>
333
+ </Box>
334
+ )}
335
+ </Box>
336
+ </Box>
337
+ </Box>
338
+ )
339
+ }
@@ -0,0 +1,2 @@
1
+ export { ArcadeMenu } from './ArcadeMenu'
2
+ export { VibeInvaders } from './VibeInvaders'