Skip to content

Commit 16211af

Browse files
authored
Adding modcommands for avatar libraries (#675)
1 parent 5f21a67 commit 16211af

File tree

9 files changed

+211
-24
lines changed

9 files changed

+211
-24
lines changed

src/clients/s3/S3Agent.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import User from '../../models/user';
2+
import { getAvatarLibrarySizeForUser } from '../../rewards/getRewards';
3+
14
enum FolderName {
25
APPROVED = 'approved_avatars',
36
PENDING = 'pending_avatars',
@@ -11,12 +14,8 @@ interface S3AvatarLinks {
1114
}
1215

1316
export interface IS3Controller {
14-
listObjectKeys(prefixes: string[]): any;
15-
uploadFile(
16-
key: string,
17-
fileContent: Buffer,
18-
contentType: string,
19-
): any;
17+
listObjectKeys(prefixes: string[]): Promise<string[]>;
18+
uploadFile(key: string, fileContent: Buffer, contentType: string): any;
2019
deleteFile(link: string): any;
2120
moveFile(oldLink: string, newLink: string): any;
2221
}
@@ -161,4 +160,20 @@ export class S3Agent {
161160
s3AvatarLinks.spyLink.includes(FolderName.PENDING)
162161
);
163162
}
163+
164+
public async getApprovedAvatarIdsForUser(usernameLower: string) {
165+
// Assumes each res avatar has a corresponding spy avatar with same id
166+
const existingResObjectKeys = await this.s3Controller.listObjectKeys([
167+
`${FolderName.APPROVED}/${usernameLower}/${usernameLower}_res_`,
168+
]);
169+
170+
return existingResObjectKeys
171+
.map((key) => {
172+
// Match format: Number of digits following _res_
173+
const match = key.match(/_res_(\d+)/);
174+
return match ? Number(match[1]) : NaN;
175+
})
176+
.filter((approvedAvatarId) => !isNaN(approvedAvatarId))
177+
.sort((a, b) => a - b);
178+
}
164179
}

src/clients/s3/tests/S3Agent.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ describe('S3Agent', () => {
130130
expect(mockS3Controller.moveFile).not.toHaveBeenCalled();
131131
});
132132

133-
it('Reverts overall avatar approval if one fails', async () => {
133+
it('reverts overall avatar approval if one fails', async () => {
134134
mockS3Controller.moveFile.mockResolvedValueOnce(undefined);
135135
mockS3Controller.moveFile.mockRejectedValueOnce(
136136
new Error('Move File failed'),
@@ -191,4 +191,33 @@ describe('S3Agent', () => {
191191
expect(errorCaught).toEqual(true);
192192
expect(mockS3Controller.deleteFile).not.toHaveBeenCalled();
193193
});
194+
195+
it('gets the approved avatar IDs for a user in ascending order', async () => {
196+
mockS3Controller.listObjectKeys.mockResolvedValueOnce([
197+
'approved_avatars/usernamelower/usernamelower_res_1.png',
198+
'approved_avatars/usernamelower/usernamelower_res_11.png',
199+
'approved_avatars/usernamelower/usernamelower_res_2.png',
200+
'approved_avatars/usernamelower/usernamelower_res_3.png',
201+
]);
202+
203+
const result1 = await s3Agent.getApprovedAvatarIdsForUser('usernamelower');
204+
205+
expect(result1).toEqual([1, 2, 3, 11]);
206+
207+
expect(mockS3Controller.listObjectKeys).toHaveBeenCalledWith([
208+
`approved_avatars/usernamelower/usernamelower_res_`,
209+
]);
210+
});
211+
212+
it('returns an empty array for users with no approved avatars', async () => {
213+
mockS3Controller.listObjectKeys.mockResolvedValueOnce([]);
214+
215+
const result1 = await s3Agent.getApprovedAvatarIdsForUser('usernamelower');
216+
217+
expect(result1).toEqual([]);
218+
219+
expect(mockS3Controller.listObjectKeys).toHaveBeenCalledWith([
220+
`approved_avatars/usernamelower/usernamelower_res_`,
221+
]);
222+
});
194223
});

src/gameplay/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export interface IUser {
4343
emailTokenExpiry?: Date;
4444
avatarImgRes?: string | null;
4545
avatarImgSpy?: string | null;
46+
avatarLibrary?: Number[];
4647
avatarHide?: boolean;
4748
hideStats?: boolean;
4849
pronoun?: string | null;

src/models/user.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const UserSchema = new mongoose.Schema<IUser>({
3333
type: String,
3434
default: null,
3535
},
36+
avatarLibrary: [Number],
3637
avatarHide: Boolean,
3738

3839
hideStats: Boolean,

src/rewards/getRewards.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,11 @@ import { isDev } from '../modsadmins/developers';
1212
import { PatreonAgent } from '../clients/patreon/patreonAgent';
1313
import { IUser } from '../gameplay/types';
1414
import { PatreonController } from '../clients/patreon/patreonController';
15+
import constants from './constants';
1516

16-
export async function getAllPatreonRewardsForUser(
17+
export async function getPatreonRewardTierForUser(
1718
usernameLower: string,
18-
): Promise<RewardType[]> {
19-
const rewardsSatisfied: RewardType[] = [];
20-
19+
): Promise<RewardType> {
2120
const patreonAgent = new PatreonAgent(new PatreonController());
2221

2322
const patronDetails = await patreonAgent.findOrUpdateExistingPatronDetails(
@@ -28,26 +27,31 @@ export async function getAllPatreonRewardsForUser(
2827
return null;
2928
}
3029

30+
let highestTierReward: RewardType = null;
31+
let highestDonationAmount = 0;
32+
3133
for (const key in PatreonRewards) {
3234
const reward = AllRewards[key as RewardType];
33-
if (reward.donationReq <= patronDetails.amountCents) {
34-
rewardsSatisfied.push(key as RewardType);
35+
if (
36+
reward.donationReq <= patronDetails.amountCents &&
37+
reward.donationReq > highestDonationAmount
38+
) {
39+
highestTierReward = key as RewardType;
40+
highestDonationAmount = reward.donationReq;
3541
}
3642
}
3743

38-
return rewardsSatisfied;
44+
return highestTierReward;
3945
}
4046

41-
async function getAllRewardsForUser(user: IUser): Promise<RewardType[]> {
47+
export async function getAllRewardsForUser(user: IUser): Promise<RewardType[]> {
4248
const rewardsSatisfied: RewardType[] = [];
43-
const patreonRewards = await getAllPatreonRewardsForUser(
49+
const patreonReward = await getPatreonRewardTierForUser(
4450
user.username.toLowerCase(),
4551
);
4652

47-
if (patreonRewards) {
48-
patreonRewards.forEach((reward) => {
49-
rewardsSatisfied.push(reward);
50-
});
53+
if (patreonReward) {
54+
rewardsSatisfied.push(patreonReward);
5155
}
5256

5357
for (const key in AllRewardsExceptPatreon) {
@@ -60,7 +64,7 @@ async function getAllRewardsForUser(user: IUser): Promise<RewardType[]> {
6064
return rewardsSatisfied;
6165
}
6266

63-
async function userHasReward(
67+
export async function userHasReward(
6468
user: IUser,
6569
rewardType: RewardType,
6670
): Promise<boolean> {
@@ -91,4 +95,20 @@ async function userHasReward(
9195
return true;
9296
}
9397

94-
export { userHasReward, getAllRewardsForUser };
98+
export async function getAvatarLibrarySizeForUser(
99+
usernameLower: string,
100+
): Promise<number> {
101+
const patreonReward = await getPatreonRewardTierForUser(usernameLower);
102+
103+
if (!patreonReward) {
104+
return 1;
105+
} else if (patreonReward === constants.TIER1_BADGE) {
106+
return 2;
107+
} else if (patreonReward === constants.TIER2_BADGE) {
108+
return 3;
109+
} else if (patreonReward === constants.TIER3_BADGE) {
110+
return 5;
111+
} else if (patreonReward === constants.TIER4_BADGE) {
112+
return 10;
113+
}
114+
}

src/sockets/commands/mod/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { miplinkedaccs } from './miplinkedaccs';
1717
import { mtogglepause } from './mtogglepause';
1818
import { mrevealallroles } from './mrevealallroles';
1919
import { mforcemove } from './mforcemove';
20+
import { mpushavatartolibrary } from './mpushavatartolibrary';
21+
import { msetavatar } from './msetavatar';
2022

2123
export const modCommands: Commands = {
2224
[m.command]: m,
@@ -29,7 +31,9 @@ export const modCommands: Commands = {
2931
[mtoggleregistration.command]: mtoggleregistration,
3032
[mtempenableregistration.command]: mtempenableregistration,
3133
[mwhisper.command]: mwhisper,
34+
[msetavatar.command]: msetavatar,
3235
[mremoveavatar.command]: mremoveavatar,
36+
[mpushavatartolibrary.command]: mpushavatartolibrary,
3337
[mclose.command]: mclose,
3438
[mannounce.command]: mannounce,
3539
[mkill.command]: mkill,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Command } from '../types';
2+
import { sendReplyToCommand } from '../../sockets';
3+
import { SocketUser } from '../../types';
4+
import User from '../../../models/user';
5+
import { S3Agent } from '../../../clients/s3/S3Agent';
6+
import S3Controller from '../../../clients/s3/S3Controller';
7+
import { getAvatarLibrarySizeForUser } from '../../../rewards/getRewards';
8+
9+
export const mpushavatartolibrary: Command = {
10+
command: 'mpushavatartolibrary',
11+
help: "/mpushavatartolibrary <player name> <avatar id>: Add an approved avatar ID to a user's avatar library. It will remove the oldest avatar in their library if the max avatar library size has been exceeded.",
12+
async run(args: string[], senderSocket: SocketUser) {
13+
if (args.length !== 3) {
14+
sendReplyToCommand(senderSocket, 'Please specify <username> <avatar id>');
15+
return;
16+
}
17+
18+
const usernameLower = args[1].toLowerCase();
19+
const user = await User.findOne({ usernameLower });
20+
21+
if (!user) {
22+
sendReplyToCommand(
23+
senderSocket,
24+
`Invalid username. Could not find: ${usernameLower}`,
25+
);
26+
return;
27+
}
28+
29+
const toBeAddedAvatarId = Number(args[2]);
30+
const s3Agent = new S3Agent(new S3Controller());
31+
const approvedIds = await s3Agent.getApprovedAvatarIdsForUser(
32+
usernameLower,
33+
);
34+
35+
if (isNaN(toBeAddedAvatarId) || !approvedIds.includes(toBeAddedAvatarId)) {
36+
sendReplyToCommand(
37+
senderSocket,
38+
`Invalid avatar ID received. List of valid IDs: ${approvedIds}`,
39+
);
40+
return;
41+
}
42+
43+
if (user.avatarLibrary.includes(toBeAddedAvatarId)) {
44+
sendReplyToCommand(
45+
senderSocket,
46+
`Avatar ID already exists in user's library: [${user.avatarLibrary}]`,
47+
);
48+
return;
49+
}
50+
51+
const librarySize = await getAvatarLibrarySizeForUser(usernameLower);
52+
user.avatarLibrary.push(toBeAddedAvatarId);
53+
54+
if (user.avatarLibrary.length > librarySize) {
55+
user.avatarLibrary.shift();
56+
}
57+
user.markModified('avatarLibrary');
58+
await user.save();
59+
60+
sendReplyToCommand(
61+
senderSocket,
62+
`Successfully updated ${usernameLower}'s avatar library: [${user.avatarLibrary}]`,
63+
);
64+
return;
65+
},
66+
};

src/sockets/commands/mod/mremoveavatar.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import User from '../../../models/user';
33

44
export const mremoveavatar: Command = {
55
command: 'mremoveavatar',
6-
help: '/mremoveavatar <player name>: Remove <player name>\'s avatar.',
6+
help: "/mremoveavatar <player name>: Remove <player name>'s avatars.",
77
async run(args, senderSocket) {
88
if (!args[1]) {
99
senderSocket.emit('messageCommandReturnStr', {
@@ -35,4 +35,4 @@ export const mremoveavatar: Command = {
3535
}
3636
});
3737
},
38-
};
38+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Command } from '../types';
2+
import { sendReplyToCommand } from '../../sockets';
3+
import { SocketUser } from '../../types';
4+
import User from '../../../models/user';
5+
6+
export const msetavatar: Command = {
7+
command: 'msetavatar',
8+
help: "/msetavatar <player name> <avatar resLink> <avatar spyLink>: Set a <player name>'s resistance and spy avatars.",
9+
async run(args: string[], senderSocket: SocketUser) {
10+
if (args.length !== 4) {
11+
sendReplyToCommand(
12+
senderSocket,
13+
'Please specify <username> <resLink> <spyLink>.',
14+
);
15+
return;
16+
}
17+
18+
const usernameLower = args[1].toLowerCase();
19+
const resLink = args[2];
20+
const spyLink = args[3];
21+
22+
if (
23+
(!resLink.startsWith(process.env.S3_PUBLIC_FILE_LINK_PREFIX) &&
24+
!resLink.includes('res')) ||
25+
(!spyLink.startsWith(process.env.S3_PUBLIC_FILE_LINK_PREFIX) &&
26+
!spyLink.includes('spy'))
27+
) {
28+
sendReplyToCommand(senderSocket, `Invalid avatar links provided`);
29+
return;
30+
}
31+
32+
const user = await User.findOne({ usernameLower });
33+
34+
if (!user) {
35+
sendReplyToCommand(
36+
senderSocket,
37+
`Invalid username. Could not find: ${usernameLower}`,
38+
);
39+
return;
40+
}
41+
42+
user.avatarImgRes = resLink;
43+
user.avatarImgSpy = spyLink;
44+
await user.save();
45+
46+
sendReplyToCommand(
47+
senderSocket,
48+
`Successfully changed avatars for user: ${usernameLower}.`,
49+
);
50+
},
51+
};

0 commit comments

Comments
 (0)