Skip to content

Commit 29ae4fa

Browse files
authored
feat(2024): Add subclasses (#1043)
## What does this do? Adds the 2024 subclasses ## Here's a fun image for your troubles <img width="982" height="1155" alt="image" src="https://github.com/user-attachments/assets/4c123535-3d5b-487b-8bc2-08b120fc88b3" />
1 parent 8686fd0 commit 29ae4fa

10 files changed

Lines changed: 383 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 SubclassModel from '@/models/2024/subclass'
3+
4+
export default new SimpleController(SubclassModel)

src/graphql/2024/resolvers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { MagicSchoolResolver } from './magicSchool/resolver'
1313
import { ProficiencyResolver } from './proficiency/resolver'
1414
import { SkillResolver } from './skill/resolver'
1515
import { SpeciesResolver } from './species/resolver'
16+
import { SubclassResolver } from './subclass/resolver'
1617
import { SubspeciesResolver } from './subspecies/resolver'
1718
import { TraitResolver } from './trait/resolver'
1819
import { WeaponMasteryPropertyResolver } from './weaponMasteryProperty/resolver'
@@ -33,6 +34,7 @@ const collectionResolvers = [
3334
ProficiencyResolver,
3435
SkillResolver,
3536
SpeciesResolver,
37+
SubclassResolver,
3638
SubspeciesResolver,
3739
TraitResolver,
3840
WeaponMasteryPropertyResolver,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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 SubclassOrderField {
13+
NAME = 'name'
14+
}
15+
16+
export const SUBCLASS_SORT_FIELD_MAP: Record<SubclassOrderField, string> = {
17+
[SubclassOrderField.NAME]: 'name'
18+
}
19+
20+
registerEnumType(SubclassOrderField, {
21+
name: 'SubclassOrderField',
22+
description: 'Fields to sort Subclasses by'
23+
})
24+
25+
@InputType()
26+
export class SubclassOrder implements BaseOrderInterface<SubclassOrderField> {
27+
@Field(() => SubclassOrderField)
28+
by!: SubclassOrderField
29+
30+
@Field(() => OrderByDirection)
31+
direction!: OrderByDirection
32+
33+
@Field(() => SubclassOrder, { nullable: true })
34+
then_by?: SubclassOrder
35+
}
36+
37+
export const SubclassOrderSchema: z.ZodType<SubclassOrder> = z.lazy(() =>
38+
z.object({
39+
by: z.nativeEnum(SubclassOrderField),
40+
direction: z.nativeEnum(OrderByDirection),
41+
then_by: SubclassOrderSchema.optional()
42+
})
43+
)
44+
45+
export const SubclassArgsSchema = z.object({
46+
...BaseFilterArgsSchema.shape,
47+
order: SubclassOrderSchema.optional()
48+
})
49+
50+
export const SubclassIndexArgsSchema = BaseIndexArgsSchema
51+
52+
@ArgsType()
53+
export class SubclassArgs extends BaseFilterArgs {
54+
@Field(() => SubclassOrder, {
55+
nullable: true,
56+
description: 'Specify sorting order for subclasses.'
57+
})
58+
order?: SubclassOrder
59+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Arg, Args, Query, Resolver } from 'type-graphql'
2+
3+
import { buildSortPipeline } from '@/graphql/common/args'
4+
import SubclassModel, { Subclass2024 } from '@/models/2024/subclass'
5+
import { escapeRegExp } from '@/util'
6+
7+
import {
8+
SUBCLASS_SORT_FIELD_MAP,
9+
SubclassArgs,
10+
SubclassArgsSchema,
11+
SubclassIndexArgsSchema,
12+
SubclassOrderField
13+
} from './args'
14+
15+
@Resolver(Subclass2024)
16+
export class SubclassResolver {
17+
@Query(() => [Subclass2024], {
18+
description: 'Gets all subclasses, optionally filtered by name.'
19+
})
20+
async subclasses(@Args(() => SubclassArgs) args: SubclassArgs): Promise<Subclass2024[]> {
21+
const validatedArgs = SubclassArgsSchema.parse(args)
22+
const query = SubclassModel.find()
23+
24+
if (validatedArgs.name != null && validatedArgs.name !== '') {
25+
query.where({ name: { $regex: new RegExp(escapeRegExp(validatedArgs.name), 'i') } })
26+
}
27+
28+
const sortQuery = buildSortPipeline<SubclassOrderField>({
29+
order: validatedArgs.order,
30+
sortFieldMap: SUBCLASS_SORT_FIELD_MAP,
31+
defaultSortField: SubclassOrderField.NAME
32+
})
33+
34+
if (Object.keys(sortQuery).length > 0) {
35+
query.sort(sortQuery)
36+
}
37+
38+
if (validatedArgs.skip) {
39+
query.skip(validatedArgs.skip)
40+
}
41+
if (validatedArgs.limit) {
42+
query.limit(validatedArgs.limit)
43+
}
44+
45+
return query.lean()
46+
}
47+
48+
@Query(() => Subclass2024, {
49+
nullable: true,
50+
description: 'Gets a single subclass by index.'
51+
})
52+
async subclass(@Arg('index', () => String) indexInput: string): Promise<Subclass2024 | null> {
53+
const { index } = SubclassIndexArgsSchema.parse({ index: indexInput })
54+
return SubclassModel.findOne({ index }).lean()
55+
}
56+
}

src/models/2024/subclass.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { getModelForClass, prop } from '@typegoose/typegoose'
2+
import { DocumentType } from '@typegoose/typegoose/lib/types'
3+
import { Field, Int, ObjectType } from 'type-graphql'
4+
5+
import { srdModelOptions } from '@/util/modelOptions'
6+
7+
@ObjectType({ description: 'A feature granted by a 2024 subclass at a specific level.' })
8+
export class SubclassFeature2024 {
9+
@Field(() => String, { description: 'The name of the subclass feature.' })
10+
@prop({ required: true, type: () => String })
11+
public name!: string
12+
13+
@Field(() => Int, { description: 'The character level at which this feature is gained.' })
14+
@prop({ required: true, type: () => Number })
15+
public level!: number
16+
17+
@Field(() => String, { description: 'A description of the subclass feature.' })
18+
@prop({ required: true, type: () => String })
19+
public description!: string
20+
}
21+
22+
@ObjectType({ description: 'A subclass representing a specialization of a class in D&D 5e 2024.' })
23+
@srdModelOptions('2024-subclasses')
24+
export class Subclass2024 {
25+
@Field(() => String, { description: 'The unique identifier for this subclass.' })
26+
@prop({ required: true, index: true, type: () => String })
27+
public index!: string
28+
29+
@Field(() => String, { description: 'The name of the subclass.' })
30+
@prop({ required: true, index: true, type: () => String })
31+
public name!: string
32+
33+
@Field(() => String, { description: 'A brief summary of the subclass.' })
34+
@prop({ required: true, type: () => String })
35+
public summary!: string
36+
37+
@Field(() => String, { description: 'A full description of the subclass.' })
38+
@prop({ required: true, type: () => String })
39+
public description!: string
40+
41+
@Field(() => [SubclassFeature2024], { description: 'Features granted by this subclass.' })
42+
@prop({ required: true, type: () => [SubclassFeature2024] })
43+
public features!: SubclassFeature2024[]
44+
45+
@prop({ required: true, index: true, type: () => String })
46+
public url!: string
47+
48+
@Field(() => String, { description: 'Timestamp of the last update.' })
49+
@prop({ required: true, index: true, type: () => String })
50+
public updated_at!: string
51+
}
52+
53+
export type SubclassDocument = DocumentType<Subclass2024>
54+
const SubclassModel = getModelForClass(Subclass2024)
55+
56+
export default SubclassModel

src/routes/api/2024.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import MagicSchoolsHandler from './2024/magicSchools'
1616
import ProficienciesHandler from './2024/proficiencies'
1717
import SkillsHandler from './2024/skills'
1818
import SpeciesHandler from './2024/species'
19+
import SubclassesHandler from './2024/subclasses'
1920
import SubspeciesHandler from './2024/subspecies'
2021
import TraitsHandler from './2024/traits'
2122
import WeaponMasteryPropertiesHandler from './2024/weaponMasteryProperties'
@@ -41,6 +42,7 @@ router.use('/magic-schools', MagicSchoolsHandler)
4142
router.use('/proficiencies', ProficienciesHandler)
4243
router.use('/skills', SkillsHandler)
4344
router.use('/species', SpeciesHandler)
45+
router.use('/subclasses', SubclassesHandler)
4446
router.use('/subspecies', SubspeciesHandler)
4547
router.use('/traits', TraitsHandler)
4648
router.use('/weapon-mastery-properties', WeaponMasteryPropertiesHandler)

src/routes/api/2024/subclasses.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 SubclassController from '@/controllers/api/2024/subclassController'
4+
5+
const router = express.Router()
6+
7+
router.get('/', function (req, res, next) {
8+
SubclassController.index(req, res, next)
9+
})
10+
11+
router.get('/:index', function (req, res, next) {
12+
SubclassController.show(req, res, next)
13+
})
14+
15+
export default router
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { createRequest, createResponse } from 'node-mocks-http'
2+
import { describe, expect, it, vi } from 'vitest'
3+
4+
import SubclassController from '@/controllers/api/2024/subclassController'
5+
import SubclassModel from '@/models/2024/subclass'
6+
import { subclassFactory } from '@/tests/factories/2024/subclass.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('subclass')
18+
19+
setupIsolatedDatabase(dbUri)
20+
teardownIsolatedDatabase()
21+
setupModelCleanup(SubclassModel)
22+
23+
describe('SubclassController', () => {
24+
describe('index', () => {
25+
it('returns a list of subclasses', async () => {
26+
const subclassesData = subclassFactory.buildList(3)
27+
await SubclassModel.insertMany(subclassesData)
28+
29+
const request = createRequest({ query: {} })
30+
const response = createResponse()
31+
32+
await SubclassController.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 subclassesData = [
43+
subclassFactory.build({ name: 'Path of the Berserker' }),
44+
subclassFactory.build({ name: 'Path of the Totem Warrior' })
45+
]
46+
await SubclassModel.insertMany(subclassesData)
47+
48+
const request = createRequest({ query: { name: 'Berserker' } })
49+
const response = createResponse()
50+
51+
await SubclassController.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('Path of the Berserker')
57+
})
58+
})
59+
60+
describe('show', () => {
61+
it('returns a single subclass when found', async () => {
62+
const subclassData = subclassFactory.build({
63+
index: 'berserker',
64+
name: 'Path of the Berserker'
65+
})
66+
await SubclassModel.insertMany([subclassData])
67+
68+
const request = createRequest({ params: { index: 'berserker' } })
69+
const response = createResponse()
70+
71+
await SubclassController.show(request, response, mockNext)
72+
73+
expect(response.statusCode).toBe(200)
74+
const responseData = JSON.parse(response._getData())
75+
expect(responseData.index).toBe('berserker')
76+
expect(responseData.name).toBe('Path of the Berserker')
77+
expect(responseData.features).toHaveLength(2)
78+
expect(mockNext).not.toHaveBeenCalled()
79+
})
80+
81+
it('calls next() when the subclass is not found', async () => {
82+
const request = createRequest({ params: { index: 'nonexistent' } })
83+
const response = createResponse()
84+
85+
await SubclassController.show(request, response, mockNext)
86+
87+
expect(response._getData()).toBe('')
88+
expect(mockNext).toHaveBeenCalledOnce()
89+
})
90+
})
91+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { faker } from '@faker-js/faker'
2+
import { Factory } from 'fishery'
3+
4+
import { Subclass2024, SubclassFeature2024 } from '@/models/2024/subclass'
5+
6+
export const subclassFeatureFactory = Factory.define<SubclassFeature2024>(() => ({
7+
name: faker.lorem.words(3),
8+
level: faker.number.int({ min: 1, max: 20 }),
9+
description: faker.lorem.paragraph()
10+
}))
11+
12+
export const subclassFactory = Factory.define<Subclass2024>(({ sequence }) => {
13+
const name = `Subclass ${sequence} - ${faker.lorem.words(2)}`
14+
const index = name
15+
.toLowerCase()
16+
.replace(/[^a-z0-9]+/g, '-')
17+
.replace(/^-|-$/g, '')
18+
19+
return {
20+
index,
21+
name,
22+
summary: faker.lorem.sentence(),
23+
description: faker.lorem.paragraph(),
24+
features: subclassFeatureFactory.buildList(2),
25+
url: `/api/2024/subclasses/${index}`,
26+
updated_at: faker.date.recent().toISOString()
27+
}
28+
})

0 commit comments

Comments
 (0)