Skip to content

Commit 8686fd0

Browse files
authored
feat(2024): Add magic items (#1044)
## What does this do? Adds the 2024 Magic Items ## Here's a fun image for your troubles <img width="1200" height="1200" alt="image" src="https://github.com/user-attachments/assets/b38f05f7-1441-4c19-a540-63b65831c924" />
1 parent ff30811 commit 8686fd0

10 files changed

Lines changed: 454 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import SimpleController from '@/controllers/simpleController'
2+
import MagicItemModel from '@/models/2024/magicItem'
3+
4+
export default new SimpleController(MagicItemModel)

src/graphql/2024/resolvers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ContentFieldResolver, EquipmentResolver, ToolResolver } from './equipme
88
import { EquipmentCategoryResolver } from './equipmentCategory/resolver'
99
import { FeatResolver } from './feat/resolver'
1010
import { LanguageResolver } from './language/resolver'
11+
import { MagicItemResolver } from './magicItem/resolver'
1112
import { MagicSchoolResolver } from './magicSchool/resolver'
1213
import { ProficiencyResolver } from './proficiency/resolver'
1314
import { SkillResolver } from './skill/resolver'
@@ -27,6 +28,7 @@ const collectionResolvers = [
2728
EquipmentCategoryResolver,
2829
FeatResolver,
2930
LanguageResolver,
31+
MagicItemResolver,
3032
MagicSchoolResolver,
3133
ProficiencyResolver,
3234
SkillResolver,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { ArgsType, Field, InputType, registerEnumType } from 'type-graphql'
2+
import { z } from 'zod'
3+
4+
import {
5+
BaseFilterArgs,
6+
BaseFilterArgsSchema,
7+
BaseIndexArgsSchema,
8+
BaseOrderInterface
9+
} from '@/graphql/common/args'
10+
import { OrderByDirection } from '@/graphql/common/enums'
11+
12+
export enum MagicItemOrderField {
13+
NAME = 'name'
14+
}
15+
16+
export const MAGIC_ITEM_SORT_FIELD_MAP: Record<MagicItemOrderField, string> = {
17+
[MagicItemOrderField.NAME]: 'name'
18+
}
19+
20+
registerEnumType(MagicItemOrderField, {
21+
name: 'MagicItemOrderField',
22+
description: 'Fields to sort Magic Items by'
23+
})
24+
25+
@InputType()
26+
export class MagicItemOrder implements BaseOrderInterface<MagicItemOrderField> {
27+
@Field(() => MagicItemOrderField)
28+
by!: MagicItemOrderField
29+
30+
@Field(() => OrderByDirection)
31+
direction!: OrderByDirection
32+
33+
@Field(() => MagicItemOrder, { nullable: true })
34+
then_by?: MagicItemOrder
35+
}
36+
37+
export const MagicItemOrderSchema: z.ZodType<MagicItemOrder> = z.lazy(() =>
38+
z.object({
39+
by: z.nativeEnum(MagicItemOrderField),
40+
direction: z.nativeEnum(OrderByDirection),
41+
then_by: MagicItemOrderSchema.optional()
42+
})
43+
)
44+
45+
export const MagicItemArgsSchema = z.object({
46+
...BaseFilterArgsSchema.shape,
47+
equipment_category: z.array(z.string()).optional(),
48+
rarity: z.array(z.string()).optional(),
49+
order: MagicItemOrderSchema.optional()
50+
})
51+
52+
export const MagicItemIndexArgsSchema = BaseIndexArgsSchema
53+
54+
@ArgsType()
55+
export class MagicItemArgs extends BaseFilterArgs {
56+
@Field(() => [String], {
57+
nullable: true,
58+
description: 'Filter by equipment category index (e.g., ["wondrous-items", "armor"])'
59+
})
60+
equipment_category?: string[]
61+
62+
@Field(() => [String], {
63+
nullable: true,
64+
description: 'Filter by rarity name (e.g., ["Common", "Rare"])'
65+
})
66+
rarity?: string[]
67+
68+
@Field(() => MagicItemOrder, {
69+
nullable: true,
70+
description: 'Specify sorting order for magic items.'
71+
})
72+
order?: MagicItemOrder
73+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Arg, Args, FieldResolver, Query, Resolver, Root } from 'type-graphql'
2+
3+
import { buildSortPipeline } from '@/graphql/common/args'
4+
import { resolveSingleReference, resolveMultipleReferences } from '@/graphql/utils/resolvers'
5+
import EquipmentCategoryModel, { EquipmentCategory2024 } from '@/models/2024/equipmentCategory'
6+
import MagicItemModel, { MagicItem2024 } from '@/models/2024/magicItem'
7+
import { escapeRegExp } from '@/util'
8+
9+
import {
10+
MAGIC_ITEM_SORT_FIELD_MAP,
11+
MagicItemArgs,
12+
MagicItemArgsSchema,
13+
MagicItemIndexArgsSchema,
14+
MagicItemOrderField
15+
} from './args'
16+
17+
@Resolver(MagicItem2024)
18+
export class MagicItemResolver {
19+
@Query(() => [MagicItem2024], {
20+
description: 'Gets all magic items, optionally filtered by name, equipment category, or rarity.'
21+
})
22+
async magicItems(@Args(() => MagicItemArgs) args: MagicItemArgs): Promise<MagicItem2024[]> {
23+
const validatedArgs = MagicItemArgsSchema.parse(args)
24+
const query = MagicItemModel.find()
25+
const filters: any[] = []
26+
27+
if (validatedArgs.name != null && validatedArgs.name !== '') {
28+
filters.push({ name: { $regex: new RegExp(escapeRegExp(validatedArgs.name), 'i') } })
29+
}
30+
31+
if (validatedArgs.equipment_category && validatedArgs.equipment_category.length > 0) {
32+
filters.push({ 'equipment_category.index': { $in: validatedArgs.equipment_category } })
33+
}
34+
35+
if (validatedArgs.rarity && validatedArgs.rarity.length > 0) {
36+
filters.push({ 'rarity.name': { $in: validatedArgs.rarity } })
37+
}
38+
39+
if (filters.length > 0) {
40+
query.where({ $and: filters })
41+
}
42+
43+
const sortQuery = buildSortPipeline<MagicItemOrderField>({
44+
order: validatedArgs.order,
45+
sortFieldMap: MAGIC_ITEM_SORT_FIELD_MAP,
46+
defaultSortField: MagicItemOrderField.NAME
47+
})
48+
49+
if (Object.keys(sortQuery).length > 0) {
50+
query.sort(sortQuery)
51+
}
52+
53+
if (validatedArgs.skip) {
54+
query.skip(validatedArgs.skip)
55+
}
56+
if (validatedArgs.limit) {
57+
query.limit(validatedArgs.limit)
58+
}
59+
60+
return query.lean()
61+
}
62+
63+
@Query(() => MagicItem2024, {
64+
nullable: true,
65+
description: 'Gets a single magic item by index.'
66+
})
67+
async magicItem(@Arg('index', () => String) indexInput: string): Promise<MagicItem2024 | null> {
68+
const { index } = MagicItemIndexArgsSchema.parse({ index: indexInput })
69+
return MagicItemModel.findOne({ index }).lean()
70+
}
71+
72+
@FieldResolver(() => EquipmentCategory2024)
73+
async equipment_category(@Root() magicItem: MagicItem2024): Promise<EquipmentCategory2024 | null> {
74+
return resolveSingleReference(magicItem.equipment_category, EquipmentCategoryModel)
75+
}
76+
77+
@FieldResolver(() => [MagicItem2024])
78+
async variants(@Root() magicItem: MagicItem2024): Promise<MagicItem2024[]> {
79+
return resolveMultipleReferences(magicItem.variants, MagicItemModel)
80+
}
81+
}

src/models/2024/magicItem.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { getModelForClass, prop } from '@typegoose/typegoose'
2+
import { DocumentType } from '@typegoose/typegoose/lib/types'
3+
import { Field, ObjectType } from 'type-graphql'
4+
5+
import { APIReference } from '@/models/common/apiReference'
6+
import { srdModelOptions } from '@/util/modelOptions'
7+
8+
import { EquipmentCategory2024 } from './equipmentCategory'
9+
10+
@ObjectType({ description: 'The rarity level of a 2024 magic item.' })
11+
export class Rarity2024 {
12+
@Field(() => String, {
13+
description: 'The name of the rarity level (e.g., Common, Uncommon, Rare).'
14+
})
15+
@prop({ required: true, index: true, type: () => String })
16+
public name!: string
17+
}
18+
19+
@ObjectType({ description: 'An item imbued with magical properties in D&D 5e 2024.' })
20+
@srdModelOptions('2024-magic-items')
21+
export class MagicItem2024 {
22+
@Field(() => String, {
23+
description: 'The unique identifier for this magic item (e.g., bag-of-holding).'
24+
})
25+
@prop({ required: true, index: true, type: () => String })
26+
public index!: string
27+
28+
@Field(() => String, { description: 'The name of the magic item.' })
29+
@prop({ required: true, index: true, type: () => String })
30+
public name!: string
31+
32+
@Field(() => String, { description: 'A description of the magic item.' })
33+
@prop({ required: true, type: () => String })
34+
public desc!: string
35+
36+
@Field(() => String, { nullable: true, description: 'URL of an image for the magic item.' })
37+
@prop({ type: () => String, index: true })
38+
public image?: string
39+
40+
@Field(() => EquipmentCategory2024, {
41+
description: 'The category of equipment this magic item belongs to.'
42+
})
43+
@prop({ type: () => APIReference, index: true })
44+
public equipment_category!: APIReference
45+
46+
@Field(() => Boolean, {
47+
description: 'Whether this magic item requires attunement.'
48+
})
49+
@prop({ required: true, index: true, type: () => Boolean })
50+
public attunement!: boolean
51+
52+
@Field(() => Boolean, {
53+
description: 'Indicates if this magic item is a variant of another item.'
54+
})
55+
@prop({ required: true, index: true, type: () => Boolean })
56+
public variant!: boolean
57+
58+
@Field(() => [MagicItem2024], {
59+
nullable: true,
60+
description: 'Other magic items that are variants of this item.'
61+
})
62+
@prop({ type: () => [APIReference], index: true })
63+
public variants!: APIReference[]
64+
65+
@Field(() => Rarity2024, { description: 'The rarity of the magic item.' })
66+
@prop({ required: true, index: true, type: () => Rarity2024 })
67+
public rarity!: Rarity2024
68+
69+
@Field(() => String, {
70+
nullable: true,
71+
description: 'Class restriction for attunement (e.g., "by a wizard").'
72+
})
73+
@prop({ type: () => String, index: true })
74+
public limited_to?: string
75+
76+
@prop({ required: true, index: true, type: () => String })
77+
public url!: string
78+
79+
@Field(() => String, { description: 'Timestamp of the last update.' })
80+
@prop({ required: true, index: true, type: () => String })
81+
public updated_at!: string
82+
}
83+
84+
export type MagicItemDocument = DocumentType<MagicItem2024>
85+
const MagicItemModel = getModelForClass(MagicItem2024)
86+
87+
export default MagicItemModel

src/routes/api/2024.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import EquipmentHandler from './2024/equipment'
1111
import EquipmentCategoriesHandler from './2024/equipmentCategories'
1212
import FeatsHandler from './2024/feats'
1313
import LanguagesHandler from './2024/languages'
14+
import MagicItemsHandler from './2024/magicItems'
1415
import MagicSchoolsHandler from './2024/magicSchools'
1516
import ProficienciesHandler from './2024/proficiencies'
1617
import SkillsHandler from './2024/skills'
@@ -35,6 +36,7 @@ router.use('/equipment', EquipmentHandler)
3536
router.use('/equipment-categories', EquipmentCategoriesHandler)
3637
router.use('/feats', FeatsHandler)
3738
router.use('/languages', LanguagesHandler)
39+
router.use('/magic-items', MagicItemsHandler)
3840
router.use('/magic-schools', MagicSchoolsHandler)
3941
router.use('/proficiencies', ProficienciesHandler)
4042
router.use('/skills', SkillsHandler)

src/routes/api/2024/magicItems.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as express from 'express'
2+
3+
import MagicItemController from '@/controllers/api/2024/magicItemController'
4+
5+
const router = express.Router()
6+
7+
router.get('/', function (req, res, next) {
8+
MagicItemController.index(req, res, next)
9+
})
10+
11+
router.get('/:index', function (req, res, next) {
12+
MagicItemController.show(req, res, next)
13+
})
14+
15+
export default router
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { createRequest, createResponse } from 'node-mocks-http'
2+
import { describe, expect, it, vi } from 'vitest'
3+
4+
import MagicItemController from '@/controllers/api/2024/magicItemController'
5+
import MagicItemModel from '@/models/2024/magicItem'
6+
import { magicItemFactory } from '@/tests/factories/2024/magicItem.factory'
7+
import { mockNext as defaultMockNext } from '@/tests/support'
8+
import {
9+
generateUniqueDbUri,
10+
setupIsolatedDatabase,
11+
setupModelCleanup,
12+
teardownIsolatedDatabase
13+
} from '@/tests/support/db'
14+
15+
const mockNext = vi.fn(defaultMockNext)
16+
17+
const dbUri = generateUniqueDbUri('magic-item')
18+
19+
setupIsolatedDatabase(dbUri)
20+
teardownIsolatedDatabase()
21+
setupModelCleanup(MagicItemModel)
22+
23+
describe('MagicItemController', () => {
24+
describe('index', () => {
25+
it('returns a list of magic items', async () => {
26+
const itemsData = magicItemFactory.buildList(3)
27+
await MagicItemModel.insertMany(itemsData)
28+
29+
const request = createRequest({ query: {} })
30+
const response = createResponse()
31+
32+
await MagicItemController.index(request, response, mockNext)
33+
34+
expect(response.statusCode).toBe(200)
35+
const responseData = JSON.parse(response._getData())
36+
expect(responseData.count).toBe(3)
37+
expect(responseData.results).toHaveLength(3)
38+
expect(mockNext).not.toHaveBeenCalled()
39+
})
40+
41+
it('filters by name', async () => {
42+
const itemsData = [
43+
magicItemFactory.build({ name: 'Bag of Holding' }),
44+
magicItemFactory.build({ name: 'Cloak of Elvenkind' })
45+
]
46+
await MagicItemModel.insertMany(itemsData)
47+
48+
const request = createRequest({ query: { name: 'Bag' } })
49+
const response = createResponse()
50+
51+
await MagicItemController.index(request, response, mockNext)
52+
53+
expect(response.statusCode).toBe(200)
54+
const responseData = JSON.parse(response._getData())
55+
expect(responseData.count).toBe(1)
56+
expect(responseData.results[0].name).toBe('Bag of Holding')
57+
})
58+
})
59+
60+
describe('show', () => {
61+
it('returns a single magic item when found', async () => {
62+
const itemData = magicItemFactory.build({ index: 'bag-of-holding', name: 'Bag of Holding' })
63+
await MagicItemModel.insertMany([itemData])
64+
65+
const request = createRequest({ params: { index: 'bag-of-holding' } })
66+
const response = createResponse()
67+
68+
await MagicItemController.show(request, response, mockNext)
69+
70+
expect(response.statusCode).toBe(200)
71+
const responseData = JSON.parse(response._getData())
72+
expect(responseData.index).toBe('bag-of-holding')
73+
expect(responseData.name).toBe('Bag of Holding')
74+
expect(mockNext).not.toHaveBeenCalled()
75+
})
76+
77+
it('calls next() when the magic item is not found', async () => {
78+
const request = createRequest({ params: { index: 'nonexistent' } })
79+
const response = createResponse()
80+
81+
await MagicItemController.show(request, response, mockNext)
82+
83+
expect(response._getData()).toBe('')
84+
expect(mockNext).toHaveBeenCalledOnce()
85+
})
86+
})
87+
})

0 commit comments

Comments
 (0)