rules = $rules; } /** * @param array $floors engine floors (rooms with type, direction, name, area) * @return array{floors: array, gain_weighted: float} */ public function suggest(array $floors): array { $cfg = $this->rules->swaps(); $pattern = '/' . $cfg['flexible_name_pattern'] . '/i'; $out = []; $totalGain = 0.0; foreach ($floors as $f) { $slots = []; $hasLiving = count(array_filter($f['rooms'], fn($r) => $r['type'] === 'living')) > 1; foreach ($f['rooms'] as $r) { if ($r['direction'] === 'C') continue; $flex = in_array($r['type'], $cfg['flexible_types'], true) || ($r['type'] === 'living' && preg_match($pattern, $r['name'])) || ($r['type'] === 'living' && $hasLiving && preg_match('/^living( room)?$/i', trim($r['name']))); if ($flex) $slots[] = $r; } if (count($slots) < 2 || count($slots) > self::MAX_ROOMS) continue; $uses = array_map(fn($r) => $r['type'], $slots); $value = fn(string $type, string $dir) => $this->rules->room($type)['weight'] * $this->rules->room($type)['scores'][$dir]; // Simple swaps only: exchange the uses of two rooms. Take the best swap, then at most // one more swap between two other rooms. Each must help by itself. $best = $uses; $gain = 0.0; $locked = []; for ($round = 0; $round < 2; $round++) { $pick = null; $pickGain = 0.0; for ($i = 0; $i < count($slots); $i++) { for ($j = $i + 1; $j < count($slots); $j++) { if (isset($locked[$i]) || isset($locked[$j]) || $best[$i] === $best[$j]) continue; $perm = $best; [$perm[$i], $perm[$j]] = [$perm[$j], $perm[$i]]; if (!$this->allowed($perm, $uses, $slots, $cfg)) continue; $g = $value($perm[$i], $slots[$i]['direction']) + $value($perm[$j], $slots[$j]['direction']) - $value($best[$i], $slots[$i]['direction']) - $value($best[$j], $slots[$j]['direction']); if ($g > $pickGain + 1e-9) { $pickGain = $g; $pick = [$i, $j]; } } } if (!$pick || $pickGain < $cfg['min_total_gain']) break; [$i, $j] = $pick; [$best[$i], $best[$j]] = [$best[$j], $best[$i]]; $locked[$i] = $locked[$j] = true; $gain += $pickGain; } if ($gain <= 0) continue; $bestVal = $gain; $current = 0.0; // pair each changed room with the room its new use comes from (one to one) $sources = []; foreach ($slots as $i => $s) if ($best[$i] !== $uses[$i]) $sources[$uses[$i]][] = $i; $moves = []; $bigWin = false; foreach ($slots as $i => $s) { if ($best[$i] === $uses[$i]) continue; $from = array_shift($sources[$best[$i]]); $toDef = $this->rules->room($best[$i]); $newScore = (int)$toDef['scores'][$s['direction']]; $oldScoreOfUse = (int)$toDef['scores'][$slots[$from]['direction']]; if ($newScore - $oldScoreOfUse >= $cfg['min_room_gain']) $bigWin = true; $moves[] = [ 'room' => $s['name'], 'direction' => $s['direction'], 'now_use' => $this->rules->room($uses[$i])['label'], 'now_score' => (int)$this->rules->room($uses[$i])['scores'][$s['direction']], 'new_use' => $toDef['label'], 'new_type' => $best[$i], 'new_score' => $newScore, 'from_room' => $slots[$from]['name'], 'from_direction' => $slots[$from]['direction'], 'use_score_before' => $oldScoreOfUse, ]; } if (!$moves || !$bigWin) continue; $out[] = ['floor' => $f['label'], 'moves' => $moves, 'gain_weighted' => round($gain, 1)]; $totalGain += $gain; } return ['floors' => $out, 'gain_weighted' => $totalGain]; } private function allowed(array $perm, array $uses, array $slots, array $cfg): bool { foreach ($perm as $i => $type) { $area = $slots[$i]['area'] ?? null; if ($area === null) continue; if ($type === 'primary_bedroom') { $was = array_search('primary_bedroom', $uses, true); $wasArea = $slots[$was]['area'] ?? null; if ($wasArea && $area < $cfg['min_primary_area_ratio'] * $wasArea) return false; } if (in_array($type, ['primary_bedroom', 'bedroom', 'kids_bedroom', 'guest_bedroom'], true) && $area < $cfg['min_bedroom_area_sqft']) return false; } return true; } }