fel kesz game backend
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
import { GameField, BoardData } from '../../Domain/Game/GameAggregate';
|
||||
import { logOther, logError } from '../Services/Logger';
|
||||
|
||||
interface TargetField {
|
||||
fieldNumber: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
interface SpecialFieldInfo {
|
||||
position: number;
|
||||
type: 'positive' | 'negative' | 'luck';
|
||||
}
|
||||
|
||||
export class BoardGenerationService {
|
||||
private readonly MAX_GENERATION_TIME = parseInt(process.env.MAX_GENERATION_TIME_SECONDS || '20') * 1000;
|
||||
private readonly ERROR_TOLERANCE = parseInt(process.env.GENERATION_ERROR_TOLERANCE || '15');
|
||||
|
||||
async generateBoard(
|
||||
positiveFieldCount: number,
|
||||
negativeFieldCount: number,
|
||||
luckFieldCount: number
|
||||
): Promise<BoardData> {
|
||||
const startTime = Date.now();
|
||||
let bestAttempt: BoardData | null = null;
|
||||
let attemptCount = 0;
|
||||
|
||||
while (Date.now() - startTime < this.MAX_GENERATION_TIME) {
|
||||
attemptCount++;
|
||||
|
||||
try {
|
||||
const attempt = this.generateSingleAttempt(positiveFieldCount, negativeFieldCount, luckFieldCount);
|
||||
|
||||
if (attempt.totalErrorRate <= this.ERROR_TOLERANCE) {
|
||||
logOther(`Board generation successful on attempt ${attemptCount}. Error rate: ${attempt.totalErrorRate}%`);
|
||||
return attempt;
|
||||
}
|
||||
|
||||
if (!bestAttempt || attempt.totalErrorRate < bestAttempt.totalErrorRate) {
|
||||
bestAttempt = attempt;
|
||||
}
|
||||
|
||||
logOther(`Attempt ${attemptCount}: Error rate ${attempt.totalErrorRate}% (target: ${this.ERROR_TOLERANCE}%)`);
|
||||
|
||||
} catch (error) {
|
||||
logError(`Board generation attempt ${attemptCount} failed:`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
logOther(`Using best attempt with error rate: ${bestAttempt?.totalErrorRate || 100}%`);
|
||||
return bestAttempt || this.generateFallbackBoard(positiveFieldCount, negativeFieldCount, luckFieldCount);
|
||||
}
|
||||
|
||||
private generateSingleAttempt(
|
||||
positiveFieldCount: number,
|
||||
negativeFieldCount: number,
|
||||
luckFieldCount: number
|
||||
): BoardData {
|
||||
// Step 1: Choose special field positions
|
||||
const specialFieldPositions = this.chooseSpecialFieldPositions(
|
||||
positiveFieldCount,
|
||||
negativeFieldCount,
|
||||
luckFieldCount
|
||||
);
|
||||
|
||||
// Step 2: Select target fields for each special field (6 targets per field for dice 1-6)
|
||||
const targetFieldsMap = this.selectTargetFields(specialFieldPositions);
|
||||
|
||||
// Step 3: Create border with strategic placement
|
||||
const border = this.createStrategicBorder(targetFieldsMap);
|
||||
|
||||
// Step 4: Calculate step values based on border positions
|
||||
const fields = this.calculateStepValues(specialFieldPositions, targetFieldsMap, border);
|
||||
|
||||
// Step 5: Validate against 20-30 rule and calculate error rate
|
||||
const validationResults = this.validateBoardGeneration(fields, border);
|
||||
|
||||
// Log generation statistics
|
||||
logOther('Board generation attempt completed', {
|
||||
totalFields: fields.length,
|
||||
specialFields: fields.filter(f => f.type !== 'regular').length,
|
||||
positiveFields: fields.filter(f => f.type === 'positive').length,
|
||||
negativeFields: fields.filter(f => f.type === 'negative').length,
|
||||
luckFields: fields.filter(f => f.type === 'luck').length,
|
||||
errorRate: validationResults.errorRate,
|
||||
targetCount: Array.from(targetFieldsMap.values()).reduce((sum, targets) => sum + targets.length, 0)
|
||||
});
|
||||
|
||||
return {
|
||||
fields,
|
||||
border,
|
||||
validationResults: validationResults.validationResults,
|
||||
totalErrorRate: validationResults.errorRate
|
||||
};
|
||||
}
|
||||
|
||||
private chooseSpecialFieldPositions(
|
||||
positiveFieldCount: number,
|
||||
negativeFieldCount: number,
|
||||
luckFieldCount: number
|
||||
): SpecialFieldInfo[] {
|
||||
const totalSpecial = positiveFieldCount + negativeFieldCount + luckFieldCount;
|
||||
const positions: number[] = [];
|
||||
const specialFields: SpecialFieldInfo[] = [];
|
||||
|
||||
// Random placement with retry for good distribution
|
||||
let attempts = 0;
|
||||
while (positions.length < totalSpecial && attempts < 100) {
|
||||
const position = Math.floor(Math.random() * 100) + 1; // 1-100
|
||||
|
||||
if (!positions.includes(position)) {
|
||||
// Check minimum distance from existing positions
|
||||
const tooClose = positions.some(existingPos => Math.abs(existingPos - position) < 3);
|
||||
|
||||
if (!tooClose || attempts > 50) { // Relax distance requirement after many attempts
|
||||
positions.push(position);
|
||||
}
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Sort positions and assign types
|
||||
positions.sort((a, b) => a - b);
|
||||
|
||||
// Distribute types randomly
|
||||
const types: ('positive' | 'negative' | 'luck')[] = [
|
||||
...Array(positiveFieldCount).fill('positive'),
|
||||
...Array(negativeFieldCount).fill('negative'),
|
||||
...Array(luckFieldCount).fill('luck')
|
||||
];
|
||||
|
||||
// Shuffle types
|
||||
for (let i = types.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[types[i], types[j]] = [types[j], types[i]];
|
||||
}
|
||||
|
||||
positions.forEach((position, index) => {
|
||||
specialFields.push({
|
||||
position,
|
||||
type: types[index] || 'positive'
|
||||
});
|
||||
});
|
||||
|
||||
return specialFields;
|
||||
}
|
||||
|
||||
private selectTargetFields(specialFields: SpecialFieldInfo[]): Map<number, TargetField[]> {
|
||||
const targetFieldsMap = new Map<number, TargetField[]>();
|
||||
|
||||
specialFields.forEach(field => {
|
||||
if (field.type === 'luck') {
|
||||
// Luck fields don't need target calculations
|
||||
targetFieldsMap.set(field.position, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const targets: TargetField[] = [];
|
||||
const usedTargets = new Set<number>();
|
||||
|
||||
// Generate 6 different target fields (for dice 1-6) with 20-30 rule compliance
|
||||
for (let i = 0; i < 6; i++) {
|
||||
let targetField: number;
|
||||
let distance: number;
|
||||
let attempts = 0;
|
||||
|
||||
do {
|
||||
// Determine max distance based on field position (20-30 rule)
|
||||
let maxDistance: number;
|
||||
let maxBackward: number;
|
||||
|
||||
if (field.position <= 85) {
|
||||
maxDistance = 20;
|
||||
maxBackward = 20;
|
||||
} else {
|
||||
maxDistance = 20; // forward
|
||||
maxBackward = 30; // backward
|
||||
}
|
||||
|
||||
// Create variety in distances within the allowed range
|
||||
const distanceType = Math.random();
|
||||
if (distanceType < 0.5) {
|
||||
// Close distance (50% chance) - 1 to 1/3 of max
|
||||
distance = Math.floor(Math.random() * Math.floor(maxDistance / 3)) + 1;
|
||||
} else {
|
||||
// Far distance (50% chance) - 1/3 to max
|
||||
distance = Math.floor(Math.random() * (maxDistance - Math.floor(maxDistance / 3))) + Math.floor(maxDistance / 3);
|
||||
}
|
||||
|
||||
// Randomly choose forward or backward
|
||||
if (Math.random() < 0.5) {
|
||||
distance = -Math.min(distance, maxBackward);
|
||||
} else {
|
||||
distance = Math.min(distance, maxDistance);
|
||||
}
|
||||
|
||||
targetField = field.position + distance;
|
||||
|
||||
// Ensure target is within valid range
|
||||
if (targetField < 1) targetField = 1;
|
||||
if (targetField > 100) targetField = 100;
|
||||
|
||||
// Recalculate actual distance after clamping
|
||||
distance = Math.abs(targetField - field.position);
|
||||
|
||||
attempts++;
|
||||
} while (usedTargets.has(targetField) && attempts < 30);
|
||||
|
||||
if (!usedTargets.has(targetField)) {
|
||||
usedTargets.add(targetField);
|
||||
targets.push({
|
||||
fieldNumber: targetField,
|
||||
distance: Math.abs(targetField - field.position)
|
||||
});
|
||||
} else {
|
||||
// Fallback: use a nearby valid target
|
||||
let fallbackTarget = field.position + (i - 3); // Create some variety around current position
|
||||
if (fallbackTarget < 1) fallbackTarget = 1;
|
||||
if (fallbackTarget > 100) fallbackTarget = 100;
|
||||
|
||||
targets.push({
|
||||
fieldNumber: fallbackTarget,
|
||||
distance: Math.abs(fallbackTarget - field.position)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
targetFieldsMap.set(field.position, targets);
|
||||
});
|
||||
|
||||
return targetFieldsMap;
|
||||
}
|
||||
|
||||
private createStrategicBorder(targetFieldsMap: Map<number, TargetField[]>): number[] {
|
||||
// Collect all target field numbers
|
||||
const targetNumbers = new Set<number>();
|
||||
targetFieldsMap.forEach(targets => {
|
||||
targets.forEach(target => targetNumbers.add(target.fieldNumber));
|
||||
});
|
||||
|
||||
// Create array of all numbers 1-100
|
||||
const allNumbers = Array.from({ length: 100 }, (_, i) => i + 1);
|
||||
|
||||
// Separate target numbers from remaining numbers
|
||||
const remainingNumbers = allNumbers.filter(num => !targetNumbers.has(num));
|
||||
|
||||
// Shuffle remaining numbers
|
||||
for (let i = remainingNumbers.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[remainingNumbers[i], remainingNumbers[j]] = [remainingNumbers[j], remainingNumbers[i]];
|
||||
}
|
||||
|
||||
// Create border with strategic placement
|
||||
const border: number[] = [];
|
||||
const targetArray = Array.from(targetNumbers);
|
||||
|
||||
// Encourage overlap by placing target numbers first, then fill with random
|
||||
let targetIndex = 0;
|
||||
let remainingIndex = 0;
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
// Alternate between target numbers and remaining numbers, but favor targets when available
|
||||
if (targetIndex < targetArray.length && (remainingIndex >= remainingNumbers.length || Math.random() < 0.6)) {
|
||||
border.push(targetArray[targetIndex]);
|
||||
targetIndex++;
|
||||
} else if (remainingIndex < remainingNumbers.length) {
|
||||
border.push(remainingNumbers[remainingIndex]);
|
||||
remainingIndex++;
|
||||
} else {
|
||||
// Fallback - should not happen if logic is correct
|
||||
border.push((i % 100) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return border;
|
||||
}
|
||||
|
||||
private calculateStepValues(
|
||||
specialFields: SpecialFieldInfo[],
|
||||
targetFieldsMap: Map<number, TargetField[]>,
|
||||
border: number[]
|
||||
): GameField[] {
|
||||
// Initialize all fields as regular
|
||||
const fields: GameField[] = Array.from({ length: 100 }, (_, i) => ({
|
||||
position: i + 1,
|
||||
type: 'regular' as const
|
||||
}));
|
||||
|
||||
// Update special fields with calculated step values
|
||||
specialFields.forEach(specialField => {
|
||||
const fieldIndex = specialField.position - 1; // Convert to 0-based index
|
||||
fields[fieldIndex].type = specialField.type;
|
||||
|
||||
if (specialField.type === 'luck') {
|
||||
// Luck fields don't need step values
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = targetFieldsMap.get(specialField.position) || [];
|
||||
if (targets.length === 0) return;
|
||||
|
||||
// NEW APPROACH: Calculate step value that will land on first target with dice=1
|
||||
// This ensures we have a baseline that works, then dice 2-6 will hit other targets
|
||||
const firstTarget = targets[0];
|
||||
const targetIndexInBorder = border.indexOf(firstTarget.fieldNumber);
|
||||
|
||||
if (targetIndexInBorder !== -1) {
|
||||
// Start from field position in border (field position = border index + 1, but we want 0-based)
|
||||
const startBorderIndex = (specialField.position - 1) % border.length;
|
||||
|
||||
// Calculate step value needed to reach target with dice=1
|
||||
let stepValue: number;
|
||||
|
||||
if (specialField.type === 'positive') {
|
||||
// For positive: move right to target, then +1 more for dice=1
|
||||
stepValue = targetIndexInBorder - startBorderIndex - 1; // -1 for dice offset
|
||||
|
||||
// Handle wrap-around
|
||||
if (stepValue < 0) {
|
||||
stepValue += border.length;
|
||||
}
|
||||
} else {
|
||||
// For negative: move left to target, then -1 more for dice=1
|
||||
stepValue = startBorderIndex - targetIndexInBorder + 1; // +1 for dice offset
|
||||
|
||||
// Handle wrap-around
|
||||
if (stepValue > border.length) {
|
||||
stepValue -= border.length;
|
||||
}
|
||||
|
||||
// Make negative for negative fields
|
||||
stepValue = -stepValue;
|
||||
}
|
||||
|
||||
fields[fieldIndex].stepValue = stepValue;
|
||||
|
||||
// Debug logging for step value calculation
|
||||
logOther(`Calculated step value for ${specialField.type} field at position ${specialField.position}`, {
|
||||
targetField: firstTarget.fieldNumber,
|
||||
targetIndexInBorder,
|
||||
startBorderIndex,
|
||||
calculatedStepValue: stepValue,
|
||||
fieldType: specialField.type
|
||||
});
|
||||
} else {
|
||||
// Fallback if target not found in border (shouldn't happen)
|
||||
fields[fieldIndex].stepValue = specialField.type === 'positive' ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private validateBoardGeneration(fields: GameField[], border: number[]): {
|
||||
validationResults: { [fieldIndex: number]: number[] };
|
||||
errorRate: number;
|
||||
} {
|
||||
const validationResults: { [fieldIndex: number]: number[] } = {};
|
||||
let totalCombinations = 0;
|
||||
let invalidCombinations = 0;
|
||||
|
||||
fields.forEach((field, fieldIndex) => {
|
||||
if (field.type !== 'positive' && field.type !== 'negative') {
|
||||
return; // Skip non-special fields
|
||||
}
|
||||
|
||||
const diceOutcomes: number[] = [];
|
||||
|
||||
for (let diceValue = 1; diceValue <= 6; diceValue++) {
|
||||
totalCombinations++;
|
||||
|
||||
try {
|
||||
const result = this.calculateBorderMovement(
|
||||
field.position,
|
||||
field.stepValue || 0,
|
||||
diceValue,
|
||||
border,
|
||||
field.type === 'positive'
|
||||
);
|
||||
|
||||
// Validate 20-30 rule
|
||||
const distance = Math.abs(result - field.position);
|
||||
const isValid = this.validate20_30Rule(field.position, result, distance);
|
||||
|
||||
if (isValid) {
|
||||
diceOutcomes.push(result);
|
||||
} else {
|
||||
diceOutcomes.push(-1); // Mark as invalid
|
||||
invalidCombinations++;
|
||||
}
|
||||
} catch (error) {
|
||||
diceOutcomes.push(-1); // Mark as invalid
|
||||
invalidCombinations++;
|
||||
}
|
||||
}
|
||||
|
||||
validationResults[fieldIndex] = diceOutcomes;
|
||||
});
|
||||
|
||||
const errorRate = totalCombinations > 0 ? (invalidCombinations / totalCombinations) * 100 : 0;
|
||||
|
||||
return {
|
||||
validationResults,
|
||||
errorRate: Math.round(errorRate * 100) / 100 // Round to 2 decimal places
|
||||
};
|
||||
}
|
||||
|
||||
private calculateBorderMovement(
|
||||
currentPosition: number,
|
||||
stepValue: number,
|
||||
diceValue: number,
|
||||
border: number[],
|
||||
isPositive: boolean
|
||||
): number {
|
||||
// Step 1: Find border index for current field (field position corresponds to border index)
|
||||
let borderIndex = (currentPosition - 1) % border.length; // Convert to 0-based, handle wraparound
|
||||
|
||||
// Step 2: Apply field step value (handle negative step values for negative fields)
|
||||
if (isPositive) {
|
||||
borderIndex = (borderIndex + Math.abs(stepValue)) % border.length;
|
||||
} else {
|
||||
// For negative fields, stepValue is already negative, so we subtract it (which adds its absolute value)
|
||||
borderIndex = (borderIndex - stepValue + border.length) % border.length;
|
||||
}
|
||||
|
||||
// Step 3: Apply dice value
|
||||
if (isPositive) {
|
||||
borderIndex = (borderIndex + diceValue) % border.length;
|
||||
} else {
|
||||
borderIndex = (borderIndex - diceValue + border.length) % border.length;
|
||||
}
|
||||
|
||||
// Step 4: Return the field number at final border position
|
||||
return border[borderIndex];
|
||||
}
|
||||
|
||||
private validate20_30Rule(currentPosition: number, targetPosition: number, distance: number): boolean {
|
||||
// Fields 1-85: max 20 fields in any direction
|
||||
if (currentPosition <= 85) {
|
||||
return distance <= 20;
|
||||
}
|
||||
|
||||
// Fields 86-100: max 30 fields backward, max 20 fields forward
|
||||
if (currentPosition > 85) {
|
||||
if (targetPosition > currentPosition) {
|
||||
// Moving forward: max 20 fields
|
||||
return distance <= 20;
|
||||
} else {
|
||||
// Moving backward: max 30 fields
|
||||
return distance <= 30;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private generateFallbackBoard(
|
||||
positiveFieldCount: number,
|
||||
negativeFieldCount: number,
|
||||
luckFieldCount: number
|
||||
): BoardData {
|
||||
// Simple fallback: create basic board with minimal special fields
|
||||
const fields: GameField[] = Array.from({ length: 100 }, (_, i) => ({
|
||||
position: i + 1,
|
||||
type: 'regular' as const
|
||||
}));
|
||||
|
||||
// Add a few special fields with safe step values
|
||||
let specialCount = 0;
|
||||
for (let i = 10; i < 90 && specialCount < positiveFieldCount + negativeFieldCount; i += 10) {
|
||||
if (specialCount < positiveFieldCount) {
|
||||
fields[i].type = 'positive';
|
||||
fields[i].stepValue = 1;
|
||||
} else {
|
||||
fields[i].type = 'negative';
|
||||
fields[i].stepValue = -1;
|
||||
}
|
||||
specialCount++;
|
||||
}
|
||||
|
||||
// Simple border: shuffled 1-100
|
||||
const border = Array.from({ length: 100 }, (_, i) => i + 1);
|
||||
for (let i = border.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[border[i], border[j]] = [border[j], border[i]];
|
||||
}
|
||||
|
||||
return {
|
||||
fields,
|
||||
border,
|
||||
validationResults: {},
|
||||
totalErrorRate: 100 // Mark as fallback
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user