Skip to content

Commit

Permalink
Jobs (#77)
Browse files Browse the repository at this point in the history
* feat: Init job.service.ts

* feat: Add job.handler.ts

* fix: Add job handler in job module

* feat: Check sufficient resources for job

* feat: Call methods from system.service to fulfil the job

* feat: Add SystemType enum

* fix: Use SystemUpgradeName instead of separate enum

* fix: Refund job resource to empire

* fix: Deduct job resources from empire

* refactor: Init system.service refactoring for buildings

* feat: Get building costs for job

* feat: Get district costs for job

* feat: Get technology costs for job

* feat: Get system upgrade costs for job

* fix: Update empire resources

* feat: Complete job types

* fix: Delete job, convert job cost map to record

* fix: Call complete jobs method

* feat: Build buildings with UpdateSystemDto, add constructor

* fix: Build job building

* feat: Check if methods were called with a job

* fix: Circular module dependency

* fix: Add technology to empire after job completion

* fix: Use ForwardRef for cyclic dependency

* fix: Increment user's technology count

* feat: Init behavioral jobs

* feat: Delete job on job.progress > job.total

* chore: Fix some review comments

---------

Co-authored-by: Adrian Kunz <[email protected]>
  • Loading branch information
simonloeser and Clashsoft authored Jun 21, 2024
1 parent 52994c0 commit 7fcf170
Show file tree
Hide file tree
Showing 14 changed files with 480 additions and 78 deletions.
2 changes: 1 addition & 1 deletion src/empire/empire.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,6 @@ export class EmpireController {
if (!currentUser._id.equals(empire.user)) {
throw new ForbiddenException('Cannot modify another user\'s empire.');
}
return this.empireService.updateEmpire(empire, dto);
return this.empireService.updateEmpire(empire, dto, null);
}
}
56 changes: 37 additions & 19 deletions src/empire/empire.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {EMPIRE_VARIABLES} from '../game-logic/empire-variables';
import {UserDocument} from '../user/user.schema';
import {AggregateItem, AggregateResult} from '../game-logic/aggregates';
import {Member} from '../member/member.schema';
import {JobDocument} from "../job/job.schema";

function findMissingTechnologies(technologyId: string): string[] {
const missingTechs: string[] = [];
Expand Down Expand Up @@ -59,11 +60,11 @@ export class EmpireService extends MongooseRepository<Empire> {
return rest;
}

async updateEmpire(empire: EmpireDocument, dto: UpdateEmpireDto): Promise<EmpireDocument> {
async updateEmpire(empire: EmpireDocument, dto: UpdateEmpireDto, job: JobDocument | null): Promise<EmpireDocument> {
const {technologies, resources, ...rest} = dto;
empire.set(rest);
if (technologies) {
await this.unlockTechnology(empire, technologies);
await this.unlockTechnology(empire, technologies, job);
}
if (resources) {
this.resourceTrading(empire, resources);
Expand All @@ -72,18 +73,16 @@ export class EmpireService extends MongooseRepository<Empire> {
return empire;
}

private async unlockTechnology(empire: EmpireDocument, technologies: string[]) {
async unlockTechnology(empire: EmpireDocument, technologies: string[], job: JobDocument | null) {
const user = await this.userService.find(empire.user) ?? notFound(empire.user);
const variables = {
...getVariables('technologies'),
...getVariables('empire'),
};
calculateVariables(variables, empire);

for (const technologyId of technologies) {
const technology = TECHNOLOGIES[technologyId] ?? notFound(`Technology ${technologyId} not found.`);

if (empire.technologies.includes(technologyId)) {
if (job) {
await this.emitJobFailedEvent(job, `Technology ${technologyId} has already been unlocked.`);
return;
}
throw new BadRequestException(`Technology ${technologyId} has already been unlocked.`);
}

Expand All @@ -94,33 +93,46 @@ export class EmpireService extends MongooseRepository<Empire> {

if (!hasAllRequiredTechnologies) {
const missingTechnologies = findMissingTechnologies(technologyId);
if (job) {
await this.emitJobFailedEvent(job, `Required technologies for ${technologyId}: ${missingTechnologies.join(', ')}.`);
return;
}
throw new BadRequestException(`Required technologies for ${technologyId}: ${missingTechnologies.join(', ')}.`);
}

// Calculate the technology cost based on the formula
const technologyCost = this.getTechnologyCost(user, technology, variables);
if (!job) {
// Calculate the technology cost based on the formula
const technologyCost = this.getTechnologyCost(user, empire, technology);

if (empire.resources.research < technologyCost) {
throw new BadRequestException(`Not enough research points to unlock ${technologyId}.`);
}

if (empire.resources.research < technologyCost) {
throw new BadRequestException(`Not enough research points to unlock ${technologyId}.`);
// Deduct research points and unlock technology
empire.resources.research -= technologyCost;
empire.markModified('resources');
}

// Deduct research points and unlock technology
empire.resources.research -= technologyCost;
empire.markModified('resources');
if (!empire.technologies.includes(technologyId)) {
empire.technologies.push(technologyId);
// Increment the user's technology count by 1
if (user.technologies) {
user.technologies[technologyId] = (user.technologies?.[technologyId] ?? 0) + 1;
user.markModified('technologies');
} else {
user.technologies = {[technologyId]: 1};
}
user.markModified('technologies');
}
}

await this.userService.saveAll([user]);
}

getTechnologyCost(user: UserDocument, technology: Technology, variables: Partial<Record<Variable, number>>) {
public getTechnologyCost(user: UserDocument, empire: EmpireDocument, technology: Technology) {
const variables = {
...getVariables('technologies'),
...getVariables('empire'),
};
calculateVariables(variables, empire);
const technologyCount = user.technologies?.[technology.id] || 0;

const difficultyMultiplier = variables['empire.technologies.difficulty'] || 1;
Expand Down Expand Up @@ -254,6 +266,12 @@ export class EmpireService extends MongooseRepository<Empire> {
);
}

private async emitJobFailedEvent(job: JobDocument, errorMessage: string) {
const event = `games.${job.game}.empire.${job.empire}.jobs.${job._id}.failed`;
const data = {message: errorMessage};
this.eventEmitter.emit(event, data);
}

private async emit(event: string, empire: Empire) {
this.eventEmitter.emit(`games.${empire.game}.empires.${empire._id}.${event}`, empire, [empire.user.toString()]);
const otherMembers = await this.memberService.findAll({
Expand Down
2 changes: 2 additions & 0 deletions src/game-logic/game-logic.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import {SystemModule} from '../system/system.module';
import {EmpireModule} from '../empire/empire.module';
import {GameLogicController} from './game-logic.controller';
import {MemberModule} from '../member/member.module';
import {JobModule} from "../job/job.module";

@Module({
imports: [
forwardRef(() => require('../game/game.module').GameModule),
MemberModule,
SystemModule,
EmpireModule,
JobModule,
],
providers: [GameLogicService],
exports: [GameLogicService],
Expand Down
85 changes: 83 additions & 2 deletions src/game-logic/game-logic.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {SystemService} from '../system/system.service';
import {Empire, EmpireDocument} from '../empire/empire.schema';
import {System, SystemDocument} from '../system/system.schema';
import {calculateVariables, getInitialVariables} from './variables';
import {Technology, Variable} from './types';
import {Technology, TechnologyCategory, Variable} from './types';
import {RESOURCE_NAMES, ResourceName} from './resources';
import {AggregateItem, AggregateResult} from './aggregates';
import {TECHNOLOGIES} from './technologies';
Expand All @@ -14,13 +14,17 @@ import {Game} from '../game/game.schema';
import {HOMESYSTEM_BUILDINGS, HOMESYSTEM_DISTRICT_COUNT, HOMESYSTEM_DISTRICTS} from './constants';
import {MemberService} from '../member/member.service';
import {SYSTEM_UPGRADES} from './system-upgrade';
import {JobService} from '../job/job.service';
import {JobDocument} from '../job/job.schema';
import {JobType} from '../job/job-type.enum';

@Injectable()
export class GameLogicService {
constructor(
private memberService: MemberService,
private empireService: EmpireService,
private systemService: SystemService,
private jobService: JobService,
) {
}

Expand Down Expand Up @@ -97,9 +101,13 @@ export class GameLogicService {
async updateGame(game: Game) {
const empires = await this.empireService.findAll({game: game._id});
const systems = await this.systemService.findAll({game: game._id});
const jobs = await this.jobService.findAll({game: game._id});

this._updateGame(empires, systems);
await this.updateJobs(jobs, systems);
await this.empireService.saveAll(empires);
await this.systemService.saveAll(systems);
await this.jobService.saveAll(jobs);
}

private _updateGame(empires: EmpireDocument[], systems: SystemDocument[]) {
Expand All @@ -109,6 +117,76 @@ export class GameLogicService {
}
}

async updateJobs(jobs: JobDocument[], systems: SystemDocument[]) {
const systemJobsMap: Record<string, JobDocument[]> = {};
const progressingTechnologyTags: Record<string, boolean> = {};

for (const job of jobs) {
if (job.progress === job.total) {
await this.jobService.delete(job._id);
continue;
}

if (job.type === JobType.TECHNOLOGY) {
if (!job.technology) {
continue;
}
const technology = TECHNOLOGIES[job.technology];
if (technology) {
const primaryTag = this.getPrimaryTag(technology);
if (primaryTag && !progressingTechnologyTags[primaryTag]) {
progressingTechnologyTags[primaryTag] = true;
await this.progressTechnologyJob(job);
}
}
} else {
if (!job.system) {
continue;
}
(systemJobsMap[job.system.toString()] ??= []).push(job);
}
}

for (const jobsInSystem of Object.values(systemJobsMap)) {
// Maybe do a priority sorting in v4?
const sortedJobs = jobsInSystem.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());

for (const job of sortedJobs) {
if (job.type === JobType.BUILDING || job.type === JobType.DISTRICT || job.type === JobType.UPGRADE) {
await this.progressJob(job);
}
}
}
}

private async progressJob(job: JobDocument) {
job.progress += 1;
if (job.progress >= job.total) {
await this.jobService.completeJob(job);
} else {
job.markModified('progress');
}
}

private async progressTechnologyJob(job: JobDocument) {
if (!job.technology) {
return;
}
const technology = TECHNOLOGIES[job.technology];
if (!technology) {
return;
}
const primaryTag = this.getPrimaryTag(technology);
if (!primaryTag) {
return;
}
await this.progressJob(job);
}

private getPrimaryTag(technology: Technology): TechnologyCategory {
return technology.tags[0];
}

private updateEmpire(empire: EmpireDocument, systems: SystemDocument[], aggregates?: Partial<Record<ResourceName, AggregateResult>>) {
const variables = getInitialVariables();
calculateVariables(variables, empire);
Expand Down Expand Up @@ -338,7 +416,10 @@ export class GameLogicService {
}

aggregateResources(empire: Empire, systems: System[], resources: ResourceName[]): AggregateResult[] {
const aggregates: Partial<Record<ResourceName, AggregateResult>> = Object.fromEntries(resources.map(r => [r, {total: 0, items: []}]));
const aggregates: Partial<Record<ResourceName, AggregateResult>> = Object.fromEntries(resources.map(r => [r, {
total: 0,
items: []
}]));
this.updateEmpire(empire as EmpireDocument, systems as SystemDocument[], aggregates); // NB: this mutates empire and systems, but does not save them.
return resources.map(r => aggregates[r]!);
}
Expand Down
1 change: 0 additions & 1 deletion src/game-logic/system-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,3 @@ export const SYSTEM_TYPES = {
},
} as const satisfies Record<string, SystemType>;
export type SystemTypeName = keyof typeof SYSTEM_TYPES;

3 changes: 2 additions & 1 deletion src/game-logic/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,15 @@ export const TECHNOLOGY_TAGS = [
// special
'rare',
] as const;
export type TechnologyCategory = typeof TECHNOLOGY_TAGS[0 | 1 | 2];
export type TechnologyTag = typeof TECHNOLOGY_TAGS[number];

export class Technology extends EffectSource {
@ApiProperty({
description: 'The category, sub-category and other tags classifying this technology.',
enum: TECHNOLOGY_TAGS,
})
tags: readonly TechnologyTag[];
tags: readonly [TechnologyCategory, ...TechnologyTag[]];

@ApiProperty({
description: 'The cost in research points.',
Expand Down
34 changes: 21 additions & 13 deletions src/job/job.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
Body,
Controller,
Delete, ForbiddenException,
Get,
Get, NotFoundException,
Param,
Post,
Query,
Expand All @@ -26,6 +26,7 @@ import {CreateJobDto} from './job.dto';
import {JobService} from './job.service';
import {EmpireService} from "../empire/empire.service";
import {JobType} from "./job-type.enum";
import {EmpireDocument} from "../empire/empire.schema";

@Controller('games/:game/empires/:empire/jobs')
@ApiTags('Jobs')
Expand Down Expand Up @@ -64,8 +65,7 @@ export class JobController {
@Query('type') type?: string,
): Promise<Job[]> {
await this.checkUserAccess(game, user, empire);
// TODO: Return jobs with given filters
return Array.of(new Job());
return this.jobService.findAll({game, empire, system, type});
}

@Get(':id')
Expand Down Expand Up @@ -95,10 +95,9 @@ export class JobController {
@Param('empire', ObjectIdPipe) empire: Types.ObjectId,
@AuthUser() user: User,
@Body() createJobDto: CreateJobDto,
): Promise<Job> {
await this.checkUserAccess(game, user, empire);
// TODO: Create job
return new Job();
): Promise<Job | null> {
const userEmpire = await this.checkUserAccess(game, user, empire);
return this.jobService.createJob(userEmpire, createJobDto);
}

@Delete(':id')
Expand All @@ -113,16 +112,25 @@ export class JobController {
@Param('id', ObjectIdPipe) id: Types.ObjectId,
@AuthUser() user: User,
): Promise<Job | null> {
await this.checkUserAccess(game, user, empire);
// TODO: Delete job
return null;
const userEmpire = await this.checkUserAccess(game, user, empire);
const job = await this.jobService.findOne(id);
if (!job || !job.cost) {
throw new NotFoundException('Job not found.');
}
await this.jobService.refundResources(userEmpire, job.cost as unknown as Map<string, number>);
return this.jobService.delete(id);
}

private async checkUserAccess(game: Types.ObjectId, user: User, empire: Types.ObjectId) {
// FIXME A malicious user could pass their own empire ID and get/modify another empire's job
private async checkUserAccess(game: Types.ObjectId, user: User, empire: Types.ObjectId): Promise<EmpireDocument> {
const userEmpire = await this.empireService.findOne({game, user: user._id});
if (!userEmpire || !empire.equals(userEmpire._id)) {
if (!userEmpire) {
throw new ForbiddenException('You do not own an empire in this game.');
}

const requestedEmpire = await this.empireService.findOne({_id: empire, game});
if (!requestedEmpire || !requestedEmpire._id.equals(userEmpire._id)) {
throw new ForbiddenException('You can only access jobs for your own empire.');
}
return userEmpire;
}
}
17 changes: 17 additions & 0 deletions src/job/job.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {Injectable} from "@nestjs/common";
import {OnEvent} from "@nestjs/event-emitter";
import {JobService} from "./job.service";
import {Game} from "../game/game.schema";

@Injectable()
export class JobHandler {
constructor(
private jobService: JobService,
) {
}

@OnEvent('games.*.deleted')
async onGameDeleted(game: Game): Promise<void> {
await this.jobService.deleteMany({game: game._id});
}
}
Loading

0 comments on commit 7fcf170

Please sign in to comment.