building(['kind' => 'commercial', 'entrance' => 'E', * 'rooms' => [['name' => 'Owner Cabin', 'direction' => 'SW'], ...]]); * $p = $ps->plot(['road' => 'E', 'slope' => 'NE', 'shape' => 'square', 'ratio' => 1.2, ...]); */ namespace VastuIQWP\Engine; defined( 'ABSPATH' ) || exit; final class PropertyScore { public const VERSION = '1.0.0'; private const DIRS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; private const DIR_NAMES = ['N' => 'North', 'NE' => 'North-East', 'E' => 'East', 'SE' => 'South-East', 'S' => 'South', 'SW' => 'South-West', 'W' => 'West', 'NW' => 'North-West', 'C' => 'Centre']; private const KINDS = ['commercial', 'industrial']; private array $d; private array $alias = []; // kind => [alias => type], longest first public function __construct(?string $file = null) { $file = $file ?? dirname(__DIR__) . '/data/rules-business.json'; $this->d = json_decode(is_readable($file) ? (string)file_get_contents($file) : '', true) ?: []; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reads a file bundled with the plugin. if (empty($this->d['rooms']) || empty($this->d['plot'])) throw new \RuntimeException('Business rules missing'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. foreach (self::KINDS as $k) $this->alias[$k] = []; foreach ($this->d['rooms'] as $type => $r) { foreach ($r['kinds'] as $k) foreach ($r['aliases'] as $a) $this->alias[$k][$a] = $type; } foreach (self::KINDS as $k) uksort($this->alias[$k], fn($a, $b) => (strlen($b) <=> strlen($a)) ?: strcmp($a, $b)); } public function version(): string { return $this->d['version'] ?? '?'; } public function rules(): array { return $this->d; } public static function dirName(string $d): string { return self::DIR_NAMES[$d] ?? $d; } /** Room types offered for a property kind: [type => label]. */ public function types(string $kind): array { $kind = $this->kind($kind); $out = []; foreach ($this->d['rooms'] as $type => $r) if (in_array($kind, $r['kinds'], true)) $out[$type] = $r['label']; return $out; } /** Room type for a label ("Director / Owner Cabin" → owner_cabin), or null. Own kind first, then the other kind. */ public function match(string $kind, string $name): ?string { $kind = $this->kind($kind); $s = Rules::clean($name); if ($s === '') return null; $s2 = trim(preg_replace('/\s*\d+$/', '', $s)); $order = [$kind, $kind === 'commercial' ? 'industrial' : 'commercial']; foreach ($order as $k) { if (isset($this->alias[$k][$s])) return $this->alias[$k][$s]; if (isset($this->alias[$k][$s2])) return $this->alias[$k][$s2]; } foreach ($order as $k) { foreach ($this->alias[$k] as $a => $type) { if (strlen($a) >= 3 && preg_match('/(^|[\s\/&-])' . preg_quote($a, '/') . '($|[\s\/&-])/', $s)) return $type; } } return null; } /** * Score a commercial or industrial building. * $in = ['kind' => 'commercial'|'industrial', 'entrance' => 'N'..'NW' or null, * 'rooms' => [['name' => 'Owner Cabin', 'type' => optional, 'direction' => 'N'..'NW'|'C'], ...]] */ public function building(array $in): array { $kind = $this->kind((string)($in['kind'] ?? '')); $pen = $this->d['centre_penalty']; $sum = 0.0; $wsum = 0.0; $penalty = 0; $majors = []; $rooms = []; $unmatched = []; $notices = []; $entrance = null; $ed = strtoupper(trim((string)($in['entrance'] ?? ''))); if ($ed !== '') { if (!in_array($ed, self::DIRS, true)) throw new \InvalidArgumentException('Choose a direction for the main entrance'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. $e = $this->d['entrance']; $s = (int)$e['scores'][$ed]; $sum += $s * $e['weight']; $wsum += $e['weight']; $isMajor = in_array($ed, $e['major'], true); if ($isMajor) $majors[] = ['type' => 'entrance', 'label' => $e['label'], 'direction' => $ed, 'text' => $e['label'] . ' in the ' . self::dirName($ed)]; $entrance = ['direction' => $ed, 'direction_name' => self::dirName($ed), 'score' => $s, 'weight' => $e['weight'], 'major' => $isMajor, 'ideal' => $this->ideal($e['scores']), 'evidence' => $e['evidence'], 'basis' => $e['basis']]; } else { $notices[] = ['id' => 'no_entrance', 'text' => 'No main entrance given, so it is not part of the score.']; } $list = is_array($in['rooms'] ?? null) ? array_slice($in['rooms'], 0, 60) : []; foreach ($list as $r) { $name = trim(mb_substr((string)($r['name'] ?? ''), 0, 60)); $dir = strtoupper(trim((string)($r['direction'] ?? ''))); $type = (string)($r['type'] ?? ''); if ($type === '' || !isset($this->d['rooms'][$type])) $type = $name !== '' ? (string)$this->match($kind, $name) : ''; if ($type === '' || !isset($this->d['rooms'][$type])) { if ($name !== '') $unmatched[] = $name; continue; } if ($dir === '' || $dir === 'NA') continue; // "not applicable" if ($dir !== 'C' && !in_array($dir, self::DIRS, true)) throw new \InvalidArgumentException('Choose a direction for ' . ($name ?: $type)); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. $rule = $this->d['rooms'][$type]; $label = $name !== '' ? $name : $rule['label']; $isMajor = in_array($dir, $rule['major'], true); if ($isMajor) $majors[] = ['type' => $type, 'label' => $label, 'direction' => $dir, 'text' => $label . ($dir === 'C' ? ' in the centre' : ' in the ' . self::dirName($dir))]; $row = ['name' => $label, 'type' => $type, 'type_label' => $rule['label'], 'direction' => $dir, 'direction_name' => self::dirName($dir), 'weight' => $rule['weight'], 'major' => $isMajor, 'ideal' => $this->ideal($rule['scores']), 'evidence' => $rule['evidence'], 'basis' => $rule['basis'], 'placement' => $rule['placement'] ?? '', 'remedies' => array_map(fn($x) => ['tier' => $x[0], 'text' => $x[1]], $rule['remedies'] ?? [])]; if ($dir === 'C') { $p = $rule['centre'] ? (int)$pen[$rule['centre']] : 0; $penalty += $p; $row['score'] = null; $row['centre_penalty'] = $p; } else { $s = (int)$rule['scores'][$dir]; $sum += $s * $rule['weight']; $wsum += $rule['weight']; $row['score'] = $s; $row['centre_penalty'] = 0; } $rooms[] = $row; } if ($wsum <= 0) throw new \InvalidArgumentException('Add the main entrance or at least one room with a direction'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. $avg = $sum / $wsum; $penUsed = min((int)$this->d['centre_penalty_cap'], $penalty); $uncapped = max(0, min(100, self::round($avg - $penUsed))); $cap = (int)$this->d['major_cap']; $overall = $majors ? min($uncapped, $cap) : $uncapped; if ($majors && $uncapped > $cap) { $notices[] = ['id' => 'major_defect', 'text' => 'A major Vastu defect caps the score at ' . $cap . ' (it would otherwise be ' . $uncapped . ').']; } if ($unmatched) $notices[] = ['id' => 'unmatched', 'text' => 'Not scored (room type not recognised): ' . implode(', ', $unmatched) . '.']; $band = Rules::rating($overall); // weakest rooms first: major defects, then lowest score, then centre penalty $issues = []; foreach ($rooms as $r) { if ($r['major'] || ($r['score'] !== null && $r['score'] < 60) || $r['centre_penalty'] > 0) $issues[] = $r; } if ($entrance && ($entrance['major'] || $entrance['score'] < 60)) $issues[] = ['name' => 'Main entrance', 'type' => 'entrance'] + $entrance + ['centre_penalty' => 0]; usort($issues, fn($a, $b) => ($b['major'] <=> $a['major']) ?: (($a['score'] ?? -1) <=> ($b['score'] ?? -1)) ?: strcmp($a['name'], $b['name'])); return [ 'engine' => 'business', 'engine_version' => self::VERSION, 'rules_version' => $this->version(), 'kind' => $kind, 'score' => ['overall' => $overall, 'uncapped' => $uncapped, 'capped' => $majors && $uncapped > $cap, 'cap' => $cap, 'band' => $band['label'], 'band_id' => $band['id'], 'average' => round($avg, 2), 'centre_penalty' => $penUsed, 'weight_total' => round($wsum, 2)], 'entrance' => $entrance, 'rooms' => $rooms, 'major_defects' => $majors, 'issues' => $issues, 'unmatched' => $unmatched, 'notices' => $notices, 'notes' => $this->d['notes'], 'method' => 'Each room scores 0–100 for its direction (main entrance weight 1.3). Score = weighted average, minus up to 15 points for rooms in the centre, rounded. A major defect caps the score at ' . $cap . '.', ]; } /** * Score an open plot. $a = ['road' => 'E', 'slope' => 'NE'|'flat'|..., 'shape' => 'square', 'ratio' => 1.4 * (or 'length' + 'width'), 'water' => 'none'|dir, 'roads' => 'N+E'|'N or E'|'none'|'S or W', * 'shoola' => 'none'|'N/NE/E side'|'other side'|'SW side', 'ext' => 'none'|dir, 'wall' => 'all 4'|..., 'trees' => ...] */ public function plot(array $a): array { $tot = 0.0; $majors = []; $factors = []; foreach ($this->d['plot'] as $key => $f) { if ($key === 'ratio') { $r = $this->ratio($a); $v = 30; foreach ($f['bands'] as [$max, $score]) if ($max === null || $r <= $max + 1e-9) { $v = $score; break; } $answer = round($r, 2) . ':1'; $isMajor = false; } else { $ans = (string)($a[$key] ?? ''); if (!isset($f['options'][$ans])) { $ans = $this->optionFor($f['options'], $ans); if ($ans === null) throw new \InvalidArgumentException('Answer the question: ' . $f['label']); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. } $v = (int)$f['options'][$ans]; $answer = $ans; $isMajor = in_array($ans, $f['major'], true); } $tot += $v * $f['weight']; if ($isMajor) $majors[] = ['factor' => $key, 'label' => $f['label'], 'answer' => $answer, 'text' => $f['label'] . ': ' . self::plotText($key, (string)$answer)]; $rem = []; foreach ($f['remedies'] ?? [] as $rm) { $hit = $key === 'ratio' ? (in_array('>2', $rm['when'], true) && $r > 2 + 1e-9) : in_array($answer, $rm['when'], true); if ($hit) $rem[] = ['tier' => $rm['tier'], 'text' => $rm['text']]; } $best = $key === 'ratio' ? ['1.25:1'] : $this->ideal($f['options']); $factors[] = ['factor' => $key, 'label' => $f['label'], 'answer' => $answer, 'answer_text' => self::plotText($key, (string)$answer), 'score' => $v, 'weight' => $f['weight'], 'major' => $isMajor, 'evidence' => $f['evidence'], 'basis' => $f['basis'], 'best' => $best, 'best_text' => $key === 'ratio' ? 'Up to 1.25:1 (length no more than a quarter longer than the width)' : self::bestText($key, $best), 'remedies' => $rem]; } $uncapped = max(0, min(100, self::round($tot / 100))); $cap = (int)$this->d['major_cap']; $overall = $majors ? min($uncapped, $cap) : $uncapped; $band = Rules::rating($overall); $notices = []; if ($majors && $uncapped > $cap) $notices[] = ['id' => 'major_defect', 'text' => 'A major Vastu defect caps the score at ' . $cap . ' (it would otherwise be ' . $uncapped . ').']; return [ 'engine' => 'business', 'engine_version' => self::VERSION, 'rules_version' => $this->version(), 'kind' => 'plot', 'score' => ['overall' => $overall, 'uncapped' => $uncapped, 'capped' => $majors && $uncapped > $cap, 'cap' => $cap, 'band' => $band['label'], 'band_id' => $band['id']], 'factors' => $factors, 'major_defects' => $majors, 'notices' => $notices, 'method' => 'Ten site factors, each 0–100, weighted (weights add up to 100). A major defect caps the score at ' . $cap . '.', ]; } /** The best answers in one phrase: "Lowest on the North-East, North or East". */ public static function bestText(string $key, array $best): string { $D = ['N' => 'North', 'NE' => 'North-East', 'E' => 'East', 'SE' => 'South-East', 'S' => 'South', 'SW' => 'South-West', 'W' => 'West', 'NW' => 'North-West']; $or = function (array $x) { return count($x) > 1 ? implode(', ', array_slice($x, 0, -1)) . ' or ' . end($x) : ($x[0] ?? ''); }; $dirs = array_values(array_filter($best, fn($b) => isset($D[$b]))); $pre = ['road' => '', 'slope' => '', 'water' => '', 'ext' => 'Extended in the '][$key] ?? null; if ($pre !== null && count($dirs) === count($best)) return $pre . $or(array_map(fn($d) => $D[$d], $dirs)); if ($key === 'roads') return 'Roads on the North and East, or on one of them'; return ucfirst($or(array_map(fn($o) => lcfirst(self::plotText($key, (string)$o)), $best))); } /** A plot answer in plain words. */ public static function plotText(string $key, string $a): string { $D = ['N' => 'North', 'NE' => 'North-East', 'E' => 'East', 'SE' => 'South-East', 'S' => 'South', 'SW' => 'South-West', 'W' => 'West', 'NW' => 'North-West']; switch ($key) { case 'road': return $D[$a] ?? $a; case 'slope': return $a === 'flat' ? 'Level' : ($D[$a] ?? $a); case 'water': return $a === 'none' ? 'None' : ($D[$a] ?? $a); case 'ext': return $a === 'none' ? 'None' : (isset($D[$a]) ? 'Extended in the ' . $D[$a] : $a); case 'shape': return ['square' => 'Square', 'rectangle' => 'Rectangle', 'gaumukhi' => 'Gaumukhi (narrow front, wider back)', 'sherdaha' => 'Sherdaha (wide front, narrow back)', 'L-shaped' => 'L-shaped', 'irregular' => 'Irregular', 'circular' => 'Circular', 'triangular' => 'Triangular'][$a] ?? $a; case 'roads': return ['N+E' => 'North and East', 'N or E' => 'North or East', 'none' => 'None', 'S or W' => 'South or West'][$a] ?? $a; case 'shoola': return ['none' => 'None', 'N/NE/E side' => 'From the North, North-East or East', 'other side' => 'From the South-East, South, West or North-West', 'SW side' => 'From the South-West'][$a] ?? $a; case 'wall': return ['all 4' => 'On all 4 sides', '3 sides' => 'On 3 sides', '2 sides' => 'On 2 sides', 'none' => 'None'][$a] ?? $a; case 'trees': return ['large trees S/W' => 'Large trees on the South or West', 'none' => 'None', 'mixed' => 'Mixed', 'large trees N/E/NE' => 'Large trees on the North, East or North-East', 'thorny or milky trees' => 'Thorny or milky-sap trees'][$a] ?? $a; } return $a; } /** Half rounds up, as in the PHP engine and the approved rules (95.5 → 96). */ public static function round(float $x): int { return (int)floor($x + 0.5 + 1e-9); } private function kind(string $k): string { $k = strtolower(trim($k)); if (!in_array($k, self::KINDS, true)) throw new \InvalidArgumentException('Property kind must be commercial or industrial'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. return $k; } /** Directions that score 85+ (best first), or the single best one. */ private function ideal(array $scores): array { arsort($scores); $best = array_keys(array_filter($scores, fn($s) => $s >= 85)); return $best ?: [array_key_first($scores)]; } private function ratio(array $a): float { if (isset($a['length'], $a['width']) && (float)$a['length'] > 0 && (float)$a['width'] > 0) { $l = (float)$a['length']; $w = (float)$a['width']; return max($l, $w) / min($l, $w); } $r = (float)($a['ratio'] ?? 0); if ($r <= 0) throw new \InvalidArgumentException('Give the plot length and width'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. return $r < 1 ? 1 / $r : $r; } /** Case-insensitive option match ("Square" → "square", "ne" → "NE"). */ private function optionFor(array $options, string $ans): ?string { $n = strtolower(trim($ans)); foreach (array_keys($options) as $o) if (strtolower($o) === $n) return $o; return null; } }