pingthings 0.8.0 → 0.9.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.
- package/bin/pingthings.js +6 -0
- package/package.json +1 -1
- package/src/cli/demo.js +36 -0
- package/src/cli/play.js +8 -4
- package/src/cli/setup.js +173 -0
- package/src/cli/stats.js +96 -0
- package/src/cli/theme.js +44 -0
package/bin/pingthings.js
CHANGED
|
@@ -23,6 +23,9 @@ const commands = {
|
|
|
23
23
|
install: () => import('../src/cli/install.js'),
|
|
24
24
|
uninstall: () => import('../src/cli/uninstall.js'),
|
|
25
25
|
init: () => import('../src/cli/init.js'),
|
|
26
|
+
setup: () => import('../src/cli/setup.js'),
|
|
27
|
+
demo: () => import('../src/cli/demo.js'),
|
|
28
|
+
stats: () => import('../src/cli/stats.js'),
|
|
26
29
|
create: () => import('../src/cli/create.js'),
|
|
27
30
|
theme: () => import('../src/cli/theme.js'),
|
|
28
31
|
'test-events': () => import('../src/cli/test-events.js'),
|
|
@@ -51,7 +54,10 @@ Commands:
|
|
|
51
54
|
test-events [pack] Play all event sounds to hear what each one sounds like
|
|
52
55
|
theme [name] Apply a sound theme (maps events across packs)
|
|
53
56
|
config [key] [val] Show or update configuration
|
|
57
|
+
demo Play one sound from every pack — showroom tour
|
|
58
|
+
stats Show usage statistics
|
|
54
59
|
init Set up Claude Code hooks automatically
|
|
60
|
+
setup <ide> Configure hooks for any IDE (cursor, copilot, codex, etc.)
|
|
55
61
|
random-pack Switch to a random pack
|
|
56
62
|
create <dir> Create a new pack from a folder of audio files
|
|
57
63
|
install <source> Install a pack from GitHub or URL
|
package/package.json
CHANGED
package/src/cli/demo.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readConfig } from '../config.js';
|
|
2
|
+
import { listPacks, getPackSounds, pickRandom } from '../packs.js';
|
|
3
|
+
import { playSoundSync } from '../player.js';
|
|
4
|
+
import { basename } from 'node:path';
|
|
5
|
+
|
|
6
|
+
function showHelp() {
|
|
7
|
+
console.log(`
|
|
8
|
+
Usage: pingthings demo
|
|
9
|
+
|
|
10
|
+
Play one sound from every pack — a showroom tour of all your notification sounds.
|
|
11
|
+
`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function demo(args) {
|
|
15
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
16
|
+
showHelp();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const config = readConfig();
|
|
21
|
+
const packs = listPacks();
|
|
22
|
+
|
|
23
|
+
console.log(`\n PINGTHINGS DEMO — ${packs.length} packs\n`);
|
|
24
|
+
|
|
25
|
+
for (const pack of packs) {
|
|
26
|
+
const sounds = getPackSounds(pack.name);
|
|
27
|
+
if (sounds.length === 0) continue;
|
|
28
|
+
|
|
29
|
+
const sound = pickRandom(sounds);
|
|
30
|
+
const active = pack.name === config.activePack ? ' *' : ' ';
|
|
31
|
+
console.log(`${active} ${pack.name.padEnd(22)} ${basename(sound)}`);
|
|
32
|
+
playSoundSync(sound, config.volume);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(`\n * = active pack\n`);
|
|
36
|
+
}
|
package/src/cli/play.js
CHANGED
|
@@ -2,10 +2,11 @@ import { readConfig, VALID_EVENTS, getLastPlayed, setLastPlayed, isQuietHours }
|
|
|
2
2
|
import { getPackSounds, getEventSounds, pickRandom, resolvePack } from '../packs.js';
|
|
3
3
|
import { playSound } from '../player.js';
|
|
4
4
|
import { sendNotification } from '../notify.js';
|
|
5
|
+
import { recordPlay } from './stats.js';
|
|
5
6
|
import { basename } from 'node:path';
|
|
6
7
|
|
|
7
8
|
function parseArgs(args) {
|
|
8
|
-
const result = { sound: null, event: null, notify: false };
|
|
9
|
+
const result = { sound: null, event: null, notify: false, silent: false };
|
|
9
10
|
|
|
10
11
|
for (let i = 0; i < args.length; i++) {
|
|
11
12
|
if ((args[i] === '--event' || args[i] === '-e') && args[i + 1]) {
|
|
@@ -13,6 +14,8 @@ function parseArgs(args) {
|
|
|
13
14
|
i++;
|
|
14
15
|
} else if (args[i] === '--notify' || args[i] === '-n') {
|
|
15
16
|
result.notify = true;
|
|
17
|
+
} else if (args[i] === '--silent' || args[i] === '-s') {
|
|
18
|
+
result.silent = true;
|
|
16
19
|
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
17
20
|
showHelp();
|
|
18
21
|
process.exit(0);
|
|
@@ -136,8 +139,9 @@ export default function play(args) {
|
|
|
136
139
|
process.exit(1);
|
|
137
140
|
}
|
|
138
141
|
|
|
139
|
-
// Quiet hours
|
|
140
|
-
if (isQuietHours(config)) {
|
|
142
|
+
// Quiet hours or silent mode — skip playback
|
|
143
|
+
if (isQuietHours(config) || parsed.silent) {
|
|
144
|
+
recordPlay(packName, parsed.event);
|
|
141
145
|
return;
|
|
142
146
|
}
|
|
143
147
|
|
|
@@ -145,7 +149,6 @@ export default function play(args) {
|
|
|
145
149
|
if (config.cooldown && !parsed.sound) {
|
|
146
150
|
const lastPlayed = getLastPlayed();
|
|
147
151
|
if (soundFile === lastPlayed) {
|
|
148
|
-
// Pick a different sound from the same pool
|
|
149
152
|
const pool = parsed.event
|
|
150
153
|
? getEventSounds(config.eventPacks?.[parsed.event] || packName, parsed.event)
|
|
151
154
|
: getPackSounds(packName);
|
|
@@ -158,6 +161,7 @@ export default function play(args) {
|
|
|
158
161
|
|
|
159
162
|
setLastPlayed(soundFile);
|
|
160
163
|
playSound(soundFile, config.volume);
|
|
164
|
+
recordPlay(packName, parsed.event);
|
|
161
165
|
|
|
162
166
|
// Desktop notification
|
|
163
167
|
if (parsed.notify || config.notifications) {
|
package/src/cli/setup.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
|
|
6
|
+
const IDES = {
|
|
7
|
+
'claude-code': {
|
|
8
|
+
name: 'Claude Code',
|
|
9
|
+
configPath: () => join(homedir(), '.claude', 'settings.json'),
|
|
10
|
+
generate: (mode) => ({
|
|
11
|
+
hooks: mode === 'informational' ? {
|
|
12
|
+
Notification: [{ matcher: '', hooks: [{ type: 'command', command: 'pingthings play --event permission' }] }],
|
|
13
|
+
Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'pingthings play --event complete' }] }],
|
|
14
|
+
PostToolUseFailure: [{ matcher: '', hooks: [{ type: 'command', command: 'pingthings play --event error' }] }],
|
|
15
|
+
StopFailure: [{ matcher: '', hooks: [{ type: 'command', command: 'pingthings play --event blocked' }] }],
|
|
16
|
+
} : {
|
|
17
|
+
Notification: [{ matcher: '', hooks: [{ type: 'command', command: 'pingthings play' }] }],
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
},
|
|
21
|
+
cursor: {
|
|
22
|
+
name: 'Cursor',
|
|
23
|
+
configPath: () => join(homedir(), '.cursor', 'hooks.json'),
|
|
24
|
+
generate: (mode) => ({
|
|
25
|
+
version: 1,
|
|
26
|
+
hooks: mode === 'informational' ? {
|
|
27
|
+
stop: [{ command: 'pingthings play --event complete' }],
|
|
28
|
+
afterFileEdit: [{ command: 'pingthings play --event done' }],
|
|
29
|
+
} : {
|
|
30
|
+
stop: [{ command: 'pingthings play' }],
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
copilot: {
|
|
35
|
+
name: 'GitHub Copilot',
|
|
36
|
+
configPath: () => join('.github', 'hooks', 'pingthings.json'),
|
|
37
|
+
generate: (mode) => ({
|
|
38
|
+
version: 1,
|
|
39
|
+
hooks: mode === 'informational' ? {
|
|
40
|
+
sessionEnd: [{ type: 'command', bash: 'pingthings play --event complete' }],
|
|
41
|
+
postToolUse: [{ type: 'command', bash: 'pingthings play --event done' }],
|
|
42
|
+
errorOccurred: [{ type: 'command', bash: 'pingthings play --event error' }],
|
|
43
|
+
} : {
|
|
44
|
+
sessionEnd: [{ type: 'command', bash: 'pingthings play' }],
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
codex: {
|
|
49
|
+
name: 'OpenAI Codex',
|
|
50
|
+
configPath: () => join(homedir(), '.codex', 'config.toml'),
|
|
51
|
+
generate: () => null, // TOML, handle separately
|
|
52
|
+
toml: 'notify = ["pingthings", "play", "--event", "complete"]\n',
|
|
53
|
+
},
|
|
54
|
+
windsurf: {
|
|
55
|
+
name: 'Windsurf',
|
|
56
|
+
configPath: () => join('.windsurf', 'hooks.json'),
|
|
57
|
+
generate: (mode) => ({
|
|
58
|
+
hooks: mode === 'informational' ? {
|
|
59
|
+
post_cascade_response: [{ command: 'pingthings play --event complete' }],
|
|
60
|
+
post_write_code: [{ command: 'pingthings play --event done' }],
|
|
61
|
+
} : {
|
|
62
|
+
post_cascade_response: [{ command: 'pingthings play' }],
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
gemini: {
|
|
67
|
+
name: 'Gemini CLI',
|
|
68
|
+
configPath: () => join(homedir(), '.gemini', 'settings.json'),
|
|
69
|
+
generate: (mode) => ({
|
|
70
|
+
hooks: mode === 'informational' ? {
|
|
71
|
+
SessionEnd: [{ matcher: '*', hooks: [{ type: 'command', command: 'pingthings play --event complete' }] }],
|
|
72
|
+
AfterTool: [{ matcher: '*', hooks: [{ type: 'command', command: 'pingthings play --event done' }] }],
|
|
73
|
+
Notification: [{ matcher: 'error', hooks: [{ type: 'command', command: 'pingthings play --event error' }] }],
|
|
74
|
+
} : {
|
|
75
|
+
SessionEnd: [{ matcher: '*', hooks: [{ type: 'command', command: 'pingthings play' }] }],
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function showHelp() {
|
|
82
|
+
console.log(`
|
|
83
|
+
Usage: pingthings setup <ide>
|
|
84
|
+
|
|
85
|
+
Configure pingthings hooks for your IDE/coding tool.
|
|
86
|
+
|
|
87
|
+
Supported IDEs:
|
|
88
|
+
claude-code Claude Code CLI
|
|
89
|
+
cursor Cursor AI editor
|
|
90
|
+
copilot GitHub Copilot CLI
|
|
91
|
+
codex OpenAI Codex CLI
|
|
92
|
+
windsurf Windsurf (Cascade)
|
|
93
|
+
gemini Gemini CLI
|
|
94
|
+
|
|
95
|
+
Options:
|
|
96
|
+
--basic Random sounds (no event mapping)
|
|
97
|
+
--informational Event-based sounds (recommended)
|
|
98
|
+
|
|
99
|
+
Examples:
|
|
100
|
+
pingthings setup cursor
|
|
101
|
+
pingthings setup copilot --informational
|
|
102
|
+
pingthings setup codex
|
|
103
|
+
`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeJsonConfig(path, config) {
|
|
107
|
+
const dir = join(path, '..');
|
|
108
|
+
mkdirSync(dir, { recursive: true });
|
|
109
|
+
|
|
110
|
+
let existing = {};
|
|
111
|
+
if (existsSync(path)) {
|
|
112
|
+
try {
|
|
113
|
+
existing = JSON.parse(readFileSync(path, 'utf8'));
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const merged = { ...existing, ...config };
|
|
118
|
+
if (existing.hooks && config.hooks) {
|
|
119
|
+
merged.hooks = { ...existing.hooks, ...config.hooks };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const tmpPath = path + '.tmp';
|
|
123
|
+
writeFileSync(tmpPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
|
|
124
|
+
renameSync(tmpPath, path);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export default async function setup(args) {
|
|
128
|
+
const ideName = args[0];
|
|
129
|
+
|
|
130
|
+
if (!ideName || ideName === '--help' || ideName === '-h') {
|
|
131
|
+
showHelp();
|
|
132
|
+
if (!ideName) process.exit(1);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const ide = IDES[ideName];
|
|
137
|
+
if (!ide) {
|
|
138
|
+
console.error(`Unknown IDE: ${ideName}`);
|
|
139
|
+
console.error(`Supported: ${Object.keys(IDES).join(', ')}`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let mode = 'informational';
|
|
144
|
+
if (args.includes('--basic')) mode = 'basic';
|
|
145
|
+
|
|
146
|
+
const configPath = ide.configPath();
|
|
147
|
+
|
|
148
|
+
console.log(`\nSetting up pingthings for ${ide.name}...\n`);
|
|
149
|
+
|
|
150
|
+
if (ideName === 'codex') {
|
|
151
|
+
// Codex uses TOML
|
|
152
|
+
mkdirSync(join(configPath, '..'), { recursive: true });
|
|
153
|
+
if (existsSync(configPath)) {
|
|
154
|
+
const existing = readFileSync(configPath, 'utf8');
|
|
155
|
+
if (existing.includes('pingthings')) {
|
|
156
|
+
console.log('pingthings is already configured in Codex.');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
writeFileSync(configPath, existing + '\n' + ide.toml, 'utf8');
|
|
160
|
+
} else {
|
|
161
|
+
writeFileSync(configPath, ide.toml, 'utf8');
|
|
162
|
+
}
|
|
163
|
+
console.log(`Written to: ${configPath}`);
|
|
164
|
+
console.log('Codex will run "pingthings play --event complete" on agent-turn-complete.');
|
|
165
|
+
} else {
|
|
166
|
+
const config = ide.generate(mode);
|
|
167
|
+
writeJsonConfig(configPath, config);
|
|
168
|
+
console.log(`Written to: ${configPath}`);
|
|
169
|
+
console.log(`Mode: ${mode}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log('\nRestart your IDE for hooks to take effect.');
|
|
173
|
+
}
|
package/src/cli/stats.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { readConfig, getConfigDir } from '../config.js';
|
|
4
|
+
import { listPacks } from '../packs.js';
|
|
5
|
+
|
|
6
|
+
function showHelp() {
|
|
7
|
+
console.log(`
|
|
8
|
+
Usage: pingthings stats
|
|
9
|
+
|
|
10
|
+
Show usage statistics — total sounds, packs, play count, and more.
|
|
11
|
+
`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getStatsPath() {
|
|
15
|
+
return join(getConfigDir(), 'stats.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function recordPlay(packName, event) {
|
|
19
|
+
const statsPath = getStatsPath();
|
|
20
|
+
let stats = { totalPlays: 0, packPlays: {}, eventPlays: {}, dailyPlays: {} };
|
|
21
|
+
|
|
22
|
+
if (existsSync(statsPath)) {
|
|
23
|
+
try {
|
|
24
|
+
stats = JSON.parse(readFileSync(statsPath, 'utf8'));
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
stats.totalPlays = (stats.totalPlays || 0) + 1;
|
|
29
|
+
|
|
30
|
+
if (!stats.packPlays) stats.packPlays = {};
|
|
31
|
+
stats.packPlays[packName] = (stats.packPlays[packName] || 0) + 1;
|
|
32
|
+
|
|
33
|
+
if (event) {
|
|
34
|
+
if (!stats.eventPlays) stats.eventPlays = {};
|
|
35
|
+
stats.eventPlays[event] = (stats.eventPlays[event] || 0) + 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const today = new Date().toISOString().split('T')[0];
|
|
39
|
+
if (!stats.dailyPlays) stats.dailyPlays = {};
|
|
40
|
+
stats.dailyPlays[today] = (stats.dailyPlays[today] || 0) + 1;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(statsPath, JSON.stringify(stats, null, 2) + '\n', 'utf8');
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function stats(args) {
|
|
48
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
49
|
+
showHelp();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const packs = listPacks();
|
|
54
|
+
const totalSounds = packs.reduce((s, p) => s + p.soundCount, 0);
|
|
55
|
+
const statsPath = getStatsPath();
|
|
56
|
+
|
|
57
|
+
console.log('\n PINGTHINGS STATS\n');
|
|
58
|
+
console.log(` Packs installed: ${packs.length}`);
|
|
59
|
+
console.log(` Total sounds: ${totalSounds}`);
|
|
60
|
+
console.log(` Categories: ${new Set(packs.map(p => p.category)).size}`);
|
|
61
|
+
|
|
62
|
+
if (!existsSync(statsPath)) {
|
|
63
|
+
console.log('\n No play history yet. Start using pingthings!\n');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let data;
|
|
68
|
+
try {
|
|
69
|
+
data = JSON.parse(readFileSync(statsPath, 'utf8'));
|
|
70
|
+
} catch {
|
|
71
|
+
console.log('\n No play history yet.\n');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(`\n Total plays: ${data.totalPlays || 0}`);
|
|
76
|
+
|
|
77
|
+
const today = new Date().toISOString().split('T')[0];
|
|
78
|
+
console.log(` Plays today: ${data.dailyPlays?.[today] || 0}`);
|
|
79
|
+
|
|
80
|
+
if (data.packPlays && Object.keys(data.packPlays).length > 0) {
|
|
81
|
+
const sorted = Object.entries(data.packPlays).sort((a, b) => b[1] - a[1]);
|
|
82
|
+
console.log(`\n Most played packs:`);
|
|
83
|
+
for (const [pack, count] of sorted.slice(0, 5)) {
|
|
84
|
+
console.log(` ${pack.padEnd(24)} ${count} plays`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (data.eventPlays && Object.keys(data.eventPlays).length > 0) {
|
|
89
|
+
console.log(`\n Events:`);
|
|
90
|
+
for (const [event, count] of Object.entries(data.eventPlays)) {
|
|
91
|
+
console.log(` ${event.padEnd(16)} ${count}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log('');
|
|
96
|
+
}
|
package/src/cli/theme.js
CHANGED
|
@@ -90,6 +90,50 @@ const THEMES = {
|
|
|
90
90
|
blocked: 'kenney-scifi',
|
|
91
91
|
},
|
|
92
92
|
},
|
|
93
|
+
'developer': {
|
|
94
|
+
description: 'AI assistant vibes — droid announcer + human voiceover',
|
|
95
|
+
activePack: 'droid-announcer',
|
|
96
|
+
eventPacks: {
|
|
97
|
+
done: 'droid-announcer',
|
|
98
|
+
permission: 'droid-announcer',
|
|
99
|
+
complete: 'kenney-voiceover',
|
|
100
|
+
error: 'droid-announcer',
|
|
101
|
+
blocked: 'kenney-voiceover',
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
'arcade': {
|
|
105
|
+
description: 'Full arcade experience — 8-bit everything',
|
|
106
|
+
activePack: 'retro-8bit',
|
|
107
|
+
eventPacks: {
|
|
108
|
+
done: 'retro-8bit',
|
|
109
|
+
permission: 'retro-movement',
|
|
110
|
+
complete: 'retro-8bit',
|
|
111
|
+
error: 'retro-weapons',
|
|
112
|
+
blocked: 'retro-weapons',
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
'tabletop': {
|
|
116
|
+
description: 'Tavern sounds — RPG foley meets material impacts',
|
|
117
|
+
activePack: 'kenney-rpg',
|
|
118
|
+
eventPacks: {
|
|
119
|
+
done: 'kenney-rpg',
|
|
120
|
+
permission: 'kenney-rpg',
|
|
121
|
+
complete: 'kenney-impacts',
|
|
122
|
+
error: 'kenney-impacts',
|
|
123
|
+
blocked: 'kenney-impacts',
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
'tournament': {
|
|
127
|
+
description: 'Fighting game tournament — multiple announcers',
|
|
128
|
+
activePack: 'kenney-fighter',
|
|
129
|
+
eventPacks: {
|
|
130
|
+
done: 'fighting-announcer',
|
|
131
|
+
permission: 'kenney-fighter',
|
|
132
|
+
complete: 'kenney-fighter',
|
|
133
|
+
error: 'fighting-announcer',
|
|
134
|
+
blocked: 'xonotic-announcer',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
93
137
|
'chaos': {
|
|
94
138
|
description: 'Random pack for every event — maximum variety',
|
|
95
139
|
activePack: '7kaa-soldiers',
|