Date: 2025-01-04 Status: Planning Phase
Integrate character/class/race/spell/skill data into muditor with character-based role calculation and comprehensive admin management interfaces.
- User role = Max level of all linked characters
- Role Mapping:
- Level <100 →
PLAYER(Mortal) - Level 100 →
IMMORTAL - Level 101-102 →
BUILDER(Gods/Builders) - Level 103 →
HEAD_BUILDER(Head Builder) - Level 104 →
CODER(Head Coder) - Level 105+ →
GOD(Overlord)
- Level <100 →
- Immediate: Role updates as soon as character is linked/unlinked
- Real-time permission changes in UI
- Game System Management (Spells, Skills, Classes, Races):
- CODER (104+): Create new entities (requires FieryMUD code changes to function)
- HEAD_BUILDER (103+): Edit/delete existing entities and manage associations
- GOD (105+): Unrestricted access including reset operations
- Zone-Based Access (Zones, Rooms, Mobs, Objects, Shops):
- BUILDER (101-102): Access to zones granted via
UserGrantstable - HEAD_BUILDER (103+): Edit/delete all zones (bypasses grants system)
- CODER (104+): Reset operations and system-wide changes
- GOD (105+): Unrestricted access including granting permissions to others
- BUILDER (101-102): Access to zones granted via
Rationale: Creating new spells/skills/classes/races requires corresponding code implementation in FieryMUD. Only CODER+ have the ability to deploy code changes.
Grants System: BUILDER users receive zone-specific permissions through the UserGrants table. HEAD_BUILDER+ bypass this system entirely.
- Spells: Standalone entities with name, description, effects, targeting
- Spell Circles: Assigned per-class via
SpellClassCirclestable- Example: "Cure Light Wounds" might be circle 1 for Clerics, circle 2 for Paladins, circle 4 for Druids
- Skills: Standalone entities with name, description, type, category
- Skill Assignments: Linked to classes via
ClassSkillsand races viaRaceSkills
File: packages/db/prisma/schema.prisma
enum UserRole {
PLAYER
IMMORTAL
BUILDER
HEAD_BUILDER // NEW - Level 103
CODER
GOD
}File: packages/db/prisma/schema.prisma
model UserGrants {
id Int @id @default(autoincrement())
userId String @map("user_id")
resourceType GrantResourceType @map("resource_type")
resourceId String @map("resource_id") // Can be zone ID, mob ID, etc.
permissions GrantPermission[]
grantedBy String @map("granted_by")
grantedAt DateTime @map("granted_at") @default(now())
expiresAt DateTime? @map("expires_at")
notes String?
user Users @relation(fields: [userId], references: [id], onDelete: Cascade)
grantedByUser Users @relation("grantsIssued", fields: [grantedBy], references: [id])
@@unique([userId, resourceType, resourceId])
@@index([userId])
@@index([resourceType, resourceId])
@@map("user_grants")
}
enum GrantResourceType {
ZONE
MOB // Future: grant access to specific mobs
OBJECT // Future: grant access to specific objects
SHOP // Future: grant access to specific shops
}
enum GrantPermission {
READ
WRITE
DELETE
ADMIN // Full control including granting to others
}
// Update Users model to add relations
model Users {
// ... existing fields ...
grants UserGrants[] @relation("userGrants")
grantsIssued UserGrants[] @relation("grantsIssued")
}Notes:
- Replaces the
olc_zonesarray on Characters (which should be empty) - Flexible design allows future expansion (mob-specific permissions, etc.)
- Supports permission expiration for temporary access
- Tracks who granted each permission for audit purposes
- Users with HEAD_BUILDER+ roles bypass this system (full access)
Action: Run pnpm db:generate to update Prisma clients (TypeScript + Python)
File: apps/api/src/users/role-calculator.service.ts
Purpose: Calculate user role based on character levels
Key Methods:
calculateRoleFromLevel(level: number): UserRole {
if (level < 100) return UserRole.PLAYER;
if (level === 100) return UserRole.IMMORTAL;
if (level >= 101 && level <= 102) return UserRole.BUILDER;
if (level === 103) return UserRole.HEAD_BUILDER;
if (level === 104) return UserRole.CODER;
if (level >= 105) return UserRole.GOD;
}
async updateUserRoleFromCharacters(userId: string): Promise<UserRole> {
// 1. Query all characters for user
// 2. Find max level
// 3. Calculate role from max level
// 4. Update user.role in database
// 5. Return new role
}
async getMaxCharacterLevel(userId: string): Promise<number> {
// Query max level from user's linked characters
}Inject into: UsersModule
File: apps/api/src/characters/characters.service.ts
Modifications:
async linkCharacterToUser(
userId: string,
characterName: string,
characterPassword: string
): Promise<Characters> {
// 1. Find character by name (case-insensitive)
// 2. Validate password with bcrypt
// 3. Check if already linked to another user
// 4. Link character to user (set userId)
// 5. Update user role via roleCalculator.updateUserRoleFromCharacters(userId)
// 6. Return character with updated user data
}
async unlinkCharacterFromUser(
characterId: string,
userId: string
): Promise<void> {
// 1. Verify character belongs to user
// 2. Unlink character (set userId to null)
// 3. Recalculate user role via roleCalculator.updateUserRoleFromCharacters(userId)
}File: apps/api/src/auth/auth.service.ts
Modifications:
async login(loginInput: LoginInput): Promise<AuthPayload> {
// ... existing validation ...
// Recalculate role on login (backup mechanism)
const updatedRole = await this.roleCalculator.updateUserRoleFromCharacters(user.id);
const accessToken = this.generateToken(user.id, user.username, updatedRole);
// ... rest of login logic ...
}Note: JWT payload includes role, so role changes require new token
File: apps/api/src/auth/guards/zone-permission.guard.ts
Purpose: Check if user has access to specific zone
Logic:
@Injectable()
export class ZonePermissionGuard implements CanActivate {
constructor(private db: DatabaseService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const zoneId = extractZoneIdFromRequest(context);
const user = extractUserFromContext(context);
// GOD, CODER, HEAD_BUILDER have full access (bypass grants system)
if (
[UserRole.GOD, UserRole.CODER, UserRole.HEAD_BUILDER].includes(user.role)
) {
return true;
}
// BUILDER must have a grant for this specific zone
if (user.role === UserRole.BUILDER) {
const grant = await this.db.userGrants.findUnique({
where: {
userId_resourceType_resourceId: {
userId: user.id,
resourceType: GrantResourceType.ZONE,
resourceId: zoneId.toString(),
},
},
});
if (!grant) return false;
// Check if grant has expired
if (grant.expiresAt && grant.expiresAt < new Date()) {
return false;
}
// Check if user has WRITE permission (needed for editing zones)
return grant.permissions.includes(GrantPermission.WRITE);
}
return false;
}
}Decorator:
// apps/api/src/auth/decorators/zone-permission.decorator.ts
export const RequireZoneAccess = () => UseGuards(ZonePermissionGuard);Notes:
- HEAD_BUILDER+ bypass the grants system entirely
- BUILDER users require an active (non-expired) grant with WRITE permission
- Future: Can add READ-only grants for viewing zones without editing
File: apps/api/src/auth/guards/minimum-role.guard.ts
Purpose: Hierarchical role checking
Logic:
@Injectable()
export class MinimumRoleGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const requiredRole = this.reflector.get<UserRole>(
'minimumRole',
context.getHandler()
);
const user = extractUserFromContext(context);
const roleHierarchy = [
UserRole.PLAYER,
UserRole.IMMORTAL,
UserRole.BUILDER,
UserRole.HEAD_BUILDER,
UserRole.CODER,
UserRole.GOD,
];
const userLevel = roleHierarchy.indexOf(user.role);
const requiredLevel = roleHierarchy.indexOf(requiredRole);
return userLevel >= requiredLevel;
}
}Decorator:
// apps/api/src/auth/decorators/minimum-role.decorator.ts
export const MinimumRole = (role: UserRole) => SetMetadata('minimumRole', role);Directory: apps/api/src/skills/
Files to Create:
skills.module.ts- Module definition with providers and importsskills.service.ts- Business logic for skill operationsskills.resolver.ts- GraphQL resolverdto/skill.dto.ts- GraphQL object typesdto/skill.input.ts- GraphQL input types
DTO Structure (skill.dto.ts):
@ObjectType()
export class SkillDto {
@Field(() => Int)
id: number;
@Field()
name: string;
@Field({ nullable: true })
description?: string;
@Field(() => SkillType)
type: SkillType;
@Field(() => SkillCategory)
category: SkillCategory;
@Field(() => Int)
maxLevel: number;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
// Relations
@Field(() => [ClassSkillDto], { nullable: true })
classSkills?: ClassSkillDto[];
@Field(() => [RaceSkillDto], { nullable: true })
raceSkills?: RaceSkillDto[];
}
@ObjectType()
export class ClassSkillDto {
@Field(() => Int)
id: number;
@Field(() => Int)
classId: number;
@Field(() => Int)
skillId: number;
@Field(() => SkillCategory)
category: SkillCategory;
@Field(() => Int)
minLevel: number;
@Field(() => Int)
maxLevel: number;
@Field(() => CharacterClassDto)
characterClass: CharacterClassDto;
}
@ObjectType()
export class RaceSkillDto {
@Field(() => Int)
id: number;
@Field(() => Race)
race: Race;
@Field(() => Int)
skillId: number;
@Field(() => SkillCategory)
category: SkillCategory;
@Field(() => Int)
bonus: number;
@Field(() => RaceDto)
raceData: RaceDto;
}Input Structure (skill.input.ts):
@InputType()
export class CreateSkillInput {
@Field()
name: string;
@Field({ nullable: true })
description?: string;
@Field(() => SkillType)
type: SkillType;
@Field(() => SkillCategory, { defaultValue: SkillCategory.SECONDARY })
category: SkillCategory;
@Field(() => Int, { defaultValue: 100 })
maxLevel: number;
}
@InputType()
export class UpdateSkillInput {
@Field({ nullable: true })
name?: string;
@Field({ nullable: true })
description?: string;
@Field(() => SkillType, { nullable: true })
type?: SkillType;
@Field(() => SkillCategory, { nullable: true })
category?: SkillCategory;
@Field(() => Int, { nullable: true })
maxLevel?: number;
}
@InputType()
export class AddSkillToClassInput {
@Field(() => Int)
classId: number;
@Field(() => Int)
skillId: number;
@Field(() => SkillCategory, { defaultValue: SkillCategory.SECONDARY })
category: SkillCategory;
@Field(() => Int, { defaultValue: 1 })
minLevel: number;
@Field(() => Int, { defaultValue: 100 })
maxLevel: number;
}
@InputType()
export class AddSkillToRaceInput {
@Field(() => Race)
race: Race;
@Field(() => Int)
skillId: number;
@Field(() => SkillCategory, { defaultValue: SkillCategory.SECONDARY })
category: SkillCategory;
@Field(() => Int, { defaultValue: 0 })
bonus: number;
}GraphQL Operations (skills.resolver.ts):
@Resolver(() => SkillDto)
@UseGuards(GraphQLJwtAuthGuard)
export class SkillsResolver {
// Queries
@Query(() => [SkillDto], { name: 'skills' })
async findAllSkills(
@Args('type', { type: () => SkillType, nullable: true }) type?: SkillType,
@Args('category', { type: () => SkillCategory, nullable: true }) category?: SkillCategory
): Promise<SkillDto[]>;
@Query(() => SkillDto, { name: 'skill' })
async findSkillById(@Args('id', { type: () => Int }) id: number): Promise<SkillDto>;
@Query(() => [SkillDto], { name: 'skillsByClass' })
async findSkillsByClass(@Args('classId', { type: () => Int }) classId: number): Promise<SkillDto[]>;
@Query(() => [SkillDto], { name: 'skillsByRace' })
async findSkillsByRace(@Args('race', { type: () => Race }) race: Race): Promise<SkillDto[]>;
// Mutations
@Mutation(() => SkillDto)
@MinimumRole(UserRole.CODER) // Create requires code changes in FieryMUD
async createSkill(@Args('data') data: CreateSkillInput): Promise<SkillDto>;
@Mutation(() => SkillDto)
@MinimumRole(UserRole.HEAD_BUILDER) // Edit existing skills
async updateSkill(
@Args('id', { type: () => Int }) id: number,
@Args('data') data: UpdateSkillInput
): Promise<SkillDto>;
@Mutation(() => Boolean)
@MinimumRole(UserRole.HEAD_BUILDER) // Delete existing skills
async deleteSkill(@Args('id', { type: () => Int }) id: number): Promise<boolean>;
// Class-Skill Associations
@Mutation(() => ClassSkillDto)
@MinimumRole(UserRole.HEAD_BUILDER)
async addSkillToClass(@Args('data') data: AddSkillToClassInput): Promise<ClassSkillDto>;
@Mutation(() => Boolean)
@MinimumRole(UserRole.HEAD_BUILDER)
async removeSkillFromClass(
@Args('classId', { type: () => Int }) classId: number,
@Args('skillId', { type: () => Int }) skillId: number
): Promise<boolean>;
// Race-Skill Associations
@Mutation(() => RaceSkillDto)
@MinimumRole(UserRole.HEAD_BUILDER)
async addSkillToRace(@Args('data') data: AddSkillToRaceInput): Promise<RaceSkillDto>;
@Mutation(() => Boolean)
@MinimumRole(UserRole.HEAD_BUILDER)
async removeSkillFromRace(
@Args('race', { type: () => Race }) race: Race,
@Args('skillId', { type: () => Int }) skillId: number
): Promise<boolean>;
}Service Methods (skills.service.ts):
@Injectable()
export class SkillsService {
constructor(private db: DatabaseService) {}
async findAll(filters: {
type?: SkillType;
category?: SkillCategory;
}): Promise<Skills[]> {
return this.db.skills.findMany({
where: filters,
include: { classSkills: true, raceSkills: true },
});
}
async findById(id: number): Promise<Skills> {
const skill = await this.db.skills.findUnique({
where: { id },
include: {
classSkills: { include: { characterClass: true } },
raceSkills: { include: { raceData: true } },
},
});
if (!skill) throw new NotFoundException(`Skill ${id} not found`);
return skill;
}
async create(data: CreateSkillInput): Promise<Skills> {
return this.db.skills.create({ data });
}
async update(id: number, data: UpdateSkillInput): Promise<Skills> {
return this.db.skills.update({ where: { id }, data });
}
async delete(id: number): Promise<void> {
await this.db.skills.delete({ where: { id } });
}
// Association methods
async addToClass(data: AddSkillToClassInput): Promise<ClassSkills> {
return this.db.classSkills.create({ data });
}
async removeFromClass(classId: number, skillId: number): Promise<void> {
await this.db.classSkills.delete({
where: { classId_skillId: { classId, skillId } },
});
}
async addToRace(data: AddSkillToRaceInput): Promise<RaceSkills> {
return this.db.raceSkills.create({ data });
}
async removeFromRace(race: Race, skillId: number): Promise<void> {
await this.db.raceSkills.delete({
where: { race_skillId: { race, skillId } },
});
}
}Directory: apps/api/src/spells/
Key Concept: Spells are standalone entities. Circles are assigned per-class via SpellClassCircles.
Files to Create:
spells.module.tsspells.service.tsspells.resolver.tsdto/spell.dto.tsdto/spell.input.ts
DTO Structure (spell.dto.ts):
@ObjectType()
export class SpellDto {
@Field(() => Int)
id: number;
@Field()
name: string;
@Field(() => Int, { nullable: true })
schoolId?: number;
@Field(() => Position)
minPosition: Position;
@Field()
violent: boolean;
@Field(() => Int)
castTimeRounds: number;
@Field(() => Int)
cooldownMs: number;
@Field()
inCombatOnly: boolean;
@Field()
isArea: boolean;
@Field({ nullable: true })
notes?: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
// Relations
@Field(() => SpellSchoolDto, { nullable: true })
spellSchools?: SpellSchoolDto;
@Field(() => [SpellClassCircleDto], { nullable: true })
spellClassCircles?: SpellClassCircleDto[];
@Field(() => [SpellEffectDto], { nullable: true })
spellEffects?: SpellEffectDto[];
@Field(() => SpellTargetingDto, { nullable: true })
spellTargeting?: SpellTargetingDto;
@Field(() => SpellMessagesDto, { nullable: true })
spellMessages?: SpellMessagesDto;
@Field(() => SpellRestrictionsDto, { nullable: true })
spellRestrictions?: SpellRestrictionsDto;
@Field(() => [SpellSavingThrowDto], { nullable: true })
spellSavingThrows?: SpellSavingThrowDto[];
}
@ObjectType()
export class SpellClassCircleDto {
@Field(() => Int)
id: number;
@Field(() => Int)
spellId: number;
@Field(() => Int)
classId: number;
@Field(() => Int)
circle: number; // Different per class!
@Field(() => Int, { nullable: true })
minLevel?: number;
@Field(() => Int, { nullable: true })
proficiencyGain?: number;
@Field(() => CharacterClassDto)
characterClass: CharacterClassDto;
}
@ObjectType()
export class SpellEffectDto {
@Field(() => Int)
id: number;
@Field(() => Int)
spellId: number;
@Field(() => EffectType)
effectType: EffectType;
@Field(() => Int)
order: number;
@Field(() => Int)
chancePct: number;
@Field(() => EffectTrigger, { nullable: true })
trigger?: EffectTrigger;
@Field({ nullable: true })
durationFormula?: string;
@Field(() => StackingRule)
stackingRule: StackingRule;
@Field(() => GraphQLJSON, { nullable: true })
conditionFilter?: any;
@Field(() => GraphQLJSON)
params: any;
}
@ObjectType()
export class SpellTargetingDto {
@Field(() => Int)
id: number;
@Field(() => Int)
allowedTargetsMask: number;
@Field(() => TargetScope)
targetScope: TargetScope;
@Field(() => Int)
maxTargets: number;
@Field(() => SpellRange)
range: SpellRange;
@Field()
requireLos: boolean;
@Field(() => Int, { nullable: true })
filtersMask?: number;
}
@ObjectType()
export class SpellMessagesDto {
@Field(() => Int)
id: number;
@Field({ nullable: true })
startToCaster?: string;
@Field({ nullable: true })
startToVictim?: string;
@Field({ nullable: true })
startToRoom?: string;
@Field({ nullable: true })
successToCaster?: string;
@Field({ nullable: true })
successToVictim?: string;
@Field({ nullable: true })
successToRoom?: string;
@Field({ nullable: true })
failToCaster?: string;
@Field({ nullable: true })
failToVictim?: string;
@Field({ nullable: true })
failToRoom?: string;
@Field({ nullable: true })
wearoffToTarget?: string;
@Field({ nullable: true })
wearoffToRoom?: string;
}
// ... similar DTOs for SpellRestrictions, SpellSavingThrow, SpellSchoolInput Structure (spell.input.ts):
@InputType()
export class CreateSpellInput {
@Field()
name: string;
@Field(() => Int, { nullable: true })
schoolId?: number;
@Field(() => Position, { defaultValue: Position.STANDING })
minPosition: Position;
@Field({ defaultValue: false })
violent: boolean;
@Field(() => Int, { defaultValue: 1 })
castTimeRounds: number;
@Field(() => Int, { defaultValue: 0 })
cooldownMs: number;
@Field({ defaultValue: false })
inCombatOnly: boolean;
@Field({ defaultValue: false })
isArea: boolean;
@Field({ nullable: true })
notes?: string;
}
@InputType()
export class UpdateSpellInput {
// All fields optional, similar to CreateSpellInput
}
@InputType()
export class AssignSpellToClassInput {
@Field(() => Int)
spellId: number;
@Field(() => Int)
classId: number;
@Field(() => Int)
circle: number; // Circle is class-specific!
@Field(() => Int, { nullable: true })
minLevel?: number;
@Field(() => Int, { nullable: true })
proficiencyGain?: number;
}
@InputType()
export class CreateSpellEffectInput {
@Field(() => Int)
spellId: number;
@Field(() => EffectType)
effectType: EffectType;
@Field(() => Int, { defaultValue: 0 })
order: number;
@Field(() => Int, { defaultValue: 100 })
chancePct: number;
@Field(() => EffectTrigger, { defaultValue: EffectTrigger.ON_CAST })
trigger: EffectTrigger;
@Field({ nullable: true })
durationFormula?: string;
@Field(() => StackingRule, { defaultValue: StackingRule.REFRESH })
stackingRule: StackingRule;
@Field(() => GraphQLJSON, { nullable: true })
conditionFilter?: any;
@Field(() => GraphQLJSON)
params: any;
}
@InputType()
export class UpdateSpellTargetingInput {
@Field(() => Int)
spellId: number;
@Field(() => Int)
allowedTargetsMask: number;
@Field(() => TargetScope, { defaultValue: TargetScope.SINGLE })
targetScope: TargetScope;
@Field(() => Int, { defaultValue: 1 })
maxTargets: number;
@Field(() => SpellRange, { defaultValue: SpellRange.ROOM })
range: SpellRange;
@Field({ defaultValue: false })
requireLos: boolean;
@Field(() => Int, { nullable: true })
filtersMask?: number;
}GraphQL Operations (spells.resolver.ts):
@Resolver(() => SpellDto)
@UseGuards(GraphQLJwtAuthGuard)
export class SpellsResolver {
// Queries
@Query(() => [SpellDto], { name: 'spells' })
async findAllSpells(
@Args('schoolId', { type: () => Int, nullable: true }) schoolId?: number,
@Args('violent', { type: () => Boolean, nullable: true }) violent?: boolean
): Promise<SpellDto[]>;
@Query(() => SpellDto, { name: 'spell' })
async findSpellById(@Args('id', { type: () => Int }) id: number): Promise<SpellDto>;
@Query(() => [SpellDto], { name: 'spellsForClass' })
async findSpellsForClass(
@Args('classId', { type: () => Int }) classId: number,
@Args('circle', { type: () => Int, nullable: true }) circle?: number
): Promise<SpellDto[]>;
// Spell CRUD
@Mutation(() => SpellDto)
@MinimumRole(UserRole.CODER) // Create requires code changes in FieryMUD
async createSpell(@Args('data') data: CreateSpellInput): Promise<SpellDto>;
@Mutation(() => SpellDto)
@MinimumRole(UserRole.HEAD_BUILDER) // Edit existing spells
async updateSpell(
@Args('id', { type: () => Int }) id: number,
@Args('data') data: UpdateSpellInput
): Promise<SpellDto>;
@Mutation(() => Boolean)
@MinimumRole(UserRole.HEAD_BUILDER) // Delete existing spells
async deleteSpell(@Args('id', { type: () => Int }) id: number): Promise<boolean>;
// Spell-Class-Circle Assignments
@Mutation(() => SpellClassCircleDto)
@MinimumRole(UserRole.HEAD_BUILDER)
async assignSpellToClass(@Args('data') data: AssignSpellToClassInput): Promise<SpellClassCircleDto>;
@Mutation(() => Boolean)
@MinimumRole(UserRole.HEAD_BUILDER)
async removeSpellFromClass(
@Args('spellId', { type: () => Int }) spellId: number,
@Args('classId', { type: () => Int }) classId: number
): Promise<boolean>;
// Spell Effects
@Mutation(() => SpellEffectDto)
@MinimumRole(UserRole.HEAD_BUILDER)
async addSpellEffect(@Args('data') data: CreateSpellEffectInput): Promise<SpellEffectDto>;
@Mutation(() => SpellEffectDto)
@MinimumRole(UserRole.HEAD_BUILDER)
async updateSpellEffect(
@Args('id', { type: () => Int }) id: number,
@Args('data') data: UpdateSpellEffectInput
): Promise<SpellEffectDto>;
@Mutation(() => Boolean)
@MinimumRole(UserRole.HEAD_BUILDER)
async removeSpellEffect(@Args('id', { type: () => Int }) id: number): Promise<boolean>;
// Spell Targeting
@Mutation(() => SpellTargetingDto)
@MinimumRole(UserRole.HEAD_BUILDER)
async updateSpellTargeting(@Args('data') data: UpdateSpellTargetingInput): Promise<SpellTargetingDto>;
// Spell Messages
@Mutation(() => SpellMessagesDto)
@MinimumRole(UserRole.HEAD_BUILDER)
async updateSpellMessages(@Args('data') data: UpdateSpellMessagesInput): Promise<SpellMessagesDto>;
}Directory: apps/api/src/races/
Key Concept: Races are enum-based, so no create/delete. Only update existing race data and manage skill associations.
Files to Create:
races.module.tsraces.service.tsraces.resolver.tsdto/race.dto.tsdto/race.input.ts
DTO Structure (race.dto.ts):
@ObjectType()
export class RaceDto {
@Field(() => Race)
race: Race;
@Field()
name: string;
@Field()
keywords: string;
@Field()
displayName: string;
@Field()
fullName: string;
@Field()
plainName: string;
@Field()
playable: boolean;
@Field()
humanoid: boolean;
@Field()
magical: boolean;
@Field(() => RaceAlign)
raceAlign: RaceAlign;
@Field(() => Size)
defaultSize: Size;
@Field(() => Int)
defaultAlignment: number;
@Field(() => Int)
bonusDamroll: number;
@Field(() => Int)
bonusHitroll: number;
@Field(() => Int)
focusBonus: number;
@Field(() => LifeForce)
defaultLifeforce: LifeForce;
@Field(() => Composition)
defaultComposition: Composition;
// Height/Weight ranges
@Field(() => Int)
maleWeightLow: number;
@Field(() => Int)
maleWeightHigh: number;
@Field(() => Int)
maleHeightLow: number;
@Field(() => Int)
maleHeightHigh: number;
@Field(() => Int)
femaleWeightLow: number;
@Field(() => Int)
femaleWeightHigh: number;
@Field(() => Int)
femaleHeightLow: number;
@Field(() => Int)
femaleHeightHigh: number;
// Stat maximums
@Field(() => Int)
maxStrength: number;
@Field(() => Int)
maxDexterity: number;
@Field(() => Int)
maxIntelligence: number;
@Field(() => Int)
maxWisdom: number;
@Field(() => Int)
maxConstitution: number;
@Field(() => Int)
maxCharisma: number;
// Factors
@Field(() => Int)
expFactor: number;
@Field(() => Int)
hpFactor: number;
@Field(() => Int)
hitDamageFactor: number;
@Field(() => Int)
damageDiceFactor: number;
@Field(() => Int)
copperFactor: number;
@Field(() => Int)
acFactor: number;
@Field({ nullable: true })
enterVerb?: string;
@Field({ nullable: true })
leaveVerb?: string;
@Field(() => [EffectFlag])
permanentEffects: EffectFlag[];
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
// Relations
@Field(() => [RaceSkillDto], { nullable: true })
raceSkills?: RaceSkillDto[];
}Input Structure (race.input.ts):
@InputType()
export class UpdateRaceInput {
@Field({ nullable: true })
name?: string;
@Field({ nullable: true })
displayName?: string;
@Field({ nullable: true })
playable?: boolean;
@Field(() => Size, { nullable: true })
defaultSize?: Size;
@Field(() => Int, { nullable: true })
bonusDamroll?: number;
@Field(() => Int, { nullable: true })
bonusHitroll?: number;
// ... all other race fields as optional
}GraphQL Operations (races.resolver.ts):
@Resolver(() => RaceDto)
@UseGuards(GraphQLJwtAuthGuard)
export class RacesResolver {
// Queries
@Query(() => [RaceDto], { name: 'races' })
async findAllRaces(
@Args('playableOnly', { type: () => Boolean, nullable: true }) playableOnly?: boolean
): Promise<RaceDto[]>;
@Query(() => RaceDto, { name: 'race' })
async findRaceByEnum(@Args('race', { type: () => Race }) race: Race): Promise<RaceDto>;
// Mutations (no create/delete - races are enum-based)
@Mutation(() => RaceDto)
@MinimumRole(UserRole.HEAD_BUILDER)
async updateRace(
@Args('race', { type: () => Race }) race: Race,
@Args('data') data: UpdateRaceInput
): Promise<RaceDto>;
// Race-Skill Associations (handled by Skills module, but can be accessed here too)
}Directory: apps/api/src/classes/
Files to Create:
classes.module.tsclasses.service.tsclasses.resolver.tsdto/class.dto.tsdto/class.input.ts
DTO Structure (class.dto.ts):
@ObjectType()
export class CharacterClassDto {
@Field(() => Int)
id: number;
@Field()
name: string;
@Field({ nullable: true })
description?: string;
@Field()
hitDice: string;
@Field({ nullable: true })
primaryStat?: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
// Relations
@Field(() => [ClassSkillDto], { nullable: true })
classSkills?: ClassSkillDto[];
@Field(() => [ClassCircleDto], { nullable: true })
classCircles?: ClassCircleDto[];
@Field(() => [SpellClassCircleDto], { nullable: true })
spellClassCircles?: SpellClassCircleDto[];
}
@ObjectType()
export class ClassCircleDto {
@Field(() => Int)
id: number;
@Field(() => Int)
classId: number;
@Field(() => Int)
circle: number;
@Field(() => Int)
minLevel: number;
}Input Structure (class.input.ts):
@InputType()
export class CreateClassInput {
@Field()
name: string;
@Field({ nullable: true })
description?: string;
@Field({ defaultValue: '1d8' })
hitDice: string;
@Field({ nullable: true })
primaryStat?: string;
}
@InputType()
export class UpdateClassInput {
@Field({ nullable: true })
name?: string;
@Field({ nullable: true })
description?: string;
@Field({ nullable: true })
hitDice?: string;
@Field({ nullable: true })
primaryStat?: string;
}
@InputType()
export class AddClassCircleInput {
@Field(() => Int)
classId: number;
@Field(() => Int)
circle: number;
@Field(() => Int)
minLevel: number;
}GraphQL Operations (classes.resolver.ts):
@Resolver(() => CharacterClassDto)
@UseGuards(GraphQLJwtAuthGuard)
export class ClassesResolver {
// Queries
@Query(() => [CharacterClassDto], { name: 'classes' })
async findAllClasses(): Promise<CharacterClassDto[]>;
@Query(() => CharacterClassDto, { name: 'class' })
async findClassById(@Args('id', { type: () => Int }) id: number): Promise<CharacterClassDto>;
// Class CRUD
@Mutation(() => CharacterClassDto)
@MinimumRole(UserRole.CODER) // Create requires code changes in FieryMUD
async createClass(@Args('data') data: CreateClassInput): Promise<CharacterClassDto>;
@Mutation(() => CharacterClassDto)
@MinimumRole(UserRole.HEAD_BUILDER) // Edit existing classes
async updateClass(
@Args('id', { type: () => Int }) id: number,
@Args('data') data: UpdateClassInput
): Promise<CharacterClassDto>;
@Mutation(() => Boolean)
@MinimumRole(UserRole.HEAD_BUILDER) // Delete existing classes
async deleteClass(@Args('id', { type: () => Int }) id: number): Promise<boolean>;
// Class Circles
@Mutation(() => ClassCircleDto)
@MinimumRole(UserRole.HEAD_BUILDER)
async addClassCircle(@Args('data') data: AddClassCircleInput): Promise<ClassCircleDto>;
@Mutation(() => Boolean)
@MinimumRole(UserRole.HEAD_BUILDER)
async removeClassCircle(
@Args('classId', { type: () => Int }) classId: number,
@Args('circle', { type: () => Int }) circle: number
): Promise<boolean>;
// Class-Skill associations handled by Skills module
// Class-Spell-Circle associations handled by Spells module
}Directory: apps/api/src/grants/
Purpose: Manage zone and resource permissions for BUILDER users
Files to Create:
grants.module.tsgrants.service.tsgrants.resolver.tsdto/grant.dto.tsdto/grant.input.ts
DTO Structure (grant.dto.ts):
@ObjectType()
export class UserGrantDto {
@Field(() => Int)
id: number;
@Field(() => ID)
userId: string;
@Field(() => GrantResourceType)
resourceType: GrantResourceType;
@Field()
resourceId: string;
@Field(() => [GrantPermission])
permissions: GrantPermission[];
@Field(() => ID)
grantedBy: string;
@Field()
grantedAt: Date;
@Field({ nullable: true })
expiresAt?: Date;
@Field({ nullable: true })
notes?: string;
// Relations
@Field(() => User)
user: User;
@Field(() => User)
grantedByUser: User;
}Input Structure (grant.input.ts):
@InputType()
export class CreateGrantInput {
@Field(() => ID)
userId: string;
@Field(() => GrantResourceType)
resourceType: GrantResourceType;
@Field()
resourceId: string; // Zone ID, Mob ID, etc.
@Field(() => [GrantPermission])
permissions: GrantPermission[];
@Field({ nullable: true })
expiresAt?: Date;
@Field({ nullable: true })
notes?: string;
}
@InputType()
export class UpdateGrantInput {
@Field(() => [GrantPermission], { nullable: true })
permissions?: GrantPermission[];
@Field({ nullable: true })
expiresAt?: Date;
@Field({ nullable: true })
notes?: string;
}
@InputType()
export class GrantZoneAccessInput {
@Field(() => ID)
userId: string;
@Field(() => Int)
zoneId: number;
@Field(() => [GrantPermission], {
defaultValue: [GrantPermission.READ, GrantPermission.WRITE],
})
permissions: GrantPermission[];
@Field({ nullable: true })
expiresAt?: Date;
@Field({ nullable: true })
notes?: string;
}GraphQL Operations (grants.resolver.ts):
@Resolver(() => UserGrantDto)
@UseGuards(GraphQLJwtAuthGuard)
export class GrantsResolver {
// Queries
@Query(() => [UserGrantDto], { name: 'userGrants' })
@MinimumRole(UserRole.HEAD_BUILDER) // View all grants
async findAllGrants(
@Args('userId', { type: () => ID, nullable: true }) userId?: string,
@Args('resourceType', { type: () => GrantResourceType, nullable: true }) resourceType?: GrantResourceType
): Promise<UserGrantDto[]>;
@Query(() => [UserGrantDto], { name: 'myGrants' })
async findMyGrants(@CurrentUser() user: Users): Promise<UserGrantDto[]>;
@Query(() => [UserGrantDto], { name: 'grantsForResource' })
@MinimumRole(UserRole.HEAD_BUILDER)
async findGrantsForResource(
@Args('resourceType', { type: () => GrantResourceType }) resourceType: GrantResourceType,
@Args('resourceId') resourceId: string
): Promise<UserGrantDto[]>;
// Mutations
@Mutation(() => UserGrantDto)
@MinimumRole(UserRole.HEAD_BUILDER) // Only HEAD_BUILDER+ can grant permissions
async createGrant(
@Args('data') data: CreateGrantInput,
@CurrentUser() user: Users
): Promise<UserGrantDto>;
@Mutation(() => UserGrantDto)
@MinimumRole(UserRole.HEAD_BUILDER) // Only HEAD_BUILDER+ can modify grants
async updateGrant(
@Args('id', { type: () => Int }) id: number,
@Args('data') data: UpdateGrantInput
): Promise<UserGrantDto>;
@Mutation(() => Boolean)
@MinimumRole(UserRole.HEAD_BUILDER) // Only HEAD_BUILDER+ can revoke grants
async revokeGrant(@Args('id', { type: () => Int }) id: number): Promise<boolean>;
// Convenience mutation for granting zone access
@Mutation(() => UserGrantDto)
@MinimumRole(UserRole.HEAD_BUILDER)
async grantZoneAccess(
@Args('data') data: GrantZoneAccessInput,
@CurrentUser() user: Users
): Promise<UserGrantDto>;
@Mutation(() => Boolean)
@MinimumRole(UserRole.HEAD_BUILDER)
async revokeZoneAccess(
@Args('userId', { type: () => ID }) userId: string,
@Args('zoneId', { type: () => Int }) zoneId: number
): Promise<boolean>;
}Service Methods (grants.service.ts):
@Injectable()
export class GrantsService {
constructor(private db: DatabaseService) {}
async findAll(filters: {
userId?: string;
resourceType?: GrantResourceType;
}): Promise<UserGrants[]> {
return this.db.userGrants.findMany({
where: filters,
include: { user: true, grantedByUser: true },
});
}
async findForUser(userId: string): Promise<UserGrants[]> {
return this.db.userGrants.findMany({
where: {
userId,
OR: [
{ expiresAt: null }, // No expiration
{ expiresAt: { gt: new Date() } }, // Not expired
],
},
include: { user: true, grantedByUser: true },
});
}
async findForResource(
resourceType: GrantResourceType,
resourceId: string
): Promise<UserGrants[]> {
return this.db.userGrants.findMany({
where: { resourceType, resourceId },
include: { user: true, grantedByUser: true },
});
}
async create(data: CreateGrantInput, grantedBy: string): Promise<UserGrants> {
// Check if grant already exists
const existing = await this.db.userGrants.findUnique({
where: {
userId_resourceType_resourceId: {
userId: data.userId,
resourceType: data.resourceType,
resourceId: data.resourceId,
},
},
});
if (existing) {
throw new ConflictException(
'Grant already exists for this user and resource'
);
}
return this.db.userGrants.create({
data: {
...data,
grantedBy,
},
include: { user: true, grantedByUser: true },
});
}
async update(id: number, data: UpdateGrantInput): Promise<UserGrants> {
return this.db.userGrants.update({
where: { id },
data,
include: { user: true, grantedByUser: true },
});
}
async revoke(id: number): Promise<void> {
await this.db.userGrants.delete({ where: { id } });
}
async grantZoneAccess(
userId: string,
zoneId: number,
permissions: GrantPermission[],
grantedBy: string,
expiresAt?: Date,
notes?: string
): Promise<UserGrants> {
return this.create(
{
userId,
resourceType: GrantResourceType.ZONE,
resourceId: zoneId.toString(),
permissions,
expiresAt,
notes,
},
grantedBy
);
}
async revokeZoneAccess(userId: string, zoneId: number): Promise<void> {
const grant = await this.db.userGrants.findUnique({
where: {
userId_resourceType_resourceId: {
userId,
resourceType: GrantResourceType.ZONE,
resourceId: zoneId.toString(),
},
},
});
if (grant) {
await this.revoke(grant.id);
}
}
async hasPermission(
userId: string,
resourceType: GrantResourceType,
resourceId: string,
requiredPermission: GrantPermission
): Promise<boolean> {
const grant = await this.db.userGrants.findUnique({
where: {
userId_resourceType_resourceId: {
userId,
resourceType,
resourceId,
},
},
});
if (!grant) return false;
// Check expiration
if (grant.expiresAt && grant.expiresAt < new Date()) {
return false;
}
return grant.permissions.includes(requiredPermission);
}
}Notes:
- Only HEAD_BUILDER+ can grant/revoke permissions
- GOD users can see all grants across the system
- BUILDER users can view their own grants via
myGrantsquery - Supports expiration dates for temporary access
- Audit trail via
grantedByandgrantedAtfields
File: apps/web/src/components/CharacterLinking/LinkCharacterDialog.tsx
Features:
- Dialog/modal for linking characters
- Form with character name and password fields
- Password validation (bcrypt comparison on backend)
- Display role change after successful link
- Error handling for invalid credentials or already-linked characters
GraphQL Mutations:
mutation LinkCharacter($data: LinkCharacterInput!) {
linkCharacter(data: $data) {
id
name
level
userId
users {
id
role
}
}
}
mutation UnlinkCharacter($data: UnlinkCharacterInput!) {
unlinkCharacter(data: $data)
}File: apps/web/src/app/dashboard/characters/page.tsx
Features:
- Display current user role prominently
- Show all linked characters with levels
- Show max character level
- "Link Character" button to open LinkCharacterDialog
- "Unlink" button for each linked character
- Show available characters to link (optional)
Directory: apps/web/src/app/dashboard/skills/
Files:
page.tsx- List view with filterseditor/page.tsx- Create/Edit form
Features:
- List all skills with type/category filters
- Create new skill (CODER+ only)
- Edit skill details (HEAD_BUILDER+)
- Delete skill (HEAD_BUILDER+)
- View associated classes and races
- Manage skill-class associations (HEAD_BUILDER+)
- Manage skill-race associations (HEAD_BUILDER+)
Directory: apps/web/src/app/dashboard/spells/
Files:
page.tsx- List view with filterseditor/page.tsx- Comprehensive spell editor
Features:
- List spells with school/class filters
- Create new spell (CODER+ only)
- Edit spell core details (HEAD_BUILDER+): name, school, cast time, etc.
- Manage spell effects (HEAD_BUILDER+): add/edit/remove
- Configure targeting (HEAD_BUILDER+): scope, range, LOS
- Edit spell messages (HEAD_BUILDER+): start, success, fail, wearoff
- Configure saving throws (HEAD_BUILDER+)
- Set restrictions (HEAD_BUILDER+): indoors/outdoors, terrain, etc.
- Manage class-circle assignments (HEAD_BUILDER+) - critical!
- Delete spell (HEAD_BUILDER+)
Class-Circle Assignment UI:
Spell: Cure Light Wounds
Class Assignments:
┌─────────────┬────────┬──────────┬──────────────────┐
│ Class │ Circle │ Min Lvl │ Proficiency Gain │
├─────────────┼────────┼──────────┼──────────────────┤
│ Cleric │ 1 │ 1 │ 10 │
│ Paladin │ 2 │ 3 │ 8 │
│ Druid │ 4 │ 7 │ 6 │
└─────────────┴────────┴──────────┴──────────────────┘
[+ Add Class Assignment]
Directory: apps/web/src/app/dashboard/races/
Files:
page.tsx- List vieweditor/page.tsx- Edit form (no create/delete)
Features:
- List all races with playable filter
- Edit race details (HEAD_BUILDER+)
- Configure stat bonuses and maximums
- Set racial factors (XP, HP, damage, etc.)
- Manage permanent effects
- View/edit race skills
Directory: apps/web/src/app/dashboard/classes/
Files:
page.tsx- List vieweditor/page.tsx- Create/Edit form
Features:
- List all classes
- Create new class (CODER+ only)
- Edit class details (HEAD_BUILDER+): name, description, hit dice, primary stat
- Manage class circles (HEAD_BUILDER+): which circles are available and at what level
- View/manage class skills (HEAD_BUILDER+): via Skills module UI
- View/manage class spells (HEAD_BUILDER+): via Spells module UI
- Delete class (HEAD_BUILDER+)
Directory: apps/web/src/app/dashboard/permissions/
Purpose: Allow HEAD_BUILDER+ to grant zone access to BUILDER users
Files:
page.tsx- List all grants with filtersgrant/page.tsx- Grant permissions form
Features:
- List all active grants (HEAD_BUILDER+)
- Filter by user, resource type, zone
- Grant zone access to users (HEAD_BUILDER+)
- Select user (BUILDER role only)
- Select zone(s)
- Select permissions (READ, WRITE, DELETE)
- Optional expiration date
- Add notes
- Revoke grants (HEAD_BUILDER+)
- View own grants (all users)
UI Components:
// Grant form
<GrantPermissionDialog>
<UserSelect role="BUILDER" />
<ZoneMultiSelect />
<PermissionCheckboxes options={['READ', 'WRITE', 'DELETE']} />
<DatePicker label="Expires At" optional />
<TextArea label="Notes" optional />
</GrantPermissionDialog>
// Grants table
<GrantsTable>
<Column field="user.username" label="User" />
<Column field="resourceId" label="Zone ID" />
<Column field="permissions" label="Permissions" render={badges} />
<Column field="expiresAt" label="Expires" />
<Column field="grantedByUser.username" label="Granted By" />
<Column actions>
<RevokeButton />
<EditButton />
</Column>
</GrantsTable>File: apps/web/src/app/dashboard/components/Navigation.tsx
New Sections:
{
/* Game Systems - Visible to IMMORTAL+ for viewing, HEAD_BUILDER+ for editing */
}
{
hasMinimumRole(UserRole.Immortal) && (
<>
<NavSection title='Game Systems'>
<NavLink href='/dashboard/skills' icon={SwordIcon}>
Skills
</NavLink>
<NavLink href='/dashboard/spells' icon={WandIcon}>
Spells
</NavLink>
<NavLink href='/dashboard/races' icon={UserIcon}>
Races
</NavLink>
<NavLink href='/dashboard/classes' icon={BookIcon}>
Classes
</NavLink>
</NavSection>
</>
);
}
{
/* Permissions Management - Only HEAD_BUILDER+ */
}
{
hasMinimumRole(UserRole.HeadBuilder) && (
<>
<NavSection title='Administration'>
<NavLink href='/dashboard/permissions' icon={ShieldIcon}>
Permissions
</NavLink>
</NavSection>
</>
);
}File: apps/web/src/hooks/useUserRole.ts
import { useUser } from '@/hooks/useUser'; // Existing hook
import { UserRole } from '@/generated/graphql';
export function useUserRole() {
const { user } = useUser();
const roleHierarchy = [
UserRole.Player,
UserRole.Immortal,
UserRole.Builder,
UserRole.HeadBuilder,
UserRole.Coder,
UserRole.God,
];
const hasMinimumRole = (minRole: UserRole): boolean => {
if (!user) return false;
const userLevel = roleHierarchy.indexOf(user.role);
const requiredLevel = roleHierarchy.indexOf(minRole);
return userLevel >= requiredLevel;
};
const canEdit = (): boolean => hasMinimumRole(UserRole.HeadBuilder);
const canDelete = (): boolean => hasMinimumRole(UserRole.Coder);
const canReset = (): boolean => hasMinimumRole(UserRole.Coder);
const hasZoneAccess = async (zoneId: number): Promise<boolean> => {
if (!user) return false;
// GOD, CODER, HEAD_BUILDER have full access (bypass grants)
if (hasMinimumRole(UserRole.HeadBuilder)) return true;
// BUILDER must have a grant for this zone
// This requires a GraphQL query to check UserGrants
// Consider caching grants in React Context or fetching on page load
return false; // TODO: Implement grant check via GraphQL
};
return {
role: user?.role,
hasMinimumRole,
canEdit,
canDelete,
canReset,
hasZoneAccess,
isHeadBuilder: hasMinimumRole(UserRole.HeadBuilder),
isCoder: hasMinimumRole(UserRole.Coder),
isGod: user?.role === UserRole.God,
};
}Note: hasZoneAccess() requires querying the UserGrants table. Implementation options:
- Cached in Context: Fetch user's grants on login and store in React Context
- Per-Page Query: Query grants when entering zone editor pages
- Optimistic UI: Assume access and handle 403 errors gracefully
Recommended: Fetch grants on dashboard load and cache in React Context. Query myGrants to get all active grants for the current user:
query MyGrants {
myGrants {
id
resourceType
resourceId
permissions
expiresAt
}
}Store grants in context and use for client-side permission checks. This avoids repeated API calls.
After creating all resolvers and DTOs:
cd apps/api
pnpm generate:typings # Generates schema.gqlAfter backend schema is updated:
cd apps/web
pnpm codegen # Generates TypeScript types from schema.gqlEnsure all Prisma enums are registered:
// In each module's DTO file
import { registerEnumType } from '@nestjs/graphql';
import {
SkillType,
SkillCategory,
Race,
EffectFlag /* etc */,
} from '@prisma/client';
registerEnumType(SkillType, { name: 'SkillType' });
registerEnumType(SkillCategory, { name: 'SkillCategory' });
registerEnumType(Race, { name: 'Race' });
registerEnumType(EffectFlag, { name: 'EffectFlag' });
// ... register all enums used in GraphQLrole-calculator.service.spec.ts- Test level-to-role conversioncharacters.service.spec.ts- Test linking/unlinking with role updates- Test guards:
minimum-role.guard.spec.ts,zone-permission.guard.spec.ts
- Test character linking flow with role calculation
- Test admin operations with different roles (should succeed/fail appropriately)
- Test zone-based permissions for builders
File: tests/character-linking.spec.ts
- Test character linking UI
- Verify role update in UI
- Test unlinking
File: tests/admin-interfaces.spec.ts
- Test skills CRUD with HEAD_BUILDER account
- Test spell editing and class-circle assignments
- Test permission denial for lower roles
cd packages/db
pnpm prisma migrate dev --name add_head_builder_roleThis creates a migration for the new HEAD_BUILDER enum value.
Before deploying:
- Backup Users table
- Backup Characters table
- Backup all game system tables (Skills, Spells, Races, Classes)
- Deploy backend with new enum and role calculator
- Deploy frontend with character linking UI
- Notify users to link their characters
- After stabilization, deploy admin interfaces
- Grant HEAD_BUILDER role to designated builders manually (if needed)
- Update Prisma schema with HEAD_BUILDER enum
- Create RoleCalculatorService
- Update CharactersService for linking/unlinking
- Create MinimumRoleGuard and ZonePermissionGuard
- Update AuthService for role recalculation on login
- Create LinkCharacterDialog component
- Update Characters page with linking UI
- Add GraphQL mutations and queries
- Test linking flow and role updates
- Create Skills backend module (service, resolver, DTOs)
- Create Skills frontend pages (list, editor)
- Test CRUD operations with different roles
- Create Spells backend module
- Create Spells frontend pages
- Implement class-circle assignment UI
- Test spell effects, targeting, messages
- Create Races backend module
- Create Races frontend pages
- Create Classes backend module
- Create Classes frontend pages
- Test skill/spell associations
- Update navigation with admin section
- Implement role-based UI visibility
- Add zone-permission checks to existing editors
- Polish UI/UX
- Write unit tests for role calculator
- Write integration tests for admin operations
- Write E2E tests for character linking and admin flows
- Update user documentation
| Operation | PLAYER | IMMORTAL | BUILDER | HEAD_BUILDER | CODER | GOD |
|---|---|---|---|---|---|---|
| View/List | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Create New | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| Edit Existing | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| Delete | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| Manage Associations | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| Reset/Wipe | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
Rationale:
- IMMORTAL+ can view all game systems for reference and planning
- Creating new game systems requires code changes in FieryMUD to function. Only CODER+ can deploy code.
- Only HEAD_BUILDER+ can edit/delete existing systems
| Operation | PLAYER | IMMORTAL | BUILDER | HEAD_BUILDER | CODER | GOD |
|---|---|---|---|---|---|---|
| View/List | ❌ | ❌ | ✅ (granted zones) | ✅ (all zones) | ✅ | ✅ |
| Create | ❌ | ❌ | ✅ (granted zones) | ✅ (all zones) | ✅ | ✅ |
| Edit | ❌ | ❌ | ✅ (granted zones) | ✅ (all zones) | ✅ | ✅ |
| Delete | ❌ | ❌ | ✅ (granted zones with DELETE permission) | ✅ (all zones) | ✅ | ✅ |
| Reset/Reload | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
Note: BUILDER access limited to zones granted via UserGrants table with WRITE permission. HEAD_BUILDER+ bypass the grants system.
| Operation | PLAYER | IMMORTAL | BUILDER | HEAD_BUILDER | CODER | GOD |
|---|---|---|---|---|---|---|
| Link Own Characters | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| View Own Characters | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| View All Characters | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| Edit Other Users | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Ban/Unban Users | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
PLAYER (Level <100)
↓
IMMORTAL (Level 100)
↓
BUILDER (Level 101-102) → Zones granted via UserGrants table
↓
HEAD_BUILDER (Level 103) → All zones + Edit game systems + Grant permissions
↓
CODER (Level 104) → Create game systems + Reset operations
↓
GOD (Level 105+) → Unrestricted access
-
Character Password Storage: Characters have
passwordHashfield. Confirm bcrypt is used for validation inlinkCharacterToUser. -
UserGrants Schema: Confirm the Prisma schema compiles correctly with the new UserGrants model and enum types.
-
JWT Token Refresh: When role changes, should we force token refresh? Or require re-login?
-
Multiple Characters: If user has multiple characters, max level wins. What if they unlink the highest? Recalculate from remaining.
-
Legacy Characters: Characters without
userIdcan be linked by anyone with correct password. Should we add "first link locks it" logic? -
Audit Logging: All admin operations (CRUD on spells/skills/etc.) should create audit logs. Confirm audit system is integrated.
-
Spell Effect Params:
SpellEffects.paramsis JSON. Define schema/validation for differentEffectTypevalues. -
Zone Permission Caching: For BUILDER users, grant checks require database queries. Cache grants in user context or JWT?
-
Role Downgrade: If user unlinks all high-level characters, role downgrades to PLAYER. Is this desired?
-
Frontend Error Handling: Graceful degradation if GraphQL queries fail (e.g., spells module unavailable).
- Users can create accounts and link game characters
- User role updates immediately upon character linking
- Role-based permissions enforced on all admin operations
- CODER (104+) can create new spells, skills, classes, races
- HEAD_BUILDER (103+) can edit/delete existing spells, skills, classes, races
- HEAD_BUILDER (103+) can edit all zones and grant zone permissions to builders
- BUILDER (101-102) can only edit zones granted via
UserGrantstable - Spells can be assigned different circles for different classes
- Skills can be associated with classes and races
- Frontend UI reflects role-based access (hidden/disabled features)
- All CRUD operations create audit logs
- E2E tests pass for character linking and admin flows
-
apps/api/src/users/role-calculator.service.ts -
apps/api/src/auth/guards/minimum-role.guard.ts -
apps/api/src/auth/guards/zone-permission.guard.ts -
apps/api/src/auth/decorators/minimum-role.decorator.ts -
apps/api/src/auth/decorators/zone-permission.decorator.ts -
apps/api/src/skills/skills.module.ts -
apps/api/src/skills/skills.service.ts -
apps/api/src/skills/skills.resolver.ts -
apps/api/src/skills/dto/skill.dto.ts -
apps/api/src/skills/dto/skill.input.ts -
apps/api/src/spells/spells.module.ts -
apps/api/src/spells/spells.service.ts -
apps/api/src/spells/spells.resolver.ts -
apps/api/src/spells/dto/spell.dto.ts -
apps/api/src/spells/dto/spell.input.ts -
apps/api/src/races/races.module.ts -
apps/api/src/races/races.service.ts -
apps/api/src/races/races.resolver.ts -
apps/api/src/races/dto/race.dto.ts -
apps/api/src/races/dto/race.input.ts -
apps/api/src/classes/classes.module.ts -
apps/api/src/classes/classes.service.ts -
apps/api/src/classes/classes.resolver.ts -
apps/api/src/classes/dto/class.dto.ts -
apps/api/src/classes/dto/class.input.ts
-
apps/web/src/components/CharacterLinking/LinkCharacterDialog.tsx -
apps/web/src/hooks/useUserRole.ts -
apps/web/src/app/dashboard/skills/page.tsx -
apps/web/src/app/dashboard/skills/editor/page.tsx -
apps/web/src/app/dashboard/spells/page.tsx -
apps/web/src/app/dashboard/spells/editor/page.tsx -
apps/web/src/app/dashboard/races/page.tsx -
apps/web/src/app/dashboard/races/editor/page.tsx -
apps/web/src/app/dashboard/classes/page.tsx -
apps/web/src/app/dashboard/classes/editor/page.tsx
-
packages/db/prisma/schema.prisma(add HEAD_BUILDER enum) -
apps/api/src/characters/characters.service.ts(update linking methods) -
apps/api/src/auth/auth.service.ts(role recalculation on login) -
apps/web/src/app/dashboard/characters/page.tsx(add linking UI) -
apps/web/src/app/dashboard/components/Navigation.tsx(add admin section)
-
apps/api/src/users/role-calculator.service.spec.ts -
apps/api/src/auth/guards/minimum-role.guard.spec.ts -
apps/api/src/auth/guards/zone-permission.guard.spec.ts -
tests/character-linking.spec.ts -
tests/admin-skills.spec.ts -
tests/admin-spells.spec.ts
This plan will be saved as /home/strider/Code/mud/muditor/PLAN_CHARACTER_ROLE_SYSTEM.md for reference across sessions.