feat: deploy adventure wallet progression

This commit is contained in:
2026-09-19 04:19:41 +08:00
parent fdb36558d3
commit 37c97708e3
14 changed files with 362 additions and 32 deletions

1
.gitignore vendored
View File

@@ -75,3 +75,4 @@ Thumbs.db
*~ *~
!src/business/auth/skin_defaults.spec.ts !src/business/auth/skin_defaults.spec.ts
!src/business/player/adventure.service.spec.ts

49
ADVENTURE.md Normal file
View File

@@ -0,0 +1,49 @@
# Battle and learning settlement
`POST /player/adventure` uses the existing JWT user identity and existing
`user_wallets` balance. It never imports the old client-local coin save.
Actions:
- `snapshot`: wallet, level, experience, health, enrollments and current battle.
- `start`, `encounter`: create/resume one battle per account; response contains session ID and question without the answer.
- `answer`, `session`, `turn`, `choice`: server grades the answer. Correct removes one of three hearts; wrong removes ten HP. Victory adds ten coins and no experience. Duplicate identical last-turn submission is idempotent.
- `escape`, `session`: closes an active battle without a reward.
- `rest`: restores health for free when not in battle.
- `recover`: spends five coins for up to thirty HP, only when injured and out of battle.
- `enroll`, `chapter`: spends twenty coins once for a published chapter.
- `complete`, `chapter`, `answers`: validates option indexes against the server chapter catalog; grants twenty learning experience once.
HP maximum is 10, 15, 20, 25, 30 for levels 15. Level requirements are
40, 60, 80, 100 experience. Only chapter completion grants experience.
## Deployment
Run `npm run db:migrate` with the target database environment before deploying
the new backend, then build/restart it and deploy the paired frontend. The
migration adds a nullable JSON `adventure` column to `user_wallets` and can be
run repeatedly. It does not change existing balances or grant old local coins.
Do not run the new entity against a database without the migration.
Database mode locks the wallet row and atomically saves progress, balance and
transaction history. Existing spend/earn operations now lock the same wallet
row so mall payments cannot overwrite adventure rewards. Memory mode supports
development/tests only and is not durable across server restarts.
`adventure_chapters.json` currently contains the two published chapter quizzes
from hello-agents and happy-llm. Update it with the corresponding frontend book
package when publishing a new chapter. Two chapters can reach level 2; levels
35 need additional published learning content. Completed chapters and defeated
encounters persist per account. No client-provided coin, damage or XP amounts
are accepted. Encounter proximity and movement are still client-authoritative;
this is not a server-authoritative world simulation.
## Verification
`npm run build`, `npm test`, and `npm run test:adventure`.
The adventure suite covers replayed/concurrent victory and enrollment requests,
wrong answers, insufficient funds, cross-account battle access, and chapter
rewards. On macOS with the sibling frontend and Godot installed it also runs the
real Godot API client against an isolated NestJS HTTP server using a test-only
guard. Production JWT validation is unchanged. MySQL migration/locking must also
be exercised in the target database environment before rollout.

View File

@@ -5,6 +5,7 @@
"description": "WhaleTown V2 NestJS backend and administration service", "description": "WhaleTown V2 NestJS backend and administration service",
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "scripts": {
"test:adventure": "jest --runInBand --runTestsByPath src/business/player/adventure.service.spec.ts",
"dev": "nest start --watch", "dev": "nest start --watch",
"build": "nest build", "build": "nest build",
"start": "node dist/main.js", "start": "node dist/main.js",

View File

@@ -3,6 +3,7 @@ import { resolve } from 'path';
import { createConnection } from 'mysql2/promise'; import { createConnection } from 'mysql2/promise';
const MIGRATIONS = [ const MIGRATIONS = [
'src/core/db/user_wallets/add-adventure-state.sql',
'src/business/invitation/migrations/create-invitation-codes.sql', 'src/business/invitation/migrations/create-invitation-codes.sql',
]; ];

View File

@@ -0,0 +1,89 @@
import { AdventureService } from './adventure.service';
import { UserWalletsMemoryService } from '../../core/db/user_wallets/user_wallets_memory.service';
import chapters from './adventure_chapters.json';
import { Test } from '@nestjs/testing';
import { PlayerController } from './player.controller';
import { PlayerStateService } from './player_state.service';
import { EconomyService } from './economy.service';
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { existsSync } from 'fs';
import { resolve } from 'path';
describe('Adventure settlement', () => {
let wallets: UserWalletsMemoryService;
let service: AdventureService;
beforeEach(() => { wallets = new UserWalletsMemoryService(); service = new AdventureService(wallets); });
const user = 1n;
it('awards exactly ten coins, no experience, and cannot replay a victory', async () => {
const start = await service.execute(user, { action: 'start', encounter: 'starting_encounter' });
const session = start.battle.id;
for (let turn = 0; turn < 2; turn++) await service.execute(user, { action: 'answer', session, turn, choice: [1, 2][turn] });
const results = await Promise.all(Array.from({ length: 8 }, () => service.execute(user, { action: 'answer', session, turn: 2, choice: 0 })));
results.forEach(result => {
expect(result.wallet.balance).toBe(1210);
expect(result.progress.experience).toBe(0);
expect(result.battle.status).toBe('victory');
});
await expect(service.execute(user, { action: 'start', encounter: 'starting_encounter' })).rejects.toThrow();
expect((await wallets.getBalance(user)).balance).toBe(1210);
});
it('rejects another user/session and makes a level one error fatal', async () => {
const start = await service.execute(user, { action: 'start', encounter: 'starting_encounter' });
const command = { action: 'answer', session: start.battle.id, turn: 0, choice: 0 };
await expect(service.execute(2n, command)).rejects.toThrow();
const result = await service.execute(user, command);
expect(result.progress.health).toBe(0);
expect(result.battle.status).toBe('defeat');
expect(result.wallet.balance).toBe(1200);
expect((await service.execute(user, { action: 'rest' })).progress.health).toBe(10);
});
it('charges once, validates chapter answers and only grants experience once', async () => {
const chapter = Object.keys(chapters)[0];
await expect(service.execute(user, { action: 'complete', chapter, answers: [] })).rejects.toThrow();
await Promise.all(Array.from({ length: 5 }, () => service.execute(user, { action: 'enroll', chapter })));
expect((await wallets.getBalance(user)).balance).toBe(1180);
await expect(service.execute(user, { action: 'complete', chapter, answers: [-1] })).rejects.toThrow();
const answers = chapters[chapter].answers;
const results = await Promise.all(Array.from({ length: 5 }, () => service.execute(user, { action: 'complete', chapter, answers })));
expect(results[4].progress.experience).toBe(20);
expect(results[4].wallet.balance).toBe(1180);
});
it('rolls back insufficient funds without enrolling', async () => {
await wallets.spend(user, 1200, 'test', 'zero');
const chapter = Object.keys(chapters)[0];
await expect(service.execute(user, { action: 'enroll', chapter })).rejects.toThrow();
const result = await service.execute(user, { action: 'snapshot' });
expect(result.progress.chapters).toEqual({});
expect(result.wallet.balance).toBe(0);
});
const godot = '/Applications/Godot.app/Contents/MacOS/Godot';
const frontend = resolve(__dirname, '../../../../whale-town-front-v2');
(existsSync(godot) && existsSync(frontend) ? it : it.skip)('runs the Godot client against the real HTTP controller', async () => {
const module = await Test.createTestingModule({ controllers: [PlayerController], providers: [
{ provide: AdventureService, useValue: service },
{ provide: PlayerStateService, useValue: {} },
{ provide: EconomyService, useValue: {} },
] }).overrideGuard(JwtAuthGuard).useValue({ canActivate: context => {
const request = context.switchToHttp().getRequest();
request.user = { sub: '88' };
return request.headers.authorization === 'Bearer adventure-test-only';
} }).compile();
const app = module.createNestApplication();
await app.listen(0, '127.0.0.1');
try {
const url = await app.getUrl();
const denied = await fetch(url + '/player/adventure', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'snapshot' }) });
expect(denied.status).toBe(403);
const invalid = await fetch(url + '/player/adventure', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer adventure-test-only' }, body: JSON.stringify({ action: 'snapshot', coins: 10000 }) });
expect(invalid.status).toBe(400);
const { stdout, stderr } = await promisify(execFile)(godot, ['--headless', '--path', frontend, '--script', 'scripts/test_adventure_http.gd'], {
env: { ...process.env, WHALETOWN_API_BASE_URL: url }, timeout: 20000,
});
expect(stdout).toContain('ADVENTURE_HTTP_OK');
expect(stderr).not.toContain('SCRIPT ERROR');
} finally { await app.close(); }
}, 30000);
});

View File

@@ -0,0 +1,102 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { UserWallets } from '../../core/db/user_wallets/user_wallets.entity';
import chapters from './adventure_chapters.json';
const QUESTIONS = [
{ text: 'Python 中列表通常使用哪一种括号表示?', options: ['{}', '[]', '()', '<>'], answer: 1 },
{ text: 'Python 中哪个关键字用于定义函数?', options: ['class', 'for', 'def', 'return'], answer: 2 },
{ text: 'Python 中 len([1, 2, 3]) 的结果是什么?', options: ['3', '2', '1', '0'], answer: 0 },
{ text: 'Python 中哪个值表示逻辑真?', options: ['None', 'False', '0', 'True'], answer: 3 },
];
export interface AdventureCommand { action: string; encounter?: string; session?: string; turn?: number; choice?: number; chapter?: string; answers?: number[]; }
@Injectable()
export class AdventureService {
constructor(@Inject('IUserWalletsService') private readonly wallets: {
mutateAdventure<T>(id: bigint, operation: (wallet: UserWallets) => T): Promise<T>;
}) {}
execute(userId: bigint, command: AdventureCommand) {
return this.wallets.mutateAdventure(userId, wallet => {
const s = wallet.adventure ||= { level: 1, experience: 0, health: 10, chapters: {}, defeated: {}, sequence: 0, battle: null };
const maxHealth = () => 10 + (s.level - 1) * 5;
const spend = (amount: number) => {
if (wallet.balance < amount) throw new BadRequestException('鲸币余额不足');
wallet.balance -= amount;
};
let correct: boolean | undefined;
const active = s.battle?.status === 'active';
if (active && ['rest', 'recover', 'enroll', 'complete'].includes(command.action)) throw new BadRequestException('请先结束战斗');
switch (command.action) {
case 'snapshot': break;
case 'start': {
if (!command.encounter || !/^(starting_encounter|-?\d+_-?\d+)$/.test(command.encounter)) throw new BadRequestException('无效遭遇');
if (active) {
if (s.battle.encounter !== command.encounter) throw new BadRequestException('已有进行中的战斗');
break;
}
if (s.defeated[command.encounter]) throw new BadRequestException('该怪物已被击败');
if (s.health <= 0) throw new BadRequestException('请返回营地恢复');
s.battle = { id: randomUUID(), encounter: command.encounter, turn: 0, hearts: 3, status: 'active', last: null };
break;
}
case 'answer': {
const b = s.battle;
if (!b || command.session !== b.id) throw new BadRequestException('战斗不存在');
if (b.last && command.turn === b.last.turn) {
if (command.choice !== b.last.choice) throw new BadRequestException('本回合已结算');
correct = b.last.correct;
break;
}
if (b.status !== 'active' || command.turn !== b.turn || !Number.isInteger(command.choice) || command.choice < 0 || command.choice > 3) throw new BadRequestException('回合或答案无效');
correct = command.choice === QUESTIONS[b.turn % QUESTIONS.length].answer;
b.last = { turn: b.turn, choice: command.choice, correct };
b.turn++;
if (correct) b.hearts--; else s.health = Math.max(0, s.health - 10);
if (b.hearts === 0) {
b.status = 'victory';
s.defeated[b.encounter] = true;
wallet.balance += 10;
} else if (!s.health) b.status = 'defeat';
break;
}
case 'escape':
if (!s.battle || s.battle.id !== command.session) throw new BadRequestException('战斗不存在');
if (active) s.battle.status = 'escaped';
break;
case 'rest': s.health = maxHealth(); break;
case 'recover':
if (s.health < maxHealth()) { spend(5); s.health = Math.min(maxHealth(), s.health + 30); }
break;
case 'enroll':
case 'complete': {
const chapter = (chapters as Record<string, { answers: number[] }>)[command.chapter];
if (!chapter) throw new BadRequestException('章节不存在');
if (command.action === 'enroll') {
if (!s.chapters[command.chapter]) { spend(20); s.chapters[command.chapter] = 'enrolled'; }
} else {
if (!s.chapters[command.chapter]) throw new BadRequestException('请先报名');
if (s.chapters[command.chapter] === 'completed') break;
if (!Array.isArray(command.answers) || command.answers.length !== chapter.answers.length || chapter.answers.some((answer, i) => command.answers[i] !== answer)) throw new BadRequestException('请完成章节练习');
s.chapters[command.chapter] = 'completed';
if (s.level < 5) s.experience += 20;
while (s.level < 5 && s.experience >= 20 * (s.level + 1)) {
s.experience -= 20 * (s.level + 1); s.level++; s.health += 5;
}
}
break;
}
default: throw new BadRequestException('未知操作');
}
s.sequence++;
const b = s.battle;
const question = QUESTIONS[(b?.turn || 0) % QUESTIONS.length];
return { wallet: { user_id: userId.toString(), balance: wallet.balance, currency: 'whale_coin' },
progress: { level: s.level, experience: s.experience, health: s.health, max_health: maxHealth(), chapters: s.chapters, defeated: s.defeated },
battle: b ? { id: b.id, encounter: b.encounter, turn: b.turn, hearts: b.hearts, status: b.status, question: { text: question.text, options: question.options } } : null,
correct,
};
});
}
}

View File

@@ -0,0 +1,12 @@
{
"hello-agents:chapter-01-第一章-初识智能体": {
"answers": [
0
]
},
"happy-llm:chapter-01-第一章-nlp-基础概念": {
"answers": [
0
]
}
}

View File

@@ -0,0 +1,11 @@
import { ArrayMaxSize, IsArray, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class AdventureCommandDto {
@IsIn(['snapshot', 'start', 'answer', 'escape', 'rest', 'recover', 'enroll', 'complete']) action: string;
@IsOptional() @IsString() @MaxLength(100) encounter?: string;
@IsOptional() @IsString() @MaxLength(100) session?: string;
@IsOptional() @IsInt() @Min(0) @Max(1000000) turn?: number;
@IsOptional() @IsInt() @Min(0) @Max(3) choice?: number;
@IsOptional() @IsString() @MaxLength(200) chapter?: string;
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsInt({ each: true }) answers?: number[];
}

View File

@@ -1,4 +1,7 @@
import { Body, Controller, Get, HttpStatus, Patch, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; import { Body, Controller, Get, HttpStatus, Patch, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
import { Post } from '@nestjs/common';
import { AdventureService } from './adventure.service';
import { AdventureCommandDto } from './dto/adventure_command.dto';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
import { Response } from 'express'; import { Response } from 'express';
import { CurrentUser } from '../../gateway/auth/current_user.decorator'; import { CurrentUser } from '../../gateway/auth/current_user.decorator';
@@ -18,8 +21,15 @@ export class PlayerController {
constructor( constructor(
private readonly playerStateService: PlayerStateService, private readonly playerStateService: PlayerStateService,
private readonly economyService: EconomyService, private readonly economyService: EconomyService,
private readonly adventureService: AdventureService,
) {} ) {}
@Post('adventure')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
async adventure(@CurrentUser() user: JwtPayload, @Body() dto: AdventureCommandDto) {
return { success: true, data: await this.adventureService.execute(BigInt(user.sub), dto) };
}
@ApiOperation({ summary: '获取当前玩家快照' }) @ApiOperation({ summary: '获取当前玩家快照' })
@SwaggerApiResponse({ status: 200, description: '玩家快照获取成功' }) @SwaggerApiResponse({ status: 200, description: '玩家快照获取成功' })
@Get('snapshot') @Get('snapshot')

View File

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdventureService } from './adventure.service';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { LoginCoreModule } from '../../core/login_core/login_core.module'; import { LoginCoreModule } from '../../core/login_core/login_core.module';
import { InventoryController } from './inventory.controller'; import { InventoryController } from './inventory.controller';
@@ -10,7 +11,7 @@ import { PlayerStateService } from './player_state.service';
@Module({ @Module({
imports: [AuthModule, LoginCoreModule], imports: [AuthModule, LoginCoreModule],
controllers: [PlayerController, InventoryController], controllers: [PlayerController, InventoryController],
providers: [EconomyService, InventoryService, PlayerStateService], providers: [EconomyService, InventoryService, PlayerStateService, AdventureService],
exports: [EconomyService, InventoryService, PlayerStateService], exports: [EconomyService, InventoryService, PlayerStateService],
}) })
export class PlayerModule {} export class PlayerModule {}

View File

@@ -0,0 +1,8 @@
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'user_wallets' AND column_name = 'adventure'),
'SELECT 1',
'ALTER TABLE user_wallets ADD COLUMN adventure JSON NULL'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -24,6 +24,9 @@ export class UserWallets {
}) })
balance: number; balance: number;
@Column({ type: 'json', nullable: true })
adventure: Record<string, any> | null;
@Column({ @Column({
type: 'timestamp', type: 'timestamp',
default: () => 'CURRENT_TIMESTAMP', default: () => 'CURRENT_TIMESTAMP',

View File

@@ -31,6 +31,28 @@ export class UserWalletsService {
private readonly walletTransactionsRepository: Repository<WalletTransactions>, private readonly walletTransactionsRepository: Repository<WalletTransactions>,
) {} ) {}
async mutateAdventure<T>(userId: bigint, operation: (wallet: UserWallets) => T): Promise<T> {
await this.ensureWallet(userId);
return this.userWalletsRepository.manager.transaction(async manager => {
const wallet = await manager.findOneOrFail(UserWallets, {
where: { user_id: userId }, lock: { mode: 'pessimistic_write' },
});
const before = wallet.balance;
const result = operation(wallet);
wallet.updated_at = new Date();
await manager.save(UserWallets, wallet);
if (wallet.balance !== before) {
await manager.save(WalletTransactions, manager.create(WalletTransactions, {
user_id: userId, type: wallet.balance > before ? 'earn' : 'spend',
amount: wallet.balance - before, balance_after: wallet.balance,
reference_type: 'adventure', reference_id: String(wallet.adventure?.sequence || 0),
note: '战斗与学习结算', created_at: new Date(),
}));
}
return result;
});
}
async ensureWallet(userId: bigint): Promise<UserWallets> { async ensureWallet(userId: bigint): Promise<UserWallets> {
const existing = await this.userWalletsRepository.findOne({ const existing = await this.userWalletsRepository.findOne({
where: { user_id: userId }, where: { user_id: userId },
@@ -39,14 +61,21 @@ export class UserWalletsService {
return existing; return existing;
} }
const wallet = new UserWallets(); return this.userWalletsRepository.manager.transaction(async manager => {
wallet.user_id = userId; const inserted = await manager.createQueryBuilder().insert().into(UserWallets).values({
wallet.balance = DEFAULT_INITIAL_WHALE_COINS; user_id: userId, balance: DEFAULT_INITIAL_WHALE_COINS,
wallet.created_at = new Date(); created_at: new Date(), updated_at: new Date(),
wallet.updated_at = new Date(); }).orIgnore().execute();
const savedWallet = await this.userWalletsRepository.save(wallet); const wallet = await manager.findOneOrFail(UserWallets, { where: { user_id: userId } });
await this.createTransaction(userId, 'grant', DEFAULT_INITIAL_WHALE_COINS, savedWallet.balance, 'registration', 'initial_wallet', '新用户初始鲸币'); if (inserted.raw.affectedRows === 1) {
return savedWallet; await manager.save(WalletTransactions, manager.create(WalletTransactions, {
user_id: userId, type: 'grant', amount: DEFAULT_INITIAL_WHALE_COINS,
balance_after: DEFAULT_INITIAL_WHALE_COINS, reference_type: 'registration',
reference_id: 'initial_wallet', note: '新用户初始鲸币', created_at: new Date(),
}));
}
return wallet;
});
} }
async getBalance(userId: bigint): Promise<WalletBalancePayload> { async getBalance(userId: bigint): Promise<WalletBalancePayload> {
@@ -63,20 +92,7 @@ export class UserWalletsService {
throw new BadRequestException('鲸币消费数量不正确'); throw new BadRequestException('鲸币消费数量不正确');
} }
const wallet = await this.ensureWallet(userId); return this.changeBalance(userId, -amount, referenceType, referenceId, note || '');
if (wallet.balance < amount) {
throw new BadRequestException('鲸币余额不足');
}
wallet.balance -= amount;
wallet.updated_at = new Date();
const savedWallet = await this.userWalletsRepository.save(wallet);
const transaction = await this.createTransaction(userId, 'spend', -amount, savedWallet.balance, referenceType, referenceId, note || '');
return {
wallet: savedWallet,
transaction,
};
} }
async earn(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<EarnWalletResult> { async earn(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<EarnWalletResult> {
@@ -84,16 +100,24 @@ export class UserWalletsService {
throw new BadRequestException('鲸币收入数量不正确'); throw new BadRequestException('鲸币收入数量不正确');
} }
const wallet = await this.ensureWallet(userId); return this.changeBalance(userId, amount, referenceType, referenceId, note || '');
wallet.balance += amount; }
wallet.updated_at = new Date();
const savedWallet = await this.userWalletsRepository.save(wallet);
const transaction = await this.createTransaction(userId, 'earn', amount, savedWallet.balance, referenceType, referenceId, note || '');
return { private async changeBalance(userId: bigint, delta: number, referenceType: string, referenceId: string, note: string): Promise<SpendWalletResult> {
wallet: savedWallet, await this.ensureWallet(userId);
transaction, return this.userWalletsRepository.manager.transaction(async manager => {
}; const wallet = await manager.findOneOrFail(UserWallets, { where: { user_id: userId }, lock: { mode: 'pessimistic_write' } });
if (wallet.balance + delta < 0) throw new BadRequestException('鲸币余额不足');
wallet.balance += delta;
wallet.updated_at = new Date();
await manager.save(UserWallets, wallet);
const transaction = await manager.save(WalletTransactions, manager.create(WalletTransactions, {
user_id: userId, type: delta < 0 ? 'spend' : 'earn', amount: delta,
balance_after: wallet.balance, reference_type: referenceType, reference_id: referenceId,
note, created_at: new Date(),
}));
return { wallet, transaction };
});
} }
private async createTransaction( private async createTransaction(

View File

@@ -5,6 +5,24 @@ import { WalletTransactions } from './wallet_transactions.entity';
@Injectable() @Injectable()
export class UserWalletsMemoryService { export class UserWalletsMemoryService {
private adventureQueues = new Map<bigint, Promise<unknown>>();
async mutateAdventure<T>(userId: bigint, operation: (wallet: UserWallets) => T): Promise<T> {
const previous = this.adventureQueues.get(userId) || Promise.resolve();
const next = previous.catch(() => {}).then(async () => {
const original = await this.ensureWallet(userId);
const draft = { ...original, adventure: JSON.parse(JSON.stringify(original.adventure || null)) };
const result = operation(draft);
const delta = draft.balance - original.balance;
Object.assign(original, draft);
if (delta) await this.createTransaction(userId, delta > 0 ? 'earn' : 'spend', delta, draft.balance, 'adventure', String(draft.adventure?.sequence || 0), '战斗与学习结算');
return result;
});
this.adventureQueues.set(userId, next);
try { return await next; } finally {
if (this.adventureQueues.get(userId) === next) this.adventureQueues.delete(userId);
}
}
private wallets: Map<bigint, UserWallets> = new Map(); private wallets: Map<bigint, UserWallets> = new Map();
private transactions: WalletTransactions[] = []; private transactions: WalletTransactions[] = [];
private currentWalletId: bigint = BigInt(1); private currentWalletId: bigint = BigInt(1);