feat(turn-in): add TurnInService with atomic item/silver/reputation exchange and POST /api/turn-ins

This commit is contained in:
Bastian Wagner
2026-08-21 13:07:46 +02:00
parent 321738496d
commit 4e3af9fe39
8 changed files with 523 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import { InventoryModule } from './inventory/inventory.module';
import { RenownModule } from './renown/renown.module';
import { ReputationModule } from './reputation/reputation.module';
import { TravelModule } from './travel/travel.module';
import { TurnInModule } from './turn-in/turn-in.module';
import { WorldModule } from './world/world.module';
@Module({
@@ -24,6 +25,7 @@ import { WorldModule } from './world/world.module';
InventoryModule,
RenownModule,
ReputationModule,
TurnInModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,10 @@
import { IsInt, IsString, Min } from 'class-validator';
export class TurnInDto {
@IsString()
turnInKey!: string;
@IsInt()
@Min(1)
quantity!: number;
}

View File

@@ -0,0 +1,72 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { configureApplication } from '../app.config';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { TurnInController } from './turn-in.controller';
import { TurnInService } from './turn-in.service';
describe('TurnInController', () => {
let app: INestApplication<App>;
const turnIn = jest.fn();
beforeEach(async () => {
turnIn.mockReset();
const module = await Test.createTestingModule({
controllers: [TurnInController],
providers: [{ provide: TurnInService, useValue: { turnIn } }],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('delegates POST /api/turn-ins with only turnInKey and quantity', async () => {
const result = {
turnInKey: 'ash-pelt-border-guard',
quantityConsumed: 3,
silverGranted: 12,
reputationResult: {
factionKey: 'border-guard',
previousReputation: 0,
newReputation: 3,
previousRank: 'STRANGER',
newRank: 'STRANGER',
rankChanged: false,
},
};
turnIn.mockResolvedValue(result);
const response = await request(app.getHttpServer())
.post('/api/turn-ins')
.send({ turnInKey: 'ash-pelt-border-guard', quantity: 3 })
.expect(201);
expect(turnIn).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'ash-pelt-border-guard', 3);
expect(response.body).toEqual(result);
});
it('rejects server-owned reward fields the client must never send', async () => {
await request(app.getHttpServer())
.post('/api/turn-ins')
.send({ turnInKey: 'ash-pelt-border-guard', quantity: 3, silverGranted: 999, reputationReward: 999 })
.expect(400);
expect(turnIn).not.toHaveBeenCalled();
});
it('rejects a missing quantity', async () => {
await request(app.getHttpServer())
.post('/api/turn-ins')
.send({ turnInKey: 'ash-pelt-border-guard' })
.expect(400);
expect(turnIn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,14 @@
import { Body, Controller, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { TurnInDto } from './dto/turn-in.dto';
import { TurnInResult, TurnInService } from './turn-in.service';
@Controller('turn-ins')
export class TurnInController {
constructor(private readonly turnInService: TurnInService) {}
@Post()
turnIn(@Body() request: TurnInDto): Promise<TurnInResult> {
return this.turnInService.turnIn(DEMO_CHARACTER_ID, request.turnInKey, request.quantity);
}
}

View File

@@ -0,0 +1,51 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type TurnInErrorCode =
| 'TURN_IN_NOT_FOUND'
| 'TURN_IN_DISABLED'
| 'TURN_IN_INVALID_QUANTITY'
| 'TURN_IN_INSUFFICIENT_QUANTITY';
export class TurnInDomainError extends HttpException {
constructor(
public readonly code: TurnInErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function turnInNotFound(): TurnInDomainError {
return new TurnInDomainError(
'TURN_IN_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This turn-in could not be found.',
);
}
export function turnInDisabled(): TurnInDomainError {
return new TurnInDomainError(
'TURN_IN_DISABLED',
HttpStatus.CONFLICT,
'This turn-in is not currently available.',
);
}
export function turnInInvalidQuantity(): TurnInDomainError {
return new TurnInDomainError(
'TURN_IN_INVALID_QUANTITY',
HttpStatus.BAD_REQUEST,
'Quantity must be a positive integer.',
);
}
export function turnInInsufficientQuantity(): TurnInDomainError {
return new TurnInDomainError(
'TURN_IN_INSUFFICIENT_QUANTITY',
HttpStatus.CONFLICT,
'The character does not own enough of this item.',
);
}
export { characterNotFound } from '../travel/travel.errors';

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationModule } from '../reputation/reputation.module';
import { TurnInDefinition } from './entities/turn-in-definition.entity';
import { TurnInController } from './turn-in.controller';
import { TurnInService } from './turn-in.service';
@Module({
imports: [
TypeOrmModule.forFeature([Character, CharacterItem, TurnInDefinition, ReputationFaction]),
ReputationModule,
],
controllers: [TurnInController],
providers: [TurnInService],
exports: [TurnInService],
})
export class TurnInModule {}

View File

@@ -0,0 +1,258 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationService } from '../reputation/reputation.service';
import { TurnInDefinition } from './entities/turn-in-definition.entity';
import { TurnInDomainError } from './turn-in.errors';
import { TurnInService } from './turn-in.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const FACTION_ID = '80000000-0000-4000-8000-000000000001';
const ITEM_DEFINITION_ID = '50000000-0000-4000-8000-00000000000c';
const CHARACTER_ITEM_ID = '95000000-0000-4000-8000-000000000001';
interface State {
characters: Character[];
characterItems: CharacterItem[];
turnIns: TurnInDefinition[];
factions: ReputationFaction[];
characterReputation: CharacterReputation[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly rows: T[],
private readonly prefix: string,
private readonly inTransaction: boolean,
) {}
findOne(options: { where: Partial<T>; lock?: { mode: string } }): Promise<T | null> {
if (options.lock && !this.inTransaction) {
throw new Error('Pessimistic locks require a transaction');
}
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null);
}
find(options: { where?: Partial<T> } = {}): Promise<T[]> {
return Promise.resolve(
options.where ? this.rows.filter((row) => this.matches(row, options.where!)) : [...this.rows],
);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = `${this.prefix}-${this.rows.length + 1}`;
}
const index = this.rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
this.rows.push(entity);
} else {
this.rows[index] = entity;
}
return Promise.resolve(entity);
}
remove(entity: T): Promise<T> {
const index = this.rows.findIndex((row) => row.id === entity.id);
if (index !== -1) {
this.rows.splice(index, 1);
}
return Promise.resolve(entity);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
}
class FakeDataSource {
constructor(public state: State) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return this.repoFor(target, false);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) => this.repoFor(target, true),
} as unknown as EntityManager);
}
private repoFor<T extends { id: string }>(target: EntityTarget<T>, inTransaction: boolean) {
if (target === Character) return new FakeRepository(this.state.characters, 'character', inTransaction) as never;
if (target === CharacterItem)
return new FakeRepository(this.state.characterItems, 'char-item', inTransaction) as never;
if (target === TurnInDefinition)
return new FakeRepository(this.state.turnIns, 'turn-in', inTransaction) as never;
if (target === ReputationFaction)
return new FakeRepository(this.state.factions, 'faction', inTransaction) as never;
if (target === CharacterReputation)
return new FakeRepository(this.state.characterReputation, 'char-rep', inTransaction) as never;
throw new Error('Unsupported repository');
}
}
function character(overrides: Partial<Character> = {}): Character {
return { id: CHARACTER_ID, silver: 10, ...overrides } as Character;
}
function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
return {
id: CHARACTER_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: ITEM_DEFINITION_ID,
quantity: 5,
...overrides,
} as CharacterItem;
}
function turnInDefinition(overrides: Partial<TurnInDefinition> = {}): TurnInDefinition {
return {
id: 'turn-in-1',
key: 'ash-pelt-border-guard',
itemDefinitionId: ITEM_DEFINITION_ID,
factionId: FACTION_ID,
silverRewardPerItem: 4,
reputationRewardPerItem: 1,
repeatable: true,
enabled: true,
...overrides,
} as TurnInDefinition;
}
function faction(overrides: Partial<ReputationFaction> = {}): ReputationFaction {
return { id: FACTION_ID, key: 'border-guard', name: 'Grenzwacht', enabled: true, ...overrides } as ReputationFaction;
}
function createState(overrides: Partial<State> = {}): State {
return {
characters: [character()],
characterItems: [characterItem()],
turnIns: [turnInDefinition()],
factions: [faction()],
characterReputation: [],
...overrides,
};
}
function createService(state: State) {
const dataSource = new FakeDataSource(state);
const reputationService = new ReputationService(dataSource as unknown as DataSource);
return {
service: new TurnInService(dataSource as unknown as DataSource, reputationService),
state,
};
}
async function expectTurnInDomainError(promise: Promise<unknown>, code: string): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(TurnInDomainError);
if (!(error instanceof TurnInDomainError)) {
throw new Error('Expected TurnInDomainError');
}
expect(error.code).toBe(code);
}
describe('TurnInService', () => {
describe('turnIn', () => {
it('consumes the exact quantity, grants silver and reputation atomically', async () => {
const { service, state } = createService(createState());
const result = await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3);
expect(result).toEqual({
turnInKey: 'ash-pelt-border-guard',
quantityConsumed: 3,
silverGranted: 12,
reputationResult: {
factionKey: 'border-guard',
previousReputation: 0,
newReputation: 3,
previousRank: 'STRANGER',
newRank: 'STRANGER',
rankChanged: false,
},
});
expect(state.characterItems[0].quantity).toBe(2);
expect(state.characters[0].silver).toBe(22);
expect(state.characterReputation[0].reputation).toBe(3);
});
it('deletes the CharacterItem row once its quantity reaches 0', async () => {
const { service, state } = createService(createState({ characterItems: [characterItem({ quantity: 3 })] }));
await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3);
expect(state.characterItems).toHaveLength(0);
});
it('rejects turning in more than the character owns, mutating nothing', async () => {
const { service, state } = createService(createState({ characterItems: [characterItem({ quantity: 2 })] }));
await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3),
'TURN_IN_INSUFFICIENT_QUANTITY',
);
expect(state.characterItems[0].quantity).toBe(2);
expect(state.characters[0].silver).toBe(10);
expect(state.characterReputation).toHaveLength(0);
});
it('rejects a zero or negative quantity', async () => {
const { service } = createService(createState());
await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 0),
'TURN_IN_INVALID_QUANTITY',
);
});
it('rejects an unknown turn-in key, mutating nothing', async () => {
const { service, state } = createService(createState());
await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'unknown-turn-in', 1),
'TURN_IN_NOT_FOUND',
);
expect(state.characters[0].silver).toBe(10);
});
it('rejects a disabled turn-in', async () => {
const state = createState({ turnIns: [turnInDefinition({ enabled: false })] });
const { service } = createService(state);
await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 1),
'TURN_IN_DISABLED',
);
});
it('computes multi-item rewards server-side from the definition, not from client input', async () => {
const state = createState({
turnIns: [turnInDefinition({ silverRewardPerItem: 12, reputationRewardPerItem: 4 })],
});
const { service, state: resultState } = createService(state);
const result = await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 5);
expect(result.silverGranted).toBe(60);
expect(result.reputationResult.newReputation).toBe(20);
expect(resultState.characters[0].silver).toBe(70);
});
});
});

View File

@@ -0,0 +1,96 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationGrantResult, ReputationService } from '../reputation/reputation.service';
import { TurnInDefinition } from './entities/turn-in-definition.entity';
import {
characterNotFound,
turnInDisabled,
turnInInsufficientQuantity,
turnInInvalidQuantity,
turnInNotFound,
} from './turn-in.errors';
export interface TurnInResult {
turnInKey: string;
quantityConsumed: number;
silverGranted: number;
reputationResult: ReputationGrantResult;
}
@Injectable()
export class TurnInService {
constructor(
private readonly dataSource: DataSource,
private readonly reputationService: ReputationService,
) {}
/**
* Consumes loot and grants Silver + Reputation atomically (spec §20,
* §31): a failed turn-in leaves items, silver, and reputation completely
* untouched. The reward math is entirely server-computed from persisted
* content -- the client supplies only `turnInKey`/`quantity` (spec §25).
*/
async turnIn(characterId: string, turnInKey: string, quantity: number): Promise<TurnInResult> {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw turnInInvalidQuantity();
}
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const characterItems = manager.getRepository(CharacterItem);
const turnIns = manager.getRepository(TurnInDefinition);
const factions = manager.getRepository(ReputationFaction);
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
const definition = await turnIns.findOneBy({ key: turnInKey });
if (!definition) {
throw turnInNotFound();
}
if (!definition.enabled) {
throw turnInDisabled();
}
const characterItem = await characterItems.findOne({
where: { characterId, itemDefinitionId: definition.itemDefinitionId },
lock: { mode: 'pessimistic_write' },
});
if (!characterItem || characterItem.quantity < quantity) {
throw turnInInsufficientQuantity();
}
if (characterItem.quantity === quantity) {
await characterItems.remove(characterItem);
} else {
characterItem.quantity -= quantity;
await characterItems.save(characterItem);
}
const silverGranted = definition.silverRewardPerItem * quantity;
character.silver += silverGranted;
await characters.save(character);
const faction = await factions.findOneBy({ id: definition.factionId });
if (!faction) {
throw turnInNotFound();
}
const reputationResult = await this.reputationService.grantReputation(
characterId,
faction.key,
definition.reputationRewardPerItem * quantity,
manager,
);
return { turnInKey, quantityConsumed: quantity, silverGranted, reputationResult };
});
}
}